Public/Get-MsecEntraMfaRegistration.ps1
|
function Get-MsecEntraMfaRegistration { <# .SYNOPSIS Per-user authentication-method registration state as flat rows - who can actually do MFA, with which methods, and who is an admin. .DESCRIPTION Calls Microsoft Graph /v1.0/reports/authenticationMethods/userRegistrationDetails, the data behind the Entra "Authentication methods - registration" report, and projects one row per user. This answers the question a Conditional Access policy cannot: CA proves MFA is *demanded*, this proves it is *possessed*. A tenant can have a flawless Require-MFA policy and still hold admin accounts with nothing registered, which surfaces only as a lockout or a bypass later. WHAT THIS DOES NOT TELL YOU. Registration is capability, not enforcement. A user can be IsMfaCapable and never once be challenged. "Is MFA actually required for this person" is a different question with three independent sources: Security defaults tenant-wide on/off - Get-MsecEntraTenantSecuritySetting Conditional Access per-policy conditions - Get-MsecEntraConditionalAccessPolicy lists the policies, but does NOT evaluate which apply to a given user; that needs the CA What-If evaluation API Legacy per-user MFA the pre-CA Enabled/Enforced/Disabled flag, which sits OUTSIDE Conditional Access entirely - an 'enforced' user is challenged whatever CA says - and is invisible in every other report here. -IncludePerUserMfaState reads it And the empirical answer - did they actually perform MFA - is in the sign-in logs, via Get-MsecEntraConditionalAccessSignInLog. When the three configuration sources disagree with the logs, the logs are right. IsMfaRegistered vs IsMfaCapable - the distinction that matters: IsMfaRegistered the user has registered at least one MFA method. IsMfaCapable the user has registered a method AND that method is enabled by the tenant's authentication-methods policy, i.e. it will actually work. Registered-but-not-capable means someone registered a method the tenant has since disabled. **Use IsMfaCapable for coverage reporting** - it is the honest number. THE DEFAULT METHOD IS NOT ONE FIELD. Entra stores the user's own choice (UserPreferredMfaMethod) and its own computed choice (SystemPreferredMfaMethods) side by side, and a tenant-level toggle (IsSystemPreferredMfaEnabled) decides which is used at sign-in: with system-preferred on, Entra picks the most secure registered method and the user's preference is ignored. DefaultMfaMethod resolves that - it is the method a sign-in would actually prompt for - and all three inputs stay on the row so the derivation can be checked. Method strength is the point of reading it: 'sms' and the 'voice*' values are phishable and interceptable, 'push' and 'oath' materially less so. A tenant whose administrators default to SMS has MFA in name. Requires the 'AuditLog.Read.All' application permission AND Microsoft Entra ID P1 or P2 on the tenant: this report is premium-gated independently of permissions. A 403 is re-thrown with Graph's own message, distinguishing the two causes, because a licensing 403 cannot be fixed by granting a permission. .PARAMETER IncludePerUserMfaState Also read each user's LEGACY per-user MFA state ('enabled', 'enforced', 'disabled') into PerUserMfaState. Off by default because there is no bulk endpoint: this costs ONE Graph call per user, and it uses the BETA endpoint /beta/users/{id}/authentication/requirements, which Microsoft does not support for production use. Needs 'Policy.Read.All', which the msec app already has. Filter before asking for it - piping only the admins through is the difference between a dozen calls and several thousand. .EXAMPLE Get-MsecEntraMfaRegistration | Where-Object { $_.IsAdmin -and -not $_.IsMfaCapable } .EXAMPLE # Method mix - how much of the estate rests on phishable phone-based MFA? Get-MsecEntraMfaRegistration | Select-Object -ExpandProperty MethodsRegistered | Group-Object | Sort-Object Count -Descending .EXAMPLE Get-MsecEntraMfaRegistration | Group-Object UserType, IsMfaCapable -NoElement .EXAMPLE # Administrators whose second factor is phishable - SMS or a voice call to a # phone number. This is the row an attacker with a SIM swap is looking for. Get-MsecEntraMfaRegistration | Where-Object { $_.IsAdmin -and $_.DefaultMfaMethod -match '^(sms|voice)' } | Format-Table UserPrincipalName, DefaultMfaMethod, MethodsRegistered .EXAMPLE # Where the tenant's defaults actually come from: with system-preferred # authentication off, every user's own choice stands unreviewed. Get-MsecEntraMfaRegistration | Group-Object IsSystemPreferredMfaEnabled, DefaultMfaMethod -NoElement .EXAMPLE # Legacy per-user MFA across the whole tenant. 'enabled' and 'enforced' predate # Conditional Access and override nothing - they simply also apply - so a tenant # that thinks it moved to CA years ago can still be running on these. Get-MsecEntraMfaRegistration -IncludePerUserMfaState | Group-Object PerUserMfaState -NoElement .EXAMPLE # Capability and enforcement together, for administrators only - the population # small enough to pay one Graph call each for. Get-MsecEntraMfaRegistration -IncludePerUserMfaState | Where-Object IsAdmin | Format-Table UserPrincipalName, IsMfaCapable, DefaultMfaMethod, PerUserMfaState .OUTPUTS PSCustomObject per user. See .NOTES for the projection. .NOTES Each row is a [PSCustomObject] with PSTypeName 'MsecEntraMfaRegistration', whose DefaultDisplayPropertySet (UserPrincipalName, IsAdmin, IsMfaCapable, DefaultMfaMethod) is registered in Msec.psm1. Projection (Graph field -> output property): id -> UserId userPrincipalName -> UserPrincipalName userDisplayName -> DisplayName userType -> UserType ('member' / 'guest') isAdmin -> IsAdmin (holds a privileged directory role) isMfaRegistered -> IsMfaRegistered isMfaCapable -> IsMfaCapable <- use this for coverage isPasswordlessCapable -> IsPasswordlessCapable isSsprRegistered/Enabled/Capable -> IsSsprRegistered / IsSsprEnabled / IsSsprCapable methodsRegistered -> MethodsRegistered (always an array) userPreferredMethodForSecondaryAuthentication -> UserPreferredMfaMethod systemPreferredAuthenticationMethods -> SystemPreferredMfaMethods (always an array) isSystemPreferredAuthenticationMethodEnabled -> IsSystemPreferredMfaEnabled (derived from those three) -> DefaultMfaMethod /beta .../authentication/requirements.perUserMfaState -> PerUserMfaState (only with -IncludePerUserMfaState) lastUpdatedDateTime -> LastUpdatedDateTime <entire object verbatim> -> Raw DefaultMfaMethod is derived, not a Graph field: the first SystemPreferredMfaMethods entry when IsSystemPreferredMfaEnabled is $true and that list is non-empty, otherwise UserPreferredMfaMethod. The collection is ranked, so its first entry is the one that would be used; it is empty for a user with nothing registered, where the user's own value ('none') is the more informative answer. DO NOT USE DefaultMfaMethod AS A YES/NO. Two ways it inverts the answer: 'none' is a legitimate value, meaning the user has no default second factor - and it is a non-empty string, so `Where-Object DefaultMfaMethod` and `if ($row.DefaultMfaMethod)` are BOTH true for it. A coverage count written that way reports users with no MFA as having MFA. It is a PREFERENCE, not a capability. With IsSystemPreferredMfaEnabled $false it is whatever the user last chose, which can name a method the tenant has since disabled in its authentication-methods policy. IsMfaCapable is the "can they actually do MFA" field: registered AND permitted by policy. DefaultMfaMethod answers "with WHICH method", which is a question about strength, not about coverage. TWO DIFFERENT VOCABULARIES, easily confused. MethodsRegistered uses method names ('mobilePhone', 'email', 'passKeyDeviceBound', ...). The preference fields use the second-factor enum: 'push', 'oath', 'voiceMobile', 'voiceAlternateMobile', 'voiceOffice', 'sms', 'none'. Do not join the two on equality; nothing will match. There is NO defaultMfaMethod property on the v1.0 userRegistrationDetails resource - an earlier version of this function read one, and produced an empty column on every row for every tenant. If a future Graph version adds one, prefer it to this derivation and delete the note. `isAdmin` is Graph's own flag for "holds a privileged directory role". It uses Microsoft's definition of privileged, which is not identical to the IsHighlyPrivileged list in Get-MsecEntraRoleHolder - cross-reference the two rather than assuming they agree. #> [CmdletBinding()] param( [Parameter()] [switch] $IncludePerUserMfaState ) Assert-MsecSession $path = '/v1.0/reports/authenticationMethods/userRegistrationDetails' try { $rows = @(Invoke-MsecGraphRequest -Path $path -All) } catch { $err = $_ if ($err.Exception.Message -notmatch '403|Forbidden') { throw } # Same two-causes-one-status-code problem as /auditLogs/signIns - see # Get-MsecGraphErrorMessage for why the response body has to be read. $detail = Get-MsecGraphErrorMessage $err if ($detail -match 'premium|B2C') { throw "The authentication-methods registration report is not available in this tenant. Microsoft Graph reports: '$detail'. This report requires Microsoft Entra ID P1 or P2 - a LICENSING limit, not a permission problem, so granting 'AuditLog.Read.All' will not change it. Treat MFA registration coverage as not measurable in this tenant." } throw "Forbidden when calling /reports/authenticationMethods/userRegistrationDetails. Microsoft Graph reports: '$detail'. The usual cause is the msec app missing the 'AuditLog.Read.All' application permission (admin consent required) - re-run New-MsecApp to add and consent it. If that permission is already consented, check licensing instead: this report also requires Entra ID P1/P2." } # @($null) yields a one-element array containing $null rather than an empty array, # which then breaks downstream -contains and .Count checks. # # Both branches are comma-wrapped because PowerShell UNROLLS a collection returned # from a scriptblock: plain `@()` emits zero objects, so the caller's property lands # as $null - the very thing this helper exists to prevent - and `@($v)` on a # single-element array would hand back the bare element. `,@(...)` emits the array # itself as one object, which survives the pipeline intact. $arr = { param($v) if ($null -eq $v) { , @() } else { , @($v) } } # Legacy per-user MFA has no bulk endpoint - it is one call per user. That cost is # reported through Write-Progress rather than a warning: a long run needs to look # like it is working, and a warning that cannot be acted on mid-pipeline is noise. if ($IncludePerUserMfaState) { Write-Verbose "Reading per-user MFA state for $($rows.Count) user(s) - one Graph call each." } $perUserFailures = 0 $perUserDone = 0 foreach ($r in $rows) { # The EFFECTIVE default second factor - the method a sign-in would actually # prompt for. When the tenant has system-preferred authentication enabled, # Entra picks the most secure registered method and the user's own preference # is ignored; otherwise the user's choice stands. Reporting either field alone # would be wrong in half of all tenants. # # systemPreferredAuthenticationMethods is a collection but is ranked, so the # first entry is what would be used; the whole list stays on the row. It is # empty for a user with nothing registered, and the user's preference ('none' # in that case) is the more informative answer, so that is the fallback. # Legacy per-user MFA: 'enabled', 'enforced' or 'disabled'. It sits OUTSIDE # Conditional Access - an enforced user is challenged whatever CA says - which # is why registration state alone cannot answer "is MFA required for them". # $null means not asked for, or asked for and failed; the warning below counts # the failures so the two are distinguishable. $perUserMfaState = $null if ($IncludePerUserMfaState -and $r.id) { $perUserDone++ if ($rows.Count -gt 25) { Write-Progress -Activity 'Reading legacy per-user MFA state' ` -Status "$perUserDone of $($rows.Count): $($r.userPrincipalName)" ` -PercentComplete ([Math]::Min(100, 100 * $perUserDone / $rows.Count)) } try { # Beta only. Microsoft has not shipped this in v1.0, and there is no # collection form - the state is reachable one user at a time. $req = Invoke-MsecGraphRequest -Path "/beta/users/$($r.id)/authentication/requirements" $perUserMfaState = $req.perUserMfaState } catch { $perUserFailures++ Write-Verbose "Could not read per-user MFA state for $($r.userPrincipalName): $(Get-MsecGraphErrorMessage $_)" } } $systemPreferred = & $arr $r.systemPreferredAuthenticationMethods $defaultMethod = if ($r.isSystemPreferredAuthenticationMethodEnabled -and $systemPreferred.Count) { $systemPreferred[0] } else { $r.userPreferredMethodForSecondaryAuthentication } [PSCustomObject]@{ PSTypeName = 'MsecEntraMfaRegistration' UserId = $r.id UserPrincipalName = $r.userPrincipalName DisplayName = $r.userDisplayName UserType = $r.userType IsAdmin = [bool] $r.isAdmin IsMfaRegistered = [bool] $r.isMfaRegistered IsMfaCapable = [bool] $r.isMfaCapable IsPasswordlessCapable = [bool] $r.isPasswordlessCapable IsSsprRegistered = [bool] $r.isSsprRegistered IsSsprEnabled = [bool] $r.isSsprEnabled IsSsprCapable = [bool] $r.isSsprCapable MethodsRegistered = & $arr $r.methodsRegistered # Which method is "the default" is not one field. Entra holds the user's own # choice and its own computed choice side by side, and a tenant-level toggle # decides which of the two is actually used at sign-in. UserPreferredMfaMethod = $r.userPreferredMethodForSecondaryAuthentication SystemPreferredMfaMethods = & $arr $r.systemPreferredAuthenticationMethods IsSystemPreferredMfaEnabled = [bool] $r.isSystemPreferredAuthenticationMethodEnabled DefaultMfaMethod = $defaultMethod PerUserMfaState = $perUserMfaState LastUpdatedDateTime = if ($r.lastUpdatedDateTime) { [datetime]$r.lastUpdatedDateTime } else { $null } Raw = $r } } if ($IncludePerUserMfaState -and $rows.Count -gt 25) { Write-Progress -Activity 'Reading legacy per-user MFA state' -Completed } if ($perUserFailures -gt 0) { Write-Warning "Per-user MFA state could not be read for $perUserFailures user(s), so their PerUserMfaState is `$null and indistinguishable from 'not requested'. This uses the BETA endpoint /beta/users/{id}/authentication/requirements and the 'Policy.Read.All' application permission; re-run with -Verbose to see Graph's message for each." } } |