Authentification moderne pour EWS sous PowerShell

Par Alexandre COTREZ 8 octobre 2022

Dans les métiers d’administrateur de l’IT il est fréquent de développer des outils pour automatiser les tâches récurrentes. EWS a longtemps été la star des scripts PowerShell destinés à administrer Exchange et c’est toujours le cas. Seulement, comme Ludovik l’a expliqué dans son article sur la suppression des protocoles hérités, leurs décommissionnements est en cours sur la plateforme Microsoft 365 a démarré ce 1er Octobre 2022 et le protocole d’authentification basique en fait parti. Microsoft ne supporte donc plus l’authentification basique sur EWS et demande de migrer sur une authentification moderne. Je vous propose de découvrir, dans cette article, comment conserver vos scripts EWS fonctionnel en modifiant le code d’authentification.

Exchange Web Service

Exchange Web Service (EWS) est un ensemble d’API reposant sur un système de requêtes / réponse SOAP, un protocole d’échange basé sur la notation XML largement surclassé par le protocole REST. Très souvent, les scripts / applications EWS utilisent une authentification basique, ne supportant donc pas les fonctionnalités de sécurité récente comme le MFA. Par ailleurs, les comptes de services utilisés sont toujours associés à des automates sans interaction humaine rendant impossible une validation de l’identité par téléphone ou par l’application Authenticator. Ces comptes à privilèges sont donc de fait doublement vulnérables et sensibles aux attaques de brute force ou de password spray. D’une part parce qu’ils utilisent un protocole hérité non sécurisé et de l’autre puisqu’une authentification forte n’est pas possible à cause de leur usage système.

Méthode d’authentification

Authentification basique

L’authentification basique (Basic Auth) est un schéma d’authentification HTTP leger qui permet l’accès aux ressources en utilisant un compte et un mot de passe. Une fois authentifié, l’utilisateur peut consommer les applications si les permissions lui sont octroyées. Le couple utilisateur / mot de passe transite dans chaque requête via le header d’authorisation avec un encodage de type Base64 et ce protocole n’expose pas de solution native pour la protection contre le brute force.

Authentification moderne (oauth)

Ce protocole d’authentification est très différent dans l’approche, l’accès ne dépend pas du compte et du mot de passe même s’il est un point d’entrée dans le processus de connexion, mais d’un jeton émis par une autorité (serveur d’authorisation) pour un périmètre bien défini (audience). Ce jeton, appelé JWT pour « Json Web Token », peut contenir un grand nombre d’informations appelées revendications, exploitables pour ajouter plus de sécurité ou à des fin applicatives. Parmi ces revendications nous retrouverons par exemple la possibilité d’ajouter des permissions sur les ressources, le status MFA, Microsoft ajoute également des informations sur les réseaux permettant d’exploiter les emplacements nommés dans Azure Active Directory.

Vue high level OAuth

Du point de vue de la sécurité, on comprend ici que le compte et le mot de passe ne sont plus suffisants. Azure Active Directory exploite les fonctionnalités de ce protocole au travers des accès conditionnels.

Il existe différentes méthodes/flux pour obtenir un token JWT parmi lesquels on notera le “Code flow”, généralement utilisé par les applications WEB ou le “Client Credential flow” plus utilisé par les services / backend / application headless. Dans ce cas, le token est délivré à l’aide d’un ID de client couplé à un secret ou d’un certificat. Vous trouverez plus d’information sur les flux d’authentification ici.

Changer la méthode d’authentification sur EWS

Avant de continuer, précisions tout de même qu’il est préférable d’utiliser l’API Graph plutôt que EWS pour accéder à la gestion programmatique d’Exchange Online. En effet, Microsoft a annoncé l’arrêt des mise à jours de EWS en Aout 2018 et recommande de migrer les développements EWS vers Microsoft Graph. Nous vous présentons ici comment se séparer de la basic auth dans ce contexte, mais EWS n’en reste pas moins un composant obsolète qui pourrait être éventuellement décomissionné par Microsoft à l’avenir.

Inscription d’application

Dans Azure AD, les automates peuvent s’authentifier grâce à des applications. L’objet d’application est un modèle alors que le principal de service qui en découle sert à la connexion, il sera créé dans tous les tenants Azure AD ou l’application est utilisée dans le cas d’une application Multi-Tenant. Cette fonctionalité nous permett de nous passer de classique « compte de service » qui ne sont finalement que des comptes utilisateurs destinés à un usage IT. La première étape est donc de créer une application comme le montre la capture suivante

Enregistrez une application dans Azure AD

Entrez un nom, et l’étendu des accès (single tenant si votre script ne s’exécute que sur une instance Exchange online) puis cliquez sur « Register »

API Permissions

Une fois l’application créée, il faut lui donner les droits d’utiliser Exchange Web Service. Il existe deux type de permissions :

  • Les permissions déléguées nécessitant une connexion utilisateur. Dans ce cas l’application utilise les droits de l’utilisateurs une fois que le consentement a été donné.
  • Les permissions d’application plutôt destinées aux services d’arrière-plan ou aux scripts qui auront directement les droits sans avoir besoin de connexion utilisateur à condition qu’un administrateur ait donné son autorisation (consentement). Nous ajoutons donc des permissions au niveau de l’application pour que le ou les scripts puisse s’exécuter sans interventions humaines.

Cherchez les permissions EWS

Pour se faire, cliquer sur « API Permissions » > « Add a permission ». Sélectionnez l’onglet « APIs my organisation uses » puis cherchez « Office 365 Exchange Online ». La capture suivante montre l’ensemble des permissions disponibles après avoir cliqué sur « Application permissions ».

Ajoutez les permissions EWS

N’oubliez pas de donner le consentement d’administrateur à votre application ou elles ne seront pas effective.

Secret ou certificat ?

Une fois les permissions appliquées nous devons protéger l’application par un secret (l’équivalent d’un mot de passe) ou d’un certificat. Dans le premier cas, il nous suffira de renseigner le secret pour se connecter et c’est assez simple, alors que pour le second il faudra bien évidemment posséder le certificat pour s’authentifier et le code est un peu plus complexe. Si vous souhaitez utiliser un secret alors cliquez sur « Certificate & Secret », puis onglet « Client Secret ». Cliquez ensuite sur « New client secret » puis renseigner les champs requis avant de valider en cliquant sur le bouton « Add ».

Ajoutez les permissions EWS

Attention a bien conserver le secret lors de sa création car il ne sera plus possible de le récupérer plus tard. Vous pouvez alors utiliser la fonction suivante pour obtenir un token valable pour l’usage Exchange Web Service.

Function Get-MSAccessTokenWithCredentialFlow {
    Param(
        [string]$Audience,
        [string]$ClientID,
        [string]$ClientSecret,
        [string]$TenantID
    )

    $loginEndPoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"

    #login using credential flow
    $body = @{`
        client_id=$clientID;
        client_secret=$clientSecret;
        grant_type="client_credentials";
        scope=$audience; `
    }

    $response = Invoke-RestMethod `
        -Method Post `
        -Uri $loginEndPoint `
        -Body $Body

    return $response.access_token

}

L’audience, périmètre dont nous avons parlé un peu plus haut doit être : https://outlook.office.com/.default

Le .default permet à l’application d’obtenir l’ensemble des permissions positionnées pour l’application. Dans le cas ou nous ne souhaiterions qu’une des permissions disponibles, il faudrait alors la spécifier. Petite précision interessante, il serait possible de changer l’audience pour https://graph.microsoft.com/.default si l’objectif était de consommer l’API MSgraph. Le code pour l’authentification ne changera pas comme pour tous les services d’API fournis par Microsoft d’ailleurs.

Le code indiqué nécessite que le module Microsoft.Graph soit installé, car la fonction utilise un cmdlet inclus dans ce module, « Invoke-RestMethod » que vous pouvez facilement remplacer par « Invoke-WebRequest » si vous ne souhaitez pas avoir de dépendance. Dans ce cas le pointeur de variable de retour ne serait plus $response.access_token mais $($response.Content | convertfrom-json).access_token En effet, Invoke-RestMethod converti directement le contenu du Json renvoyé dans la propriété content pour le retourner.

La méthode la plus sécurisée reste tout de même une authentification par certificat. Le code est un peu plus compliqué mais cela évitera la tentation de stocker l’ID de l’application et le secret en dur dans votre script ou un développement plus complexe pour stocker ce secret de façon sécurisé. Pas de panique, on vous donne aussi la solution 😊 Tout d’abord il faudra uploader le certificat en question pour l’application sur Azure Active Directory.

Ajoutez les permissions EWS

Après avoir cliqué sur « Certificates & secrets », sélectionnez l’onglet « Certificates » puis cliquez sur « Upload certificate » pour voir apparaitre le menu de chargement. Enfin, la fonction suivante vous permettra de vous authentifier à l’aide d’une application sécurisée par un certificat.

Function Get-MSAccessTokenWithCredentialFlow
{
    Param
    (
      [string]$thumbprint,
      [int]$TokenExpiryInMinutes,
      [string]$clientId,
      [string]$tenantId,
      [string]$Audience
    )

    $loginEndPoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"

    [System.Reflection.Assembly]::LoadWithPartialName('system.identitymodel') | Out-Null


    $cert = (dir Cert:\CurrentUser\my).Where{$_.Thumbprint -eq $Thumbprint}[0]
    $certHash = [System.Convert]::ToBase64String($cert.GetCertHash())

    $StartDate = (Get-Date "1970-01-01T00:00:00Z" ).ToUniversalTime()
    $JWTExpiration = [int]((New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes($TokenExpiryInMinutes)).TotalSeconds)

    $JWTHeader = @{
        alg = "RS256"
        typ = "JWT"
        x5t = ($certHash -replace '\+','-' -replace '/','_' -replace '=')
    }
    

    $JWTPayLoad = @{
        aud = $loginEndPoint
        exp = $JWTExpiration
        iss = $clientId
        jti = [guid]::NewGuid()
        sub = $clientId
    }
   
    $EncodedHeader = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json)))
    $EncodedPayload = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json)))
    $JWT = $EncodedHeader + "." + $EncodedPayload

    $dataToSign = [byte[]] [System.Text.Encoding]::UTF8.GetBytes($JWT)
    $algo = (new-object System.IdentityModel.Tokens.X509AsymmetricSecurityKey($cert)).GetAsymmetricAlgorithm("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", $true) -as [System.Security.Cryptography.RSA]

    if($algo -is [System.Security.Cryptography.RSACryptoServiceProvider])
    {
        if(($algo.CspKeyContainerInfo.ProviderType -ne 1) -and ($algo.CspKeyContainerInfo.ProviderType -ne 12) -or $algo.CspKeyContainerInfo.HardwareDevice)
        {
            $csp = $algo -as [System.Security.Cryptography.RSACryptoServiceProvider]
        }
        else
        {
            $cspParams = new-object System.Security.Cryptography.CspParameters
            $cspParams.ProviderType=24
            $cspParams.KeyContainerName=$algo.CspKeyContainerInfo.KeyContainerName
            $cspParams.KeyNumber = $algo.CspKeyContainerInfo.KeyNumber
            $cspParams.Flags = 'UseExistingkey'
            if($algo.CspKeyContainerInfo.MachineKeyStore) {$cspParams.Flags = $cspParams.Flags -bor 'UseMachineKeyStore'}

            $csp = new-object System.Security.Cryptography.RSACryptoServiceProvider($cspParams)
        }

        $sha256 = new-object System.Security.Cryptography.SHA256Cng

        $Signature = [Convert]::ToBase64String($csp.SignData($dataToSign,$sha256))
    }
    else
    {
        $csp = $algo -as [System.Security.Cryptography.RsaCng]
        $sha256 = new-object System.Security.Cryptography.SHA256Cng
        $hash = $sha256.ComputeHash($dataToSign)
        $Signature = [Convert]::ToBase64String($csp.SignHash($hash))
    }

    $JWT = $JWT + "." + ($Signature -replace '\+','-' -replace '/','_' -replace '=')

    $Body = @{
        client_id = $clientId
        client_assertion = $JWT
        client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
        grant_type = "client_credentials"
        scope = $Audience
    }

    $headers= @{}
    $headers['Authorization'] = "Bearer $JWT"

    $request = @{
        ContentType = 'application/x-www-form-urlencoded'
        Method = 'POST'
        Body = $Body
        Uri = $Loginendpoint
        Headers = $headers
    }

    $response = Invoke-RestMethod @request
   
    return $response.access_token
} 

Nous pourrions améliorer en variabilisant le conteneur de certificat, ici Cert:\CurrentUser\my et ajouter des parameterset pour inclure la méthode d’authentification par secret. Ceci permettrait d’avoir les deux modes de fonctionnement dans une seule et même fonction.

Le paramètre TokenExpiryInMinutes permet de configurer la durée de validité du token émis. Je vous conseille de la contenir à la plus faible valeur possible pour des questions évidentes de sécurité. Petit point d’attention également, n’oubliez pas que vos certificats ET vos secrets expirent. Il ne faudra pas oublier de les surveiller pour les renouvellements au risque d’arriver au jour ou votre script ne fonctionnera plus. En sortie, vous obtenez le sésame pour accéder aux ressources, le token JWT permettant une authentification moderne sur EWS.
Vous pouvez décoder ce token pour en dévoiler le contenu en visitant simplement un site dédié tel que jwt.io ou coder vous-même la fonctionnalité. Cela devrait vous donner quelque chose comme ça

Ajoutez les permissions EWS

L’entrée rôle correspond aux permissions que vous avez ajouté à l’application.

Modification du code d’authentification EWS

Une fois le token acquis, et bien il ne reste qu’à modifier votre code existant. Initialement vous devriez avoir quelque chose dans ce style :

$username = "user@domain.com"
$password = "monsupermotdepassequejenauraipascodéendur,evidemment"
$securePassword = ConvertTo-SecureString $password -AsPlainText -Force
$Service = [Microsoft.Exchange.WebServices.Data.ExchangeService]::new()
$Service.Credentials = New-Object System.Net.NetworkCredential($username,$securePassword)

Il suffit de modifier le type de credential passé à l’instance de service EWS comme suit si vous utilisez une authentification par certificat

$clientID = "mon clientid" 
$tenantId = "mon tenantid"
$audience = "https://outlook.office.com/.default"
$thumbprint="mon thumbprint" 

$accessToken = Get-MSAccessTokenWithCredentialFlow `
    -Audience $audience `
    -ClientID $clientID `
    -TenantID $tenantId `
    -Thumbprint $thumbprint `
    -TokenExpiryInMinutes 5

$Service = [Microsoft.Exchange.WebServices.Data.ExchangeService]::new()
$Service.Credentials = [Microsoft.Exchange.WebServices.Data.OAuthCredentials]$accessToken 

Si vous utilisé un secret le code est le suivant. Dans ce cas notez que bien qu’en produisant une authentification moderne, le secret est stocké en clair dans le script ce qui est une très mauvaise pratique. Vous pourriez alors imaginer de chiffrer le secret avec un certificat mais à ce compte autant utiliser l’autre méthode. L’autre option serait de stocker le secret et les informations de connexion dans un Keyvault Azure et d’héberger le code dans un compte d’automatisation ou dans une fonction serverless mais c’est un autre sujet !

$clientID = "mon clientid" 
$tenantId = "mon tenantid"
$audience = "https://outlook.office.com/.default" 
$clientSecret = " monsupersecretquejenauraipascodéendur,evidemment"

$accessToken = Get-MSAccessTokenWithCredentialFlow `
    -Audience $audience `
    -ClientID $clientID `
    -TenantID $tenantId `
    -ClientSecret $clientSecret

$Service = [Microsoft.Exchange.WebServices.Data.ExchangeService]::new()
$Service.Credentials = [Microsoft.Exchange.WebServices.Data.OAuthCredentials]$accessToken

Une fois authentifié, vous pourriez avoir quelques modifications additionnelles car l’authentification oauth sur EWS nécessite parfois l’impersonation d’une identité ayant les droits sur Exchange, comme l’accès à la boite mail d’un utilisateur. Dans ce cas il faudra préciser la propriété ImpersonatedUserId de votre instance de service comme dans l’exemple ci-dessous

$Service.ImpersonatedUserId = [Microsoft.Exchange.WebServices.Data.ImpersonatedUserId]::new([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, "user@domain.com") 

Ajoutez les permissions EWS

Tadam ! vous êtes connectés.

Le mot de la fin

Maintenant que votre code bénéficie d’une authentification moderne, les portes sont ouvertes. Vous pouvez ajouter de la sécurité en cas de compromission de l’application (comme la fuite de l’application ID et de son secret). Les accès conditionnels vous permettent en effet de cibler des principaux de service et de limiter les réseaux sur lesquels votre script peut s’exécuter (préversion).