Public/Get-AdminsWithoutPhishingResistantMFA.ps1

<#
.SYNOPSIS
    Identifies privileged users without phishing-resistant MFA configured.

.DESCRIPTION
    This function checks all users with privileged directory roles and identifies
    those who do not have phishing-resistant MFA methods (FIDO2, Windows Hello for
    Business, or Certificate-based authentication) registered.

    Privileged accounts are prime targets for attackers, and SMS/Voice MFA can be
    bypassed through SIM swapping or social engineering. Phishing-resistant MFA
    provides significantly stronger protection.

.PARAMETER IncludeAllMFAMethods
    Include all MFA methods in output, not just phishing-resistant ones.

.PARAMETER RolesToCheck
    Specific role names to check. Default checks all critical admin roles.

.PARAMETER ExportPath
    Optional path to export results to CSV.

.EXAMPLE
    Get-AdminsWithoutPhishingResistantMFA

    Returns all privileged users without phishing-resistant MFA.

.EXAMPLE
    Get-AdminsWithoutPhishingResistantMFA -IncludeAllMFAMethods $true

    Shows all MFA methods registered for each privileged user.

.EXAMPLE
    Get-AdminsWithoutPhishingResistantMFA | Where-Object { -not $_.HasPhishingResistantMFA }

    Returns only users who need to register stronger MFA.

.NOTES
    Author: Kent Agent (kentagent-ai)
    Created: 2026-03-11
    Requires: Microsoft.Graph PowerShell module
    Permissions: Policy.Read.All, Directory.Read.All, RoleManagement.Read.Directory, UserAuthenticationMethod.Read.All

.LINK
    https://github.com/kentagent-ai/EntraIDSecurityScripts
#>

function Get-AdminsWithoutPhishingResistantMFA {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [bool]$IncludeAllMFAMethods = $false,

        [Parameter(Mandatory = $false)]
        [string[]]$RolesToCheck,

        [Parameter(Mandatory = $false)]
        [string]$ExportPath
    )

    begin {
        # Verify Graph connection
        $context = Get-MgContext
        if (-not $context) {
            throw "Not connected to Microsoft Graph. Run: Connect-MgGraph -Scopes 'Directory.Read.All', 'RoleManagement.Read.Directory', 'UserAuthenticationMethod.Read.All'"
        }

        # Default critical roles to check
        $defaultCriticalRoles = @(
            'Global Administrator'
            'Privileged Role Administrator'
            'Security Administrator'
            'Exchange Administrator'
            'SharePoint Administrator'
            'User Administrator'
            'Authentication Administrator'
            'Privileged Authentication Administrator'
            'Conditional Access Administrator'
            'Intune Administrator'
            'Cloud Application Administrator'
            'Application Administrator'
            'Azure AD Joined Device Local Administrator'
            'Billing Administrator'
            'Compliance Administrator'
            'Global Reader'
        )

        $rolesToAudit = if ($RolesToCheck) { $RolesToCheck } else { $defaultCriticalRoles }

        # Phishing-resistant MFA method types
        $phishingResistantMethods = @(
            '#microsoft.graph.fido2AuthenticationMethod'
            '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod'
            '#microsoft.graph.platformCredentialAuthenticationMethod'
            '#microsoft.graph.x509CertificateAuthenticationMethod'
        )

        $results = [System.Collections.Generic.List[PSCustomObject]]::new()
    }

    process {
        Write-Verbose "Retrieving directory roles..."

        # Get all directory roles
        try {
            $directoryRoles = Get-MgDirectoryRole -All -ErrorAction Stop
        }
        catch {
            throw "Failed to retrieve directory roles: $_"
        }

        # Filter to roles we care about
        $targetRoles = $directoryRoles | Where-Object { $_.DisplayName -in $rolesToAudit }

        Write-Verbose "Checking $($targetRoles.Count) privileged roles..."

        # Track processed users to avoid duplicates
        $processedUsers = @{}

        foreach ($role in $targetRoles) {
            Write-Verbose "Processing role: $($role.DisplayName)"

            # Get role members
            try {
                $members = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All -ErrorAction Stop
            }
            catch {
                Write-Warning "Failed to get members of $($role.DisplayName): $_"
                continue
            }

            foreach ($member in $members) {
                # Skip if already processed
                if ($processedUsers.ContainsKey($member.Id)) {
                    # Add this role to existing entry
                    $existingEntry = $results | Where-Object { $_.UserId -eq $member.Id }
                    if ($existingEntry -and $existingEntry.Roles -notcontains $role.DisplayName) {
                        $existingEntry.Roles += $role.DisplayName
                    }
                    continue
                }
                $processedUsers[$member.Id] = $true

                # Get user details
                $user = $null
                try {
                    $user = Get-MgUser -UserId $member.Id -Property Id, DisplayName, UserPrincipalName, AccountEnabled -ErrorAction Stop
                }
                catch {
                    # User may be deleted but role assignment remains (orphaned)
                    Write-Verbose "Skipping orphaned/deleted user: $($member.Id)"
                    continue
                }
                
                if ($null -eq $user) {
                    Write-Verbose "Skipping - user not found: $($member.Id)"
                    continue
                }

                # Skip disabled accounts
                if (-not $user.AccountEnabled) {
                    Write-Verbose "Skipping disabled account: $($user.UserPrincipalName)"
                    continue
                }

                # Get authentication methods
                $authMethods = @()
                $hasPhishingResistant = $false
                $phishingResistantMethodNames = @()
                $allMethodNames = @()

                try {
                    $methods = Get-MgUserAuthenticationMethod -UserId $user.Id -ErrorAction Stop
                    
                    foreach ($method in $methods) {
                        $methodType = $method.AdditionalProperties['@odata.type']
                        $methodName = switch ($methodType) {
                            '#microsoft.graph.fido2AuthenticationMethod' { 'FIDO2 Security Key' }
                            '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod' { 'Windows Hello for Business' }
                            '#microsoft.graph.platformCredentialAuthenticationMethod' { 'Platform Credential' }
                            '#microsoft.graph.x509CertificateAuthenticationMethod' { 'Certificate (X.509)' }
                            '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' { 'Microsoft Authenticator' }
                            '#microsoft.graph.phoneAuthenticationMethod' { 'Phone (SMS/Voice)' }
                            '#microsoft.graph.emailAuthenticationMethod' { 'Email' }
                            '#microsoft.graph.passwordAuthenticationMethod' { 'Password' }
                            '#microsoft.graph.temporaryAccessPassAuthenticationMethod' { 'Temporary Access Pass' }
                            '#microsoft.graph.softwareOathAuthenticationMethod' { 'Software OATH Token' }
                            default { $methodType }
                        }

                        $allMethodNames += $methodName

                        if ($methodType -in $phishingResistantMethods) {
                            $hasPhishingResistant = $true
                            $phishingResistantMethodNames += $methodName
                        }
                    }
                }
                catch {
                    Write-Warning "Failed to get auth methods for $($user.UserPrincipalName): $_"
                    $allMethodNames = @('Unable to retrieve')
                }

                # Determine risk level
                $riskLevel = if (-not $hasPhishingResistant) {
                    if ($role.DisplayName -eq 'Global Administrator') { 'CRITICAL' }
                    elseif ($role.DisplayName -match 'Privileged|Security|Authentication') { 'HIGH' }
                    else { 'MEDIUM' }
                } else { 'LOW' }

                # Build recommendation
                $recommendation = if (-not $hasPhishingResistant) {
                    "Register phishing-resistant MFA (FIDO2 key or Windows Hello)"
                } else {
                    "Compliant - has phishing-resistant MFA"
                }

                $resultObj = [PSCustomObject]@{
                    UserPrincipalName         = $user.UserPrincipalName
                    DisplayName               = $user.DisplayName
                    UserId                    = $user.Id
                    Roles                     = @($role.DisplayName)
                    HasPhishingResistantMFA   = $hasPhishingResistant
                    PhishingResistantMethods  = $phishingResistantMethodNames -join ', '
                    RiskLevel                 = $riskLevel
                    Recommendation            = $recommendation
                }

                if ($IncludeAllMFAMethods) {
                    $resultObj | Add-Member -NotePropertyName 'AllAuthMethods' -NotePropertyValue ($allMethodNames -join ', ')
                }

                $results.Add($resultObj)
            }
        }
    }

    end {
        # Convert Roles array to string for display/export
        foreach ($result in $results) {
            $result.Roles = $result.Roles -join ', '
        }

        # Summary
        $atRisk = $results | Where-Object { -not $_.HasPhishingResistantMFA }
        $critical = $atRisk | Where-Object { $_.RiskLevel -eq 'CRITICAL' }

        Write-Host "`n=== Privileged User MFA Audit Summary ===" -ForegroundColor Yellow
        Write-Host "Total privileged users: $($results.Count)" -ForegroundColor White
        Write-Host "With phishing-resistant MFA: $(($results | Where-Object { $_.HasPhishingResistantMFA }).Count)" -ForegroundColor Green
        Write-Host "Without phishing-resistant MFA: $($atRisk.Count)" -ForegroundColor $(if ($atRisk.Count -gt 0) { 'Red' } else { 'Green' })
        if ($critical.Count -gt 0) {
            Write-Host "CRITICAL (Global Admins at risk): $($critical.Count)" -ForegroundColor Red
        }
        Write-Host "==========================================" -ForegroundColor Yellow

        # Export if path specified
        if ($ExportPath) {
            try {
                $results | Export-Csv -Path $ExportPath -NoTypeInformation -Encoding UTF8
                Write-Host "Results exported to: $ExportPath" -ForegroundColor Green
            }
            catch {
                Write-Error "Failed to export results: $_"
            }
        }

        return $results
    }
}

# Export function if loaded as module
Export-ModuleMember -Function Get-AdminsWithoutPhishingResistantMFA -ErrorAction SilentlyContinue