Compare commits
3 Commits
f2f0537bb9
...
7b82711ec7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b82711ec7 | |||
| 4c49f49f11 | |||
| 6b1595c168 |
@@ -0,0 +1,813 @@
|
||||
<#
|
||||
.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 Test-GraphModuleEnvironment {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Checks the Microsoft.Graph modules this script depends on for the
|
||||
most common cause of hard-to-diagnose runtime failures: multiple
|
||||
mismatched versions of the same module installed side by side.
|
||||
.DESCRIPTION
|
||||
When several versions of a Microsoft.Graph module are installed at
|
||||
once, PowerShell can end up loading one version's assembly into the
|
||||
session and then, mid-script, trying to auto-load a different
|
||||
module that depends on a different version - which fails with
|
||||
"assembly already loaded" or "could not load file or assembly"
|
||||
errors. This surfaces as scattered per-user warnings deep into a
|
||||
run rather than one clear message, so it's worth catching upfront.
|
||||
.PARAMETER RequiredModuleNames
|
||||
Names of the Microsoft.Graph modules this script depends on.
|
||||
.OUTPUTS
|
||||
[bool] $true if the environment looks consistent, $false if a
|
||||
problem was found (details and a fix are written via Write-Warning).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string[]]
|
||||
$RequiredModuleNames
|
||||
)
|
||||
|
||||
$requiredModuleNames = $RequiredModuleNames
|
||||
|
||||
# @(...) around each pipeline (not just an [array] cast on the variable)
|
||||
# is required here: when a pipeline produces zero output, PowerShell
|
||||
# assigns $null even to an [array]-typed variable, not an empty array -
|
||||
# only wrapping the pipeline itself in @(...) guarantees a real array.
|
||||
[array]$installed = @(Get-Module -ListAvailable -Name $requiredModuleNames -ErrorAction SilentlyContinue)
|
||||
# Piped through Select-Object rather than direct dot-access ($installed.Name):
|
||||
# member-enumeration on an empty array throws under StrictMode, and
|
||||
# $installed IS legitimately empty on a machine with none of these
|
||||
# modules installed at all.
|
||||
[array]$installedNames = @($installed | Select-Object -ExpandProperty Name)
|
||||
[array]$missingNames = @($requiredModuleNames | Where-Object { $_ -notin $installedNames })
|
||||
[array]$allVersions = @($installed | Select-Object -ExpandProperty Version -Unique)
|
||||
$hasVersionMismatch = $allVersions.Count -gt 1
|
||||
|
||||
# Display-only reference: whichever version most of the required
|
||||
# modules currently have installed. Not the authoritative "correct"
|
||||
# version (Repair-GraphModuleEnvironment resolves that separately from
|
||||
# the Beta module's manifest / the gallery) - just enough to flag which
|
||||
# rows are the odd ones out when printing the table below.
|
||||
$referenceVersion = $null
|
||||
if ($installed.Count -gt 0) {
|
||||
$referenceVersion = ($installed | Group-Object Version | Sort-Object Count -Descending | Select-Object -First 1).Name
|
||||
}
|
||||
|
||||
Write-Host 'Checking required Microsoft.Graph modules...' -ForegroundColor Cyan
|
||||
$nameColumnWidth = 48
|
||||
foreach ($name in $requiredModuleNames) {
|
||||
[array]$moduleVersions = @($installed | Where-Object { $_.Name -eq $name } | Select-Object -ExpandProperty Version | Sort-Object -Descending)
|
||||
|
||||
$isOk = $false
|
||||
if ($moduleVersions.Count -eq 1) {
|
||||
if ($moduleVersions[0].ToString() -eq $referenceVersion) {
|
||||
$isOk = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($isOk) {
|
||||
Write-Host (" [OK] {0,-$nameColumnWidth} {1}" -f $name, $moduleVersions[0]) -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
$versionDisplay = if ($moduleVersions.Count -gt 0) { $moduleVersions -join ', ' } else { 'not installed' }
|
||||
Write-Host (" [FAIL] {0,-$nameColumnWidth} {1}" -f $name, $versionDisplay) -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingNames.Count -eq 0 -and -not $hasVersionMismatch) {
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Warning 'Microsoft.Graph module check found a problem (see [FAIL] rows above) likely to cause runtime failures (e.g. "assembly already loaded" errors partway through the report).'
|
||||
return $false
|
||||
}
|
||||
|
||||
function Repair-GraphModuleEnvironment {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Installs missing Microsoft.Graph modules and removes mismatched
|
||||
versions of the ones this script depends on.
|
||||
.DESCRIPTION
|
||||
Determines the single version every required module must share -
|
||||
the exact version Microsoft.Graph.Beta.Identity.SignIns' own
|
||||
manifest pins for Microsoft.Graph.Authentication (a RequiredVersion,
|
||||
not just a minimum), or, if that module isn't installed at all yet,
|
||||
the latest version currently published on the gallery. For each
|
||||
required module, it then installs that exact version if missing and
|
||||
offers to remove any other installed version of it.
|
||||
|
||||
Every install/uninstall changes state outside this repository (and
|
||||
removing a module version can affect other scripts on the machine
|
||||
that depend on it), so this supports -WhatIf/-Confirm and prompts
|
||||
for confirmation by default (ConfirmImpact = 'High').
|
||||
.PARAMETER RequiredModuleNames
|
||||
Names of the Microsoft.Graph modules this script depends on.
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string[]]
|
||||
$RequiredModuleNames
|
||||
)
|
||||
|
||||
$betaModuleName = 'Microsoft.Graph.Beta.Identity.SignIns'
|
||||
$betaModule = Get-Module -ListAvailable -Name $betaModuleName -ErrorAction SilentlyContinue |
|
||||
Sort-Object Version -Descending | Select-Object -First 1
|
||||
|
||||
if ($betaModule) {
|
||||
$pin = $betaModule.RequiredModules | Where-Object { $_.Name -eq 'Microsoft.Graph.Authentication' } | Select-Object -First 1
|
||||
$targetVersion = if ($pin) { $pin.Version } else { $betaModule.Version }
|
||||
}
|
||||
else {
|
||||
try {
|
||||
Write-Host "Looking up the latest published version of $betaModuleName..." -ForegroundColor Cyan
|
||||
$targetVersion = (Find-Module -Name $betaModuleName -ErrorAction Stop).Version
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Could not look up the latest version of $betaModuleName from the gallery: $_"
|
||||
return
|
||||
}
|
||||
}
|
||||
Write-Host "Target Microsoft.Graph module version: $targetVersion" -ForegroundColor Cyan
|
||||
|
||||
foreach ($name in $RequiredModuleNames) {
|
||||
[array]$copies = @(Get-Module -ListAvailable -Name $name -ErrorAction SilentlyContinue)
|
||||
$hasTarget = $copies | Where-Object { $_.Version -eq $targetVersion }
|
||||
|
||||
if (-not $hasTarget) {
|
||||
if ($PSCmdlet.ShouldProcess("$name $targetVersion", 'Install-Module')) {
|
||||
try {
|
||||
Install-Module -Name $name -RequiredVersion $targetVersion -Scope CurrentUser -Force -Confirm:$false -AllowClobber -ErrorAction Stop
|
||||
Write-Host "Installed $name $targetVersion" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Could not install $name ${targetVersion}: $_"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "$name $targetVersion was not installed - the script cannot continue without it."
|
||||
}
|
||||
}
|
||||
|
||||
[array]$stale = @($copies | Where-Object { $_.Version -ne $targetVersion })
|
||||
foreach ($old in $stale) {
|
||||
if ($PSCmdlet.ShouldProcess("$name $($old.Version)", 'Uninstall-Module')) {
|
||||
try {
|
||||
Uninstall-Module -Name $name -RequiredVersion $old.Version -Force -Confirm:$false -ErrorAction Stop
|
||||
Write-Host "Removed $name $($old.Version)" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Could not remove $name $($old.Version) - it may be installed system-wide (Scope AllUsers) and need an elevated PowerShell session to remove: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 the Microsoft.Graph modules this script
|
||||
# depends on aren't installed OR can't actually be loaded (e.g. an
|
||||
# assembly-version conflict between mismatched module versions),
|
||||
# rather than letting every per-user call into them fail silently later
|
||||
# and produce an incomplete/misleading report. If a problem is found,
|
||||
# offer to fix it (install what's missing, remove mismatched versions)
|
||||
# rather than just describing the fix and stopping.
|
||||
$requiredGraphModuleNames = @(
|
||||
'Microsoft.Graph.Authentication'
|
||||
'Microsoft.Graph.Beta.Identity.SignIns'
|
||||
'Microsoft.Graph.Identity.DirectoryManagement'
|
||||
'Microsoft.Graph.Identity.SignIns'
|
||||
'Microsoft.Graph.Reports'
|
||||
'Microsoft.Graph.Users'
|
||||
)
|
||||
if (-not (Test-GraphModuleEnvironment -RequiredModuleNames $requiredGraphModuleNames)) {
|
||||
Repair-GraphModuleEnvironment -RequiredModuleNames $requiredGraphModuleNames
|
||||
if (-not (Test-GraphModuleEnvironment -RequiredModuleNames $requiredGraphModuleNames)) {
|
||||
throw 'Microsoft.Graph module environment still has a problem after the repair attempt - see the warnings above. Close this terminal window, open a new one, and try again; if it still fails, some of the old versions may be installed system-wide (Scope AllUsers) and need to be removed from an elevated PowerShell session.'
|
||||
}
|
||||
Write-Host 'Microsoft.Graph module environment repaired.' -ForegroundColor Green
|
||||
}
|
||||
$requiredBetaModule = 'Microsoft.Graph.Beta.Identity.SignIns'
|
||||
try {
|
||||
Import-Module -Name $requiredBetaModule -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
throw "Required module '$requiredBetaModule' failed to load even though the version check passed - see the underlying error for details: $_"
|
||||
}
|
||||
|
||||
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 {
|
||||
# -ErrorAction SilentlyContinue does not suppress "command not found" -
|
||||
# that's a terminating parse/discovery-time error, not a stream event -
|
||||
# so guard explicitly for the case where the script failed before
|
||||
# Microsoft.Graph.Authentication was ever loaded (e.g. missing modules).
|
||||
if (Get-Command -Name Disconnect-MgGraph -ErrorAction SilentlyContinue) {
|
||||
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user