Table of Contents

In meiner Arbeit an der praktischen Umsetzung von Kennwortrotation ohne Privileged Authentication Administrator, bin ich über eine etwas umfangreiche Herausforderung gestolpert.

Wenn man sich über PowerShell mit Benutzername + Passwort an der Graph API anmelden will, findet man keine Kombination in der PowerShell SDK.
Die einzige Methode wäre ClientID + Secret – damit wären wir aber wieder bei der Nutzung von App Permissions, was ich in diesem Fall konkret vermeiden will.

Wir wissen auch, dass es geht, weil in der Azure command line ein solcher connect möglich ist – nur dafür aber das Az Modul oder die Azure CLI zu fordern widerspricht meinem Perfektionismus. Vor allem weil es dort auch nicht trivial ist, sich gegen eine spezifische App Registration zu authentifizieren.

Die faule Lösung gibt es entsprechend schon – den Token kann man dann mit Connect-MgGraph -AccessToken $(ConvertTo-SecureString "<AccessToken>") weiter verwenden

Da ein so spezifisches Teilproblem bald den halben Artikel einnahm, fasse ich lieber hier separat zusammen, wie ich zu meiner Lösung gekommen bin.


Erste Untersuchung

Beginnend mit dem Wissen, dass es eine funktionierende Implementierung gibt, habe ich mir mal mehr Details geben lassen. So komme ich also zu dem Resource Owner Password Credentials (ROPC) grant. In der zugehörigen Dokumentation finden wir auch schon sehr detaillierte Informationen, wie er zu benutzen ist.

Nämlich idealerweise gar nicht:

ROPC Warning

Moderne Authentifizierungsprotokolle sind so gebaut, dass Benutzeranmeldung so weit von der Applikation getrennt sein soll wie möglich – bei allen anderen OIDC Flows bekommen wir einfach die Bestätigung, dass der Benutzer authentifiziert ist. Hier halten wir temporär seine Credentials – wo wir es meistens gar nicht müssten.

Allerdings ist da die relevante Phrase: "in most scenarios". Wenn ich mich ohne Interaktion als normaler Benutzer anmelden will, gibt es meines Wissens keine Alternative – ich bin aber für eine Korrektur offen. Der Upside ist es in meinem Szenario Wert, weil paradoxerweise in meinem ganz spezifischen Usecase Benutzerberechtigungen um Meilen granularer sind als Applikationsrechte – und ich eh die Passwörter der Benutzer im PAM Vault habe.

Wir werden aber auch sehr wahrscheinlich keine SDKs finden, die so funktionieren wie ich es gerne hätte: Ich stimme Microsoft zu, ich will nicht, dass auf diese App von Benutzern zugegriffen wird. Sonst würden sie sich ohne MFA Authentifizieren können und requests gegen die API stellen. Der beste Weg dazu wäre, dass ich eine confidential client App registriere, wo bei Requests immer ein Zertifikat mitgegeben werden muss um nachzuweisen, dass das System die App Registration nutzen darf. Aber:

ROPC No Confidential

Nachvollziehbar, wenn man davon ausgeht, dass beim Login Passwörter an den Server weiter gegeben werden würden. In unserem Fall aber nicht zutreffend, weil wir die Kennwörter ja schon haben.

Wenn die SDKs es nicht unterstützen, werden wir wohl die Web Requests nachbauen müssen.


Bau des Code

Ich mache mir zwar oft viel mehr Arbeit als notwendig wäre (So wie jetzt gerade? 🤔) – ich will aber nicht ganz übertreiben. Es gibt mehr als genug Implementierungen der Graph API Auth über Invoke-WebRequest – ich werde mir also eine möglichst gute Version nehmen und für ROPC anpassen.

Am besten sah für mich die Version von Adam The Automator aus – vor allem weil dort auch direkt die zertifikatsbasierte Authentifizierung implementiert ist, an der ich wahrscheinlich eine ganze Weile geknabbert hätte.

Bei ersten Tests des Codes stolpere ich aber über in Problem:

Error Connecting

Nach ein wenig Troubleshooting identifiziere ich die folgende Passage:

#...
$Certificate = Get-Item Cert:\CurrentUser\My\$thumbprint
#...

# Get the private key object of your certificate
$PrivateKey = $Certificate.PrivateKey

# Define RSA signature and hashing algorithm
$RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
$HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256

# Create a signature of the JWT
$Signature = [Convert]::ToBase64String(
    $PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT),$HashAlgorithm,$RSAPadding)
) -replace '\+','-' -replace '/','_' -replace '='
#...

Wahrscheinlich weil meine Private Keys nicht exportierbar sind blieb $PrivateKey leer.
Auch private Keys, die nicht exportiert werden können sind aber auf dem System nutzbar, auf dem sie installiert sind – sonst würde Connect-MgGraph -CertificateThumbprint -ClientId auch fehlschlagen.

Baut man ein .Net Objekt direkt aus dem Zertifikat, dass einem eine Signatur liefern kann, kommen wir weiter:

$rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
$Signature = [Convert]::ToBase64String(
    $rsaCert.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding)
) -replace '\+', '-' -replace '/', '_' -replace '='

SuccessfulSignature


Danach folgen nur noch Anpassungen, die zwar eine Weile dauern, aber weniger interressant darzustellen sind: Ich

  1. übertrage die HTTP Beispiele aus der Doku in entsprechende PowerShell Syntax
  2. benutze den Access Token, um mich mit Connect-MgGraph zu verbinden
  3. parametrisiere alles, so dass es eine wiederaufrufbare Funktion wird
  4. übernehme auch die Authentifizierung gegen Public Clients und mit Client Secret, der Vollständigkeit halber
  5. stelle sicher, dass die sensiblen Variablen geleert werden


Nutzung

Voraussetzungen:

  • Wie so üblich braucht man auch hier eine Enterprise Applikation, mit den delegate Rechten, die man später nutzen will
  • Wenn keine App Credentials genutzt werden sollen, muss in den Advanced Settings die Registration als "Public Client App" markiert werden
    • Bitte dringend vermeiden
  • Die App muss von MFA und Compliant Device Kontrollen in Conditional Access ausgenommen werden
    • Idealerweise wird stattdessen mindestens die IP Adresse geprüft und die zugelassenen Benutzer eingeschränkt


Verbindungsaufbau ist sehr einfach:

$params = @{
  #alternativ get-credentials
  userCredentials       = $creds
  tenantId              = $config.tenantID
  clientId              = $config.clientID
  scopes                = @("User.Read","Directory.AccessAsUser.All")
  certificateThumbprint = $config.thumb
}
Connect-ROPCGraph @params

Der Aufbau ist bewusst möglichst nah an Connect-MgGraph – wir brauchen aber nun mal die Credentials.

Im ersten Moment bekommt man aber keinen Output – das soll aber so, die Funktion ist schließlich für noninteraktive Prozesse gebaut. Wir sehen den Erfolg, wenn wir andere Kommandos aus der Graph SDK ausführen und feststellen dürfen, dass alles so funktioniert wie gewohnt:

Successfull Connection

Einschränkungen
TL:DR des großen Warnungsblocks von ROPC; Folgendes Funktioniert NICHT

  • Microsoft Personal Accounts
  • Passwortlose Accounts
  • Logins, die MFA Fordern
  • Föderierte Accounts
  • Kennwörter mit Leerzeichen am Anfang oder Ende


Fazit

Jetzt können wir uns ohne Benutzerzutun als End User Anmelden und die zugehörigen Vorteile ausnutzen. Wir wissen aber, dass es einen Grund hat, dass dieser Flow nicht einfach verfügbar gemacht wird – explizite Nutzung von Benutzerpasswörtern und Umgehen von MFA sollte so selten wie möglich genutzt werden.

Und ich kann mit meinem Passwortrotations-PoC weiter machen 😉


Ich werde keine Kommentare moderieren und möchte Ihre E-Mail-Adresse nicht; bitte beteiligen Sie sich an der Diskussion über meinen Zugehörigen LinkedIn Post.


Wenn sie an den Dingen interessiert sind die ich tue folgen sie mir auf LinkedIn.


Volles Script

Aktuellste Version

function Connect-ROPCGraph {
    param (
        [Parameter(ParameterSetName = "PublicClient", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]$userCredentials,

        [Parameter(ParameterSetName = "PublicClient", Mandatory = $true)]
        [switch]$publicClient,

        [Parameter(ParameterSetName = "PublicClient", Mandatory = $false)]
        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [string]$tenantId,

        [Parameter(ParameterSetName = "PublicClient", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [string]$clientId,

        [Parameter(ParameterSetName = "PublicClient", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [array]$scopes,

        [Parameter(ParameterSetName = "ClientCert", Mandatory = $true)]
        [ValidateNotNull()]
        [string]$certificateThumbprint,

        [Parameter(ParameterSetName = "ClientCredentials", Mandatory = $true)]
        [ValidateNotNull()]
        [securestring]$clientSecret
    )

    # Depending on which Type of Client Credentials were used we generate the Request Body
    switch ($PSCmdlet.ParameterSetName) {
        "PublicClient" {
            $Body = @{
                client_id  = $clientId
                scope      = [string]$scopes
                username   = $userCredentials.UserName
                password   = $userCredentials.GetNetworkCredential().Password
                grant_type = "password"
            }
        }
        "ClientCert" {
            # If we are using Certificate Credentials we have to generate a JWT Assertion
            # Based on https://adamtheautomator.com/powershell-graph-api/ - the original certificate usage did not work for me though
            try {
                # Load Certificate
                $Certificate = Get-Item "Cert:\CurrentUser\My\$certificateThumbprint" -ErrorAction Stop

                # Get base64 hash of certificate in Web Encoding
                $CertificateBase64Hash = [System.Convert]::ToBase64String($Certificate.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '='
            }
            catch {
                throw "Error Reading Certificate"
            }

            $StartDate = (Get-Date "1970-01-01T00:00:00Z").ToUniversalTime()

            # Create JWT timestamp for expiration
            $JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds
            $JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0)

            # Create JWT validity start timestamp
            $NotBeforeExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds
            $NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0)

            # Create JWT header
            $JWTHeader = @{
                alg = "RS256"
                typ = "JWT"
                x5t = $CertificateBase64Hash 
            }

            # Create JWT payload
            $JWTPayLoad = @{
                aud = "https://login.microsoftonline.com/$tenantID/oauth2/token"
                exp = $JWTExpiration
                iss = $clientID
                jti = [guid]::NewGuid()
                nbf = $NotBefore
                sub = $clientID
            }

            # Convert header and payload to base64
            $JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json))
            $EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte)

            $JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json))
            $EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte)

            $JWT = $EncodedHeader + "." + $EncodedPayload

            # Define RSA signature and hashing algorithm
            $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
            $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256

            # Sign the JWT
            $rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
            $Signature = [Convert]::ToBase64String(
                $rsaCert.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding)
            ) -replace '\+', '-' -replace '/', '_' -replace '='

            # Add Signature to JWT
            $JWT = $JWT + "." + $Signature

            $Body = @{
                client_id             = $clientId
                client_assertion      = $JWT
                client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
                scope                 = [string]$scopes
                username              = $userCredentials.UserName
                password              = $userCredentials.GetNetworkCredential().Password
                grant_type            = "password"
            }
        }
        "ClientCredentials" {
            $Body = @{
                client_id     = $clientId
                client_secret = [System.Net.NetworkCredential]::new("", $clientSecret).Password
                scope         = [string]$scopes
                username      = $userCredentials.UserName
                password      = $userCredentials.GetNetworkCredential().Password
                grant_type    = "password"
            }
        }
    }

    $params = @{
        Uri         = "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token"
        Method      = 'POST'
        ContentType = 'application/x-www-form-urlencoded'
        Body        = $Body
        # If we use a JWT we must add an Authorization Header
        Headers     = if ($JWT) { @{ Authorization = "Bearer $JWT" } }
    }
    $accessToken = ConvertTo-SecureString (Invoke-RestMethod @params -ErrorAction Stop).access_token -AsPlainText -Force

    # Use our Access token to Connect to Microsoft Graph
    Connect-MgGraph -AccessToken $accessToken -NoWelcome

    # Clear Senstive Values
    $sensitiveVars = @("userCredentials","accessToken","body","params","jwt","signature")
    Remove-Variable $sensitiveVars
    [gc]::collect()
}
Zuletzt bearbeitet: 11. Februar 2024