Enhance MFA method reporting by adding new classification for default SMS/Voice methods and updating HTML output for clarity

This commit is contained in:
Petr Štěpán
2026-07-29 14:27:01 +02:00
parent 7b82711ec7
commit 698139884f
+37 -9
View File
@@ -26,10 +26,14 @@
are SMS and/or Voice call. These accounts are SMS and/or Voice call. These accounts
will hit the blocking passkey prompt on will hit the blocking passkey prompt on
Feb 2027 retirement with no fallback. Feb 2027 retirement with no fallback.
- RED ("SmsVoiceDefault"): the user's actual default/preferred
method is SMS or Voice, even though
other methods are also registered - the
direct risk this retirement is about.
- YELLOW ("SmsVoiceRegistered"): other MFA methods are registered too, - YELLOW ("SmsVoiceRegistered"): other MFA methods are registered too,
but SMS or Voice is also registered and and SMS or Voice is also registered (but
still needs to be removed from the not the default) - still needs to be
account ahead of the retirement. removed ahead of the retirement.
- (none): no SMS/Voice exposure, or no MFA registered - (none): no SMS/Voice exposure, or no MFA registered
at all (flagged separately in the Notes at all (flagged separately in the Notes
column - a different problem, not this column - a different problem, not this
@@ -70,9 +74,16 @@
.NOTES .NOTES
Author: Petr Štěpán Author: Petr Štěpán
Created: 2026-07-27 Created: 2026-07-27
Version: 1.0.0 Version: 1.0.1
Changelog: Changelog:
1.0.0 - Initial version 1.0.0 - Initial version
1.0.1 - Added tenant authentication methods policy overview (console
summary and HTML report), progress bar for per-user
processing, pre-flight Microsoft.Graph module version check
with an offer to auto-repair mismatched/missing modules,
and a new RED classification for accounts whose actual
default sign-in method is SMS/Voice (previously only
flagged yellow unless it was the user's only method).
#> #>
#Requires -Version 7.0 #Requires -Version 7.0
@@ -506,6 +517,7 @@ TABLE{border: 1px solid #969595; border-collapse: collapse; font-size: 9pt;}
TH{border: 1px solid #969595; background: #dddddd; padding: 5px; color: #000000;} TH{border: 1px solid #969595; background: #dddddd; padding: 5px; color: #000000;}
TD{border: 1px solid #969595; padding: 5px;} TD{border: 1px solid #969595; padding: 5px;}
tr.SmsVoiceOnly{background: #f8d7da;} tr.SmsVoiceOnly{background: #f8d7da;}
tr.SmsVoiceDefault{background: #f8d7da;}
tr.SmsVoiceRegistered{background: #fff3cd;} tr.SmsVoiceRegistered{background: #fff3cd;}
tr.MethodEnabledSmsVoice{background: #fff3cd;} tr.MethodEnabledSmsVoice{background: #fff3cd;}
tr.MethodEnabled{background: #d4edda;} tr.MethodEnabled{background: #d4edda;}
@@ -528,7 +540,8 @@ tr.MethodDisabled{background: #e2e3e5; color: #6c6c6c;}
$xml.table.Attributes.Append($tableClassAttr) | Out-Null $xml.table.Attributes.Append($tableClassAttr) | Out-Null
# Walk the generated <tr> rows in lock-step with the report rows to apply # Walk the generated <tr> rows in lock-step with the report rows to apply
# the RowClass (SmsVoiceOnly / SmsVoiceRegistered / none) computed earlier. # the RowClass (SmsVoiceOnly / SmsVoiceDefault / SmsVoiceRegistered / none)
# computed earlier.
$rowIndex = 0 $rowIndex = 0
foreach ($tableRow in $xml.table.SelectNodes('tr')) { foreach ($tableRow in $xml.table.SelectNodes('tr')) {
if ($tableRow.SelectNodes('th').Count -eq 0 -and $rowIndex -lt $Report.Count) { if ($tableRow.SelectNodes('th').Count -eq 0 -and $rowIndex -lt $Report.Count) {
@@ -543,7 +556,8 @@ tr.MethodDisabled{background: #e2e3e5; color: #6c6c6c;}
$htmlBody = [string]::Format('<div>{0}</div>', $xml.OuterXml) $htmlBody = [string]::Format('<div>{0}</div>', $xml.OuterXml)
$countOnly = @($Report | Where-Object { $_.RowClass -eq 'SmsVoiceOnly' }).Count $countOnly = @($Report | Where-Object { $_.RowClass -eq 'SmsVoiceOnly' }).Count
$countDefault = @($Report | Where-Object { $_.RowClass -eq 'SmsVoiceRegistered' }).Count $countDefault = @($Report | Where-Object { $_.RowClass -eq 'SmsVoiceDefault' }).Count
$countRegistered = @($Report | Where-Object { $_.RowClass -eq 'SmsVoiceRegistered' }).Count
$methodPolicyHtml = '' $methodPolicyHtml = ''
if ($MethodPolicyStates.Count -gt 0) { if ($MethodPolicyStates.Count -gt 0) {
@@ -567,7 +581,8 @@ $($methodRows -join "`n")
<p>-----------------------------------------------------------------------</p> <p>-----------------------------------------------------------------------</p>
<p>Total users reported: $($Report.Count)</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:#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 style="background:#f8d7da; display:inline-block; padding:2px 6px;">Red - default method is SMS/Voice: $countDefault</p><br/>
<p style="background:#fff3cd; display:inline-block; padding:2px 6px;">Yellow - SMS/Voice registered, not default: $countRegistered</p>
<p>Reference: https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement</p> <p>Reference: https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sms-voice-retirement</p>
"@ "@
@@ -716,10 +731,21 @@ try {
} }
} }
# Only matches a verified value (from the Graph API preference read,
# or the tenant-wide report) or our own logical deduction above (not
# a guess) - 'Not reported' and $null correctly fail to match.
$defaultIsPhone = $defaultMethod -match '(?i)^(sms|voice)'
$rowClass = $null $rowClass = $null
if ($hasPhoneMethod -and -not $hasStrongMethod) { if ($hasPhoneMethod -and -not $hasStrongMethod) {
$rowClass = 'SmsVoiceOnly' $rowClass = 'SmsVoiceOnly'
} }
elseif ($defaultIsPhone) {
# The user's actual default/preferred method is SMS or Voice -
# this is the direct risk the retirement is about, regardless of
# what other methods are also registered.
$rowClass = 'SmsVoiceDefault'
}
elseif ($hasStrongMethod -and $hasPhoneMethod) { elseif ($hasStrongMethod -and $hasPhoneMethod) {
# SMS/Voice is registered alongside other methods - not the user's # SMS/Voice is registered alongside other methods - not the user's
# only option, but it still needs to be removed from their # only option, but it still needs to be removed from their
@@ -776,13 +802,15 @@ try {
Export-AuthenticationHtmlReport -Report $report -Path $htmlPath -TenantDisplayName $tenantDisplayName -IsPremiumTenant $isPremium -MethodPolicyStates $methodPolicyStates Export-AuthenticationHtmlReport -Report $report -Path $htmlPath -TenantDisplayName $tenantDisplayName -IsPremiumTenant $isPremium -MethodPolicyStates $methodPolicyStates
$countOnly = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceOnly' }).Count $countOnly = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceOnly' }).Count
$countDefault = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceRegistered' }).Count $countDefault = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceDefault' }).Count
$countRegistered = @($report | Where-Object { $_.RowClass -eq 'SmsVoiceRegistered' }).Count
Write-Host "`n===== SUMMARY =====" -ForegroundColor Magenta Write-Host "`n===== SUMMARY =====" -ForegroundColor Magenta
Write-Host "Tenant: $tenantDisplayName" Write-Host "Tenant: $tenantDisplayName"
Write-Host "Total users reported: $($report.Count)" Write-Host "Total users reported: $($report.Count)"
Write-Host "Red (SMS/Voice only method): $countOnly" -ForegroundColor Red Write-Host "Red (SMS/Voice only method): $countOnly" -ForegroundColor Red
Write-Host "Yellow (SMS/Voice registered alongside other methods): $countDefault" -ForegroundColor Yellow Write-Host "Red (SMS/Voice is default method): $countDefault" -ForegroundColor Red
Write-Host "Yellow (SMS/Voice registered, not default): $countRegistered" -ForegroundColor Yellow
Write-Host "CSV report: $csvPath" Write-Host "CSV report: $csvPath"
Write-Host "HTML report: $htmlPath" Write-Host "HTML report: $htmlPath"