From 6b1595c1683262cbd12561868ceabcb1f3351e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0t=C4=9Bp=C3=A1n?= Date: Mon, 27 Jul 2026 14:36:15 +0200 Subject: [PATCH] Add script to report user MFA methods and highlight SMS/Voice reliance ahead of retirement --- Get-UserMfaMethodReport.ps1 | 598 ++++++++++++++++++++++++++++++++++++ 1 file changed, 598 insertions(+) create mode 100644 Get-UserMfaMethodReport.ps1 diff --git a/Get-UserMfaMethodReport.ps1 b/Get-UserMfaMethodReport.ps1 new file mode 100644 index 0000000..dd08aa3 --- /dev/null +++ b/Get-UserMfaMethodReport.ps1 @@ -0,0 +1,598 @@ +<# +.SYNOPSIS + Reports every user's registered MFA/authentication methods and default method, + flagging accounts that rely solely on SMS/Voice (red) or whose default method + is SMS/Voice even though other methods exist (yellow). + +.DESCRIPTION + Built ahead of the Microsoft Entra ID SMS/Voice retirement + (https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement). + Works on both Entra ID P1/P2 tenants and Free/Business Basic tenants: + + - On P1/P2 tenants it uses the fast tenant-wide report + (Get-MgReportAuthenticationMethodUserRegistrationDetail), one call for + every user, including the reported preferred/default method. + - On tenants without P1/P2 (or if that report is unavailable/unlicensed) + it falls back to the per-user authentication methods API + (Get-MgUserAuthenticationMethod), which does not require premium + licensing, and best-effort per-user sign-in preference for the + default method. + + The script never modifies anything in the tenant - it only reads + authentication method registrations - so it does not need -WhatIf/-Confirm. + + Row classification in the HTML/CSV output: + - RED ("SmsVoiceOnly"): the only MFA-capable method(s) registered + are SMS and/or Voice call. These accounts + will hit the blocking passkey prompt on + Feb 2027 retirement with no fallback. + - YELLOW ("SmsVoiceRegistered"): other MFA methods are registered too, + but SMS or Voice is also registered and + still needs to be removed from the + account ahead of the retirement. + - (none): no SMS/Voice exposure, or no MFA registered + at all (flagged separately in the Notes + column - a different problem, not this + retirement). + + Intended to be run once per tenant. For an MSP/CSP audit across many + customer tenants, call this script once per tenant (looping over your + GDAP-delegated customers) and collect the per-tenant CSV outputs. + +.PARAMETER TenantId + Target Entra ID tenant ID or domain to connect to. Optional if you are + already connected via Connect-MgGraph, or if you want the interactive/ + device-code sign-in to resolve the tenant itself. + +.PARAMETER OutputFolder + Folder to write the CSV and HTML report to. Defaults to the current + working directory. + +.PARAMETER IncludeGuests + Include guest (B2B) accounts in the report. By default only Member users + are reported, matching how Microsoft scopes the SMS/Voice retirement. + +.PARAMETER ForcePerUserLookup + Skip the fast tenant-wide registration report even if the tenant has + P1/P2, and use the slower per-user method lookup for every user instead. + Useful for validating the two code paths against each other. + +.EXAMPLE + PS> .\Get-UserMfaMethodReport.ps1 + Connects interactively, reports on the current tenant, and writes + UserMfaMethodReport.csv / .html to the current folder. + +.EXAMPLE + PS> .\Get-UserMfaMethodReport.ps1 -TenantId "contoso.onmicrosoft.com" -OutputFolder "C:\Audits\Contoso" + Reports on a specific customer tenant (e.g. via GDAP) and writes output + to a per-client folder. + +.NOTES + Author: Petr Štěpán + Created: 2026-07-27 + Version: 1.0.0 + Changelog: + 1.0.0 - Initial version +#> + +#Requires -Version 7.0 +[CmdletBinding()] +param( + [Parameter(Mandatory = $false, HelpMessage = 'Target tenant ID or domain name.')] + [string] + $TenantId, + + [Parameter(Mandatory = $false, HelpMessage = 'Folder to write the CSV/HTML report to.')] + [ValidateNotNullOrEmpty()] + [string] + $OutputFolder = (Get-Location).Path, + + [Parameter(Mandatory = $false)] + [switch] + $IncludeGuests, + + [Parameter(Mandatory = $false)] + [switch] + $ForcePerUserLookup +) + +# --- Safety preamble ------------------------------------------------------- +# Fail fast on uninitialized variables and typos in property/member names. +Set-StrictMode -Version Latest +# Treat all non-terminating errors as terminating so failures can't slip by silently, +# except inside the per-user/per-item loops below, where we deliberately catch and +# continue so one bad user doesn't abort the whole tenant run. +$ErrorActionPreference = 'Stop' + +# Method @odata.type values that count as a genuine MFA-capable factor. +# Password and Email are excluded: Password isn't a second factor, and Email +# is an SSPR-only method, not an MFA method. +$script:MfaMethodTypes = @( + '#microsoft.graph.phoneAuthenticationMethod' + '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' + '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod' + '#microsoft.graph.fido2AuthenticationMethod' + '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod' + '#microsoft.graph.softwareOathAuthenticationMethod' + '#microsoft.graph.temporaryAccessPassAuthenticationMethod' + '#microsoft.graph.platformCredentialAuthenticationMethod' +) + +# --- Functions --------------------------------------------------------- + +function Test-PremiumAuthReportLicense { + <# + .SYNOPSIS + Returns $true if the connected tenant has an Entra ID P1 or P2 service + plan assigned to any subscribed SKU, which is required for the + tenant-wide authentication methods registration report. + #> + [CmdletBinding()] + param() + + try { + [array]$skus = Get-MgSubscribedSku -All -ErrorAction Stop + } + catch { + Write-Warning "Could not read subscribed SKUs to detect P1/P2 licensing: $_" + return $false + } + + # AAD_PREMIUM = Entra ID P1, AAD_PREMIUM_P2 = Entra ID P2. Both are also + # included as service plans inside bundles like Business Premium / E3 / E5. + $premiumPlanNames = @('AAD_PREMIUM', 'AAD_PREMIUM_P2') + foreach ($sku in $skus) { + foreach ($plan in $sku.ServicePlans) { + if ($plan.ServicePlanName -in $premiumPlanNames -and $plan.ProvisioningStatus -eq 'Success') { + return $true + } + } + } + return $false +} + +function ConvertTo-FriendlyAuthMethod { + <# + .SYNOPSIS + Maps a Graph authentication method @odata.type (plus phoneType, where + relevant) to a friendly display name and a category used for + red/yellow classification. + .PARAMETER ODataType + The '@odata.type' value of the method, e.g. + '#microsoft.graph.phoneAuthenticationMethod'. + .PARAMETER PhoneType + For phone methods only: 'mobile', 'alternateMobile', or 'office'. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $ODataType, + + [Parameter(Mandatory = $false)] + [string] + $PhoneType + ) + + switch ($ODataType) { + '#microsoft.graph.passwordAuthenticationMethod' { + return [PSCustomObject]@{ DisplayName = 'Password'; Category = 'NotMfa' } + } + '#microsoft.graph.phoneAuthenticationMethod' { + $label = if ($PhoneType -eq 'office') { 'Voice call (office phone)' } else { 'SMS / Voice call (mobile)' } + return [PSCustomObject]@{ DisplayName = $label; Category = 'Phone' } + } + '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod' { + return [PSCustomObject]@{ DisplayName = 'Microsoft Authenticator'; Category = 'Strong' } + } + '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod' { + return [PSCustomObject]@{ DisplayName = 'Passwordless (Authenticator)'; Category = 'Strong' } + } + '#microsoft.graph.fido2AuthenticationMethod' { + return [PSCustomObject]@{ DisplayName = 'FIDO2 / Passkey'; Category = 'Strong' } + } + '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod' { + return [PSCustomObject]@{ DisplayName = 'Windows Hello for Business'; Category = 'Strong' } + } + '#microsoft.graph.softwareOathAuthenticationMethod' { + return [PSCustomObject]@{ DisplayName = 'OATH software token'; Category = 'Strong' } + } + '#microsoft.graph.temporaryAccessPassAuthenticationMethod' { + return [PSCustomObject]@{ DisplayName = 'Temporary Access Pass'; Category = 'Other' } + } + '#microsoft.graph.platformCredentialAuthenticationMethod' { + return [PSCustomObject]@{ DisplayName = 'Platform credential (e.g. Mac)'; Category = 'Strong' } + } + '#microsoft.graph.emailAuthenticationMethod' { + return [PSCustomObject]@{ DisplayName = 'Email (SSPR only)'; Category = 'NotMfa' } + } + default { + return [PSCustomObject]@{ DisplayName = "Unknown method ($ODataType)"; Category = 'Other' } + } + } +} + +function Get-UserAuthMethodDetail { + <# + .SYNOPSIS + Reads a single user's registered authentication methods and best-effort + default/preferred method via the per-user Graph API. Works regardless + of tenant licensing. + .PARAMETER UserId + The Entra ID object ID of the user to look up. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $UserId + ) + + [array]$friendlyMethods = @() + $hasStrongMethod = $false + $hasPhoneMethod = $false + + try { + [array]$methods = Get-MgUserAuthenticationMethod -UserId $UserId -ErrorAction Stop + } + catch { + Write-Warning "Could not read authentication methods for user '$UserId': $_" + return [PSCustomObject]@{ + MethodsDisplay = 'Error retrieving methods' + HasStrongMethod = $false + HasPhoneMethod = $false + DefaultMethod = $null + } + } + + foreach ($method in $methods) { + $odataType = $method.AdditionalProperties['@odata.type'] + $phoneType = $method.AdditionalProperties['phoneType'] + $friendly = ConvertTo-FriendlyAuthMethod -ODataType $odataType -PhoneType $phoneType + $friendlyMethods += $friendly.DisplayName + + if ($friendly.Category -eq 'Strong') { $hasStrongMethod = $true } + if ($friendly.Category -eq 'Phone') { $hasPhoneMethod = $true } + } + + # Best-effort default/preferred method. This endpoint reflects the user's + # own sign-in preference and, unlike the tenant-wide usage/insights report, + # is not gated behind a P1/P2 license - but treat failures as "unknown" + # rather than fatal, since behavior can vary by tenant/SDK version. + $defaultMethod = $null + try { + $preference = Get-MgBetaUserAuthenticationSignInPreference -UserId $UserId -ErrorAction Stop + $defaultMethod = $preference.UserPreferredMethodForSecondaryAuthentication + } + catch { + Write-Verbose "No sign-in preference available for user '$UserId' (falling back to heuristic): $_" + } + + return [PSCustomObject]@{ + MethodsDisplay = ($friendlyMethods -join ', ') + HasStrongMethod = $hasStrongMethod + HasPhoneMethod = $hasPhoneMethod + DefaultMethod = $defaultMethod + } +} + +function Export-AuthenticationHtmlReport { + <# + .SYNOPSIS + Writes the collected report rows to a colour-coded HTML file. + .PARAMETER Report + Array of report row objects (must include a 'RowClass' property). + .PARAMETER Path + Destination HTML file path. + .PARAMETER TenantDisplayName + Tenant name shown in the report header. + .PARAMETER IsPremiumTenant + Whether the tenant-wide (P1/P2) report path was used. + .PARAMETER MethodPolicyStates + Array of objects with Id/State properties describing the tenant's + authentication methods policy (which methods are allowed/enabled), + as returned by Get-MgPolicyAuthenticationMethodPolicy. Optional - + omit or pass an empty array to skip that section of the report. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [array] + $Report, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Path, + + [Parameter(Mandatory = $true)] + [string] + $TenantDisplayName, + + [Parameter(Mandatory = $true)] + [bool] + $IsPremiumTenant, + + [Parameter(Mandatory = $false)] + [array] + $MethodPolicyStates = @() + ) + + $runDate = Get-Date -Format 'dd-MMM-yyyy HH:mm:ss' + $licenseNote = if ($IsPremiumTenant) { 'P1/P2 detected - tenant-wide report used' } else { 'No P1/P2 detected - per-user lookup used' } + + $htmlHead = @" + + + +
+

User MFA Method Report - SMS/Voice Retirement Impact

+

Tenant: $TenantDisplayName  |  Generated: $runDate  |  $licenseNote

+
+"@ + + $htmlTable = $Report | + Select-Object DisplayName, UserPrincipalName, RegisteredMethods, DefaultMethod, Notes | + ConvertTo-Html -Fragment + + [xml]$xml = $htmlTable + $tableClassAttr = $xml.CreateAttribute('class') + $tableClassAttr.Value = 'ReportTable' + $xml.table.Attributes.Append($tableClassAttr) | Out-Null + + # Walk the generated rows in lock-step with the report rows to apply + # the RowClass (SmsVoiceOnly / SmsVoiceRegistered / none) computed earlier. + $rowIndex = 0 + foreach ($tableRow in $xml.table.SelectNodes('tr')) { + if ($tableRow.SelectNodes('th').Count -eq 0 -and $rowIndex -lt $Report.Count) { + $rowClass = $Report[$rowIndex].RowClass + if ($rowClass) { + $tableRow.SetAttribute('class', $rowClass) + } + $rowIndex++ + } + } + + $htmlBody = [string]::Format('
{0}
', $xml.OuterXml) + + $countOnly = @($Report | Where-Object { $_.RowClass -eq 'SmsVoiceOnly' }).Count + $countDefault = @($Report | Where-Object { $_.RowClass -eq 'SmsVoiceRegistered' }).Count + + $methodPolicyHtml = '' + if ($MethodPolicyStates.Count -gt 0) { + $methodRows = foreach ($methodState in $MethodPolicyStates) { + $isSmsVoice = $methodState.Id -in @('Sms', 'Voice') + $rowClass = if ($methodState.State -ne 'enabled') { 'MethodDisabled' } elseif ($isSmsVoice) { 'MethodEnabledSmsVoice' } else { 'MethodEnabled' } + "$($methodState.Id)$($methodState.State)" + } + $methodPolicyHtml = @" +
+

Allowed authentication methods (tenant policy)

+ + +$($methodRows -join "`n") +
MethodState
+
+"@ + } + + $htmlTail = @" +

-----------------------------------------------------------------------

+

Total users reported: $($Report.Count)

+

Red - only MFA method is SMS/Voice: $countOnly


+

Yellow - SMS/Voice registered alongside other methods: $countDefault

+

Reference: https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement

+"@ + + ($htmlHead + $htmlBody + $htmlTail + $methodPolicyHtml) | Out-File -FilePath $Path -Encoding utf8 +} + +# --- Main -------------------------------------------------------------- +try { + Write-Verbose 'Connecting to Microsoft Graph...' + $requiredScopes = @( + 'User.Read.All' + 'UserAuthenticationMethod.Read.All' + 'Reports.Read.All' + 'AuditLog.Read.All' + 'Organization.Read.All' + 'Directory.Read.All' + 'Policy.Read.All' + ) + $connectParams = @{ Scopes = $requiredScopes; NoWelcome = $true } + if ($TenantId) { $connectParams['TenantId'] = $TenantId } + Connect-MgGraph @connectParams + + [array]$organization = Get-MgOrganization -ErrorAction SilentlyContinue + $tenantDisplayName = if ($organization) { $organization[0].DisplayName } else { 'Unknown tenant' } + + $isPremium = (-not $ForcePerUserLookup) -and (Test-PremiumAuthReportLicense) + Write-Host "Tenant: $tenantDisplayName - Premium (P1/P2) auth report available: $isPremium" -ForegroundColor Cyan + + # Pull user population first; every user in scope gets a row regardless + # of which data-source path is used for their methods. + Write-Host 'Retrieving user accounts...' -ForegroundColor Cyan + $userFilter = "accountEnabled eq true" + if (-not $IncludeGuests) { $userFilter += " and userType eq 'Member'" } + [array]$users = Get-MgUser -Filter $userFilter -ConsistencyLevel eventual -CountVariable usersFound ` + -Property Id, DisplayName, UserPrincipalName, UserType -All -PageSize 500 -Sort DisplayName + Write-Host "Found $($users.Count) enabled user account(s) to report on." -ForegroundColor Cyan + + # On premium tenants, try the fast tenant-wide report once; fall back to + # per-user lookup for everyone if the report call fails (e.g. missing + # consent, throttled tenant, or a licensing edge case the SKU check missed). + $registrationLookup = $null + if ($isPremium) { + try { + Write-Host 'Retrieving tenant-wide authentication method registration report...' -ForegroundColor Cyan + [array]$registrationDetails = Get-MgReportAuthenticationMethodUserRegistrationDetail -All -PageSize 500 + $registrationLookup = @{} + foreach ($detail in $registrationDetails) { + $registrationLookup[$detail.Id] = $detail + } + } + catch { + Write-Warning "Tenant-wide registration report failed even though P1/P2 was detected; falling back to per-user lookup. Error: $_" + $registrationLookup = $null + } + } + + $report = [System.Collections.Generic.List[Object]]::new() + [int]$i = 0 + foreach ($user in $users) { + $i++ + Write-Progress -Activity 'Retrieving MFA methods' -Status "$($user.DisplayName) ($i of $($users.Count))" -PercentComplete (($i / $users.Count) * 100) + Write-Verbose ("Processing {0} ({1}/{2})..." -f $user.DisplayName, $i, $users.Count) + + $registeredMethodsDisplay = $null + $hasStrongMethod = $false + $hasPhoneMethod = $false + $defaultMethod = $null + + $regDetail = if ($registrationLookup) { $registrationLookup[$user.Id] } else { $null } + + if ($regDetail) { + # Fast path: everything needed comes from the single tenant-wide report. + $registeredMethodsDisplay = ($regDetail.MethodsRegistered -join ', ') + $hasPhoneMethod = ($regDetail.MethodsRegistered -contains 'mobilePhone') -or ($regDetail.MethodsRegistered -contains 'alternateMobilePhone') -or ($regDetail.MethodsRegistered -contains 'officePhone') + $strongRegistered = $regDetail.MethodsRegistered | Where-Object { + $_ -notin @('mobilePhone', 'alternateMobilePhone', 'officePhone', 'email', 'password') + } + $hasStrongMethod = [bool]$strongRegistered + $defaultMethod = $regDetail.UserPreferredMethodForSecondaryAuthentication + } + else { + # Fallback path: per-user methods API, works without P1/P2. + try { + $detail = Get-UserAuthMethodDetail -UserId $user.Id + $registeredMethodsDisplay = $detail.MethodsDisplay + $hasStrongMethod = $detail.HasStrongMethod + $hasPhoneMethod = $detail.HasPhoneMethod + $defaultMethod = $detail.DefaultMethod + } + catch { + # Do not let one user's failure abort the whole tenant run. + Write-Warning "Skipping method detail for '$($user.UserPrincipalName)' after repeated failure: $_" + $registeredMethodsDisplay = 'Error retrieving methods' + } + } + + # If no explicit default was reported/available, fall back to a + # heuristic: if phone is the only MFA-capable method registered, + # it is by definition the (only possible) default. + $notes = $null + if (-not $defaultMethod) { + if ($hasPhoneMethod -and -not $hasStrongMethod) { + $defaultMethod = 'sms/voice (inferred - only method registered)' + } + elseif (-not $hasPhoneMethod -and -not $hasStrongMethod) { + $notes = 'No MFA method registered' + } + else { + $defaultMethod = 'Not reported' + } + } + + $rowClass = $null + if ($hasPhoneMethod -and -not $hasStrongMethod) { + $rowClass = 'SmsVoiceOnly' + } + elseif ($hasStrongMethod -and $hasPhoneMethod) { + # SMS/Voice is registered alongside other methods - not the user's + # only option, but it still needs to be removed from their + # account ahead of the retirement, regardless of default status. + $rowClass = 'SmsVoiceRegistered' + } + + $report.Add([PSCustomObject]@{ + DisplayName = $user.DisplayName + UserPrincipalName = $user.UserPrincipalName + RegisteredMethods = $registeredMethodsDisplay + DefaultMethod = $defaultMethod + RowClass = $rowClass + Notes = $notes + }) + } + Write-Progress -Activity 'Retrieving MFA methods' -Completed + + # Tenant-wide authentication methods policy: which methods are allowed + # (enabled) at all, regardless of who has actually registered them. + # Non-fatal - this is a supplementary overview, not required to have + # already produced the per-user report above. + [array]$methodPolicyStates = @() + try { + $authMethodPolicy = Get-MgPolicyAuthenticationMethodPolicy -ErrorAction Stop + [array]$methodPolicyStates = $authMethodPolicy.AuthenticationMethodConfigurations | + Select-Object Id, State | + Sort-Object Id + } + catch { + Write-Warning "Could not read tenant authentication methods policy: $_" + } + + if (-not (Test-Path -Path $OutputFolder)) { + New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null + } + $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' + # Prefer the tenant ID/domain the caller connected with (e.g. + # "contoso.onmicrosoft.com") over the organization's display name, since + # it's stable, unique, and matches how an MSP/CSP would identify the + # customer tenant across multiple runs. Fall back to the display name + # when no -TenantId was supplied (e.g. interactive sign-in). + $tenantIdentifier = if ($TenantId) { $TenantId } else { $tenantDisplayName } + # Strip characters that are reserved in file names on Windows (the + # strictest of the platforms this cross-platform script can run on) + # so the tenant identifier can be safely embedded in the file name. + $safeTenantName = ($tenantIdentifier -replace '[\\/:*?"<>|]', '_').Trim() + $csvPath = Join-Path $OutputFolder "UserMfaMethodReport_${safeTenantName}_$timestamp.csv" + $htmlPath = Join-Path $OutputFolder "UserMfaMethodReport_${safeTenantName}_$timestamp.html" + + $report | Select-Object DisplayName, UserPrincipalName, RegisteredMethods, DefaultMethod, RowClass, Notes | + Export-Csv -Path $csvPath -NoTypeInformation -Encoding utf8 + + Export-AuthenticationHtmlReport -Report $report -Path $htmlPath -TenantDisplayName $tenantDisplayName -IsPremiumTenant $isPremium -MethodPolicyStates $methodPolicyStates + + $countOnly = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceOnly' }).Count + $countDefault = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceRegistered' }).Count + + Write-Host "`n===== SUMMARY =====" -ForegroundColor Magenta + Write-Host "Tenant: $tenantDisplayName" + Write-Host "Total users reported: $($report.Count)" + Write-Host "Red (SMS/Voice only method): $countOnly" -ForegroundColor Red + Write-Host "Yellow (SMS/Voice registered alongside other methods): $countDefault" -ForegroundColor Yellow + Write-Host "CSV report: $csvPath" + Write-Host "HTML report: $htmlPath" + + if ($methodPolicyStates.Count -gt 0) { + Write-Host "`nAllowed authentication methods (tenant policy):" -ForegroundColor Magenta + foreach ($methodState in $methodPolicyStates) { + $isSmsVoice = $methodState.Id -in @('Sms', 'Voice') + $color = if ($methodState.State -ne 'enabled') { 'DarkGray' } elseif ($isSmsVoice) { 'Yellow' } else { 'Green' } + Write-Host (" {0,-32} {1}" -f $methodState.Id, $methodState.State) -ForegroundColor $color + } + } +} +catch { + Write-Verbose "Full stack trace: $($_.ScriptStackTrace)" + Write-Error "Script failed: $_" + exit 1 +} +finally { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null +} + +exit 0