631 lines
28 KiB
PowerShell
631 lines
28 KiB
PowerShell
<#
|
|
.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 only reflects an
|
|
# explicit override the user (or an admin) has set - unlike the
|
|
# tenant-wide usage/insights report, it's not gated behind a P1/P2
|
|
# license - but treat failures as "unknown" rather than fatal, since
|
|
# behavior can vary by tenant/SDK version. Deliberately NOT guessed/
|
|
# inferred when unavailable: Entra ID's actual method selection (e.g.
|
|
# under System-preferred MFA) depends on per-user state this API doesn't
|
|
# expose, so a guess here can be flatly wrong and is worse than admitting
|
|
# it's unknown - this value drives real remediation decisions.
|
|
$defaultMethod = $null
|
|
try {
|
|
$preference = Get-MgBetaUserAuthenticationSignInPreference -UserId $UserId -ErrorAction Stop
|
|
if ($preference.UserPreferredMethodForSecondaryAuthentication -and $preference.UserPreferredMethodForSecondaryAuthentication -ne 'none') {
|
|
$defaultMethod = $preference.UserPreferredMethodForSecondaryAuthentication
|
|
}
|
|
}
|
|
catch {
|
|
Write-Warning "No sign-in preference available for user '$UserId': $_"
|
|
}
|
|
|
|
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 = @"
|
|
<html>
|
|
<style>
|
|
BODY{font-family: Arial; font-size: 9pt;}
|
|
H1{font-size: 20px; font-family: 'Segoe UI Light','Segoe UI',Verdana,Arial,sans-serif;}
|
|
H3{font-size: 13px; font-family: 'Segoe UI Light','Segoe UI',Verdana,Arial,sans-serif; font-weight: normal;}
|
|
TABLE{border: 1px solid #969595; border-collapse: collapse; font-size: 9pt;}
|
|
TH{border: 1px solid #969595; background: #dddddd; padding: 5px; color: #000000;}
|
|
TD{border: 1px solid #969595; padding: 5px;}
|
|
tr.SmsVoiceOnly{background: #f8d7da;}
|
|
tr.SmsVoiceRegistered{background: #fff3cd;}
|
|
tr.MethodEnabledSmsVoice{background: #fff3cd;}
|
|
tr.MethodEnabled{background: #d4edda;}
|
|
tr.MethodDisabled{background: #e2e3e5; color: #6c6c6c;}
|
|
</style>
|
|
<body>
|
|
<div align="center">
|
|
<h1>User MFA Method Report - SMS/Voice Retirement Impact</h1>
|
|
<h3>Tenant: $TenantDisplayName | Generated: $runDate | $licenseNote</h3>
|
|
</div>
|
|
"@
|
|
|
|
$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 <tr> 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('<div>{0}</div>', $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' }
|
|
"<tr class='$rowClass'><td>$($methodState.Id)</td><td>$($methodState.State)</td></tr>"
|
|
}
|
|
$methodPolicyHtml = @"
|
|
<div align="center">
|
|
<h3>Allowed authentication methods (tenant policy)</h3>
|
|
<table>
|
|
<tr><th>Method</th><th>State</th></tr>
|
|
$($methodRows -join "`n")
|
|
</table>
|
|
</div>
|
|
"@
|
|
}
|
|
|
|
$htmlTail = @"
|
|
<p>-----------------------------------------------------------------------</p>
|
|
<p>Total users reported: $($Report.Count)</p>
|
|
<p style="background:#f8d7da; display:inline-block; padding:2px 6px;">Red - only MFA method is SMS/Voice: $countOnly</p><br/>
|
|
<p style="background:#fff3cd; display:inline-block; padding:2px 6px;">Yellow - SMS/Voice registered alongside other methods: $countDefault</p>
|
|
<p>Reference: https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement</p>
|
|
"@
|
|
|
|
($htmlHead + $htmlBody + $htmlTail + $methodPolicyHtml) | Out-File -FilePath $Path -Encoding utf8
|
|
}
|
|
|
|
# --- Main --------------------------------------------------------------
|
|
try {
|
|
# Fail fast and loudly if a module this script depends on isn't
|
|
# installed OR can't actually be loaded (e.g. an assembly-version
|
|
# conflict with another installed Microsoft.Graph module version),
|
|
# rather than letting every per-user call into it fail silently later
|
|
# and produce an incomplete/misleading report. Microsoft.Graph.Beta.
|
|
# Identity.SignIns provides Get-MgBetaUserAuthenticationSignInPreference,
|
|
# which reads each user's actual default sign-in method - there is no
|
|
# v1.0/GA equivalent, this data is beta-only in Microsoft Graph.
|
|
$requiredBetaModule = 'Microsoft.Graph.Beta.Identity.SignIns'
|
|
if (-not (Get-Module -ListAvailable -Name $requiredBetaModule)) {
|
|
throw "Required module '$requiredBetaModule' is not installed. It's needed to read each user's actual default sign-in method. Install it with: Install-Module $requiredBetaModule -Scope CurrentUser"
|
|
}
|
|
try {
|
|
Import-Module -Name $requiredBetaModule -ErrorAction Stop
|
|
}
|
|
catch {
|
|
throw "Required module '$requiredBetaModule' is installed but failed to load - this is usually caused by multiple mismatched Microsoft.Graph module versions installed side by side (an assembly-version conflict), not a missing module. Run: Get-Module -ListAvailable 'Microsoft.Graph*' | Group-Object Name | Where-Object { (`$_.Group.Version | Sort-Object -Unique).Count -gt 1 } to find the mismatched ones, update them all to the same version, and remove the old ones. Underlying error: $_"
|
|
}
|
|
|
|
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
|
|
# This report field is also "none" (a literal string, not $null)
|
|
# for users without an explicit override - most commonly because
|
|
# the tenant runs System-preferred MFA. Normalize it so the
|
|
# fallback heuristic below actually kicks in instead of the row
|
|
# showing the literal word "none" as if it were a real method.
|
|
$defaultMethod = $regDetail.UserPreferredMethodForSecondaryAuthentication
|
|
if ($defaultMethod -eq 'none') { $defaultMethod = $null }
|
|
}
|
|
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
|