Src/Private/Get-AbrEntraIDMFA.ps1
|
function Get-AbrEntraIDMFA { <# .SYNOPSIS Documents the MFA registration and enforcement status for all users in Entra ID. .DESCRIPTION Collects and reports on: - Per-user MFA registration status (registered methods, capable, enforced) - MFA registration summary (totals, gaps) - Users without MFA registered (critical gap list) - MFA enforcement via Conditional Access vs per-user MFA - Per-user MFA state (Disabled / Enabled / Enforced) where applicable .NOTES Version: 0.1.20 Author: Pai Wei Sing #> [CmdletBinding()] param ( [Parameter(Position = 0, Mandatory)] [string]$TenantId ) begin { Write-PScriboMessage -Message "Collecting Entra ID MFA status for tenant $TenantId." Show-AbrDebugExecutionTime -Start -TitleMessage 'MFA' } process { #region MFA Section -Style Heading2 'Multi-Factor Authentication (MFA)' { Paragraph "The following section documents the MFA registration and enforcement posture for tenant $TenantId." BlankLine #region MFA Registration Details (Graph credentialUserRegistrationDetails) try { Write-Host " - Retrieving MFA registration details..." # credentialUserRegistrationDetails provides MFA-capable / registered / default method info # Use REST API directly - Get-MgReportCredentialUserRegistrationDetail requires PS7 $RegResponse = Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/reports/authenticationMethods/userRegistrationDetails?$top=999' -ErrorAction Stop $RegDetailsRaw = $RegResponse.value while ($RegResponse.'@odata.nextLink') { $RegResponse = Invoke-MgGraphRequest -Method GET -Uri $RegResponse.'@odata.nextLink' -ErrorAction Stop $RegDetailsRaw += $RegResponse.value } # Convert to objects matching the expected property names $RegDetails = $RegDetailsRaw | ForEach-Object { [PSCustomObject]@{ UserDisplayName = $_.userDisplayName UserPrincipalName= $_.userPrincipalName IsMfaRegistered = $_.isMfaRegistered IsMfaCapable = $_.isMfaCapable IsSsprEnabled = $_.isSsprEnabled IsSsprRegistered = $_.isSsprRegistered AuthMethods = $_.authMethods } } if ($RegDetails) { #region MFA Registration Summary $TotalReported = @($RegDetails).Count $MfaCapable = @($RegDetails | Where-Object { $_.IsMfaCapable }).Count $MfaRegistered = @($RegDetails | Where-Object { $_.IsMfaRegistered }).Count $SsprEnabled = @($RegDetails | Where-Object { $_.IsSsprEnabled }).Count $SsprRegistered = @($RegDetails | Where-Object { $_.IsSsprRegistered }).Count $NotMfaCapable = $TotalReported - $MfaCapable $RegSumObj = [System.Collections.ArrayList]::new() $regSumInObj = [ordered] @{ 'Total Users Reported' = $TotalReported 'MFA Capable' = $MfaCapable 'MFA Registered' = $MfaRegistered 'NOT MFA Capable (Gap)' = $NotMfaCapable 'SSPR Enabled' = $SsprEnabled 'SSPR Registered' = $SsprRegistered 'MFA Coverage (%)' = if ($TotalReported -gt 0) { [math]::Round(($MfaCapable / $TotalReported) * 100, 1) } else { 0 } } $RegSumObj.Add([pscustomobject]$regSumInObj) | Out-Null $null = (& { if ($HealthCheck.EntraID.MFA) { $null = ($RegSumObj | Where-Object { [int]$_.'NOT MFA Capable (Gap)' -gt 0 } | Set-Style -Style Critical | Out-Null) } }) $RegSumTableParams = @{ Name = "MFA Registration Summary - $TenantId"; List = $true; ColumnWidths = 50, 50 } if ($Report.ShowTableCaptions) { $RegSumTableParams['Caption'] = "- $($RegSumTableParams.Name)" } $RegSumObj | Table @RegSumTableParams # Render chart here -- after summary, before per-user detail # (chart is generated later after all vars are computed, stored in $script:Charts['MFA']) if ($script:Charts['MFA']) { BlankLine Image -Text 'MFA Coverage' -Base64 $script:Charts['MFA'] -Percent 65 -Align Center Paragraph "Figure: MFA Coverage -- $MfaCapableCount of $TotalMfaUsers users ($MfaPct%) are MFA capable" BlankLine } #endregion #region Per-User MFA Registration Detail Table (InfoLevel 2) if ($InfoLevel.MFA -ge 2) { $MfaDetailObj = [System.Collections.ArrayList]::new() foreach ($Reg in ($RegDetails | Sort-Object UserDisplayName)) { $Methods = if ($Reg.AuthMethods -and $Reg.AuthMethods.Count -gt 0) { ($Reg.AuthMethods -join ', ') } else { 'None' } $mfaDetailInObj = [ordered] @{ 'Display Name' = $Reg.UserDisplayName 'UPN' = $Reg.UserPrincipalName 'MFA Registered' = if ($Reg.IsMfaRegistered) { 'Yes' } else { 'No' } 'MFA Capable' = if ($Reg.IsMfaCapable) { 'Yes' } else { 'No' } 'SSPR Enabled' = if ($Reg.IsSsprEnabled) { 'Yes' } else { 'No' } 'SSPR Registered' = if ($Reg.IsSsprRegistered) { 'Yes' } else { 'No' } 'Registered Methods' = $Methods } $MfaDetailObj.Add([pscustomobject](ConvertTo-HashToYN $mfaDetailInObj)) | Out-Null } $null = (& { if ($HealthCheck.EntraID.MFA) { $null = ($MfaDetailObj | Where-Object { $_.'MFA Registered' -eq 'No' } | Set-Style -Style Critical | Out-Null) $null = ($MfaDetailObj | Where-Object { $_.'MFA Capable' -eq 'No' } | Set-Style -Style Warning | Out-Null) } }) $MfaDetailTableParams = @{ Name = "MFA Registration per User - $TenantId"; List = $false; ColumnWidths = 18, 24, 10, 10, 10, 10, 18 } if ($Report.ShowTableCaptions) { $MfaDetailTableParams['Caption'] = "- $($MfaDetailTableParams.Name)" } $MfaDetailObj | Table @MfaDetailTableParams # Store for Excel export $null = ($script:ExcelSheets['MFA Registration'] = $MfaDetailObj) } #endregion #region Users WITHOUT MFA (gap report) if ($InfoLevel.MFA -ge 2) { $NoMfaUsers = $RegDetails | Where-Object { -not $_.IsMfaCapable } if ($NoMfaUsers) { Section -Style Heading3 'Users Without MFA Registered' { Paragraph "The following $(@($NoMfaUsers).Count) user(s) are not MFA capable and represent a security gap in tenant $TenantId. Remediation should be prioritised." BlankLine $NoMfaObj = [System.Collections.ArrayList]::new() foreach ($User in ($NoMfaUsers | Sort-Object UserDisplayName)) { $noMfaInObj = [ordered] @{ 'Display Name' = $User.UserDisplayName 'UPN' = $User.UserPrincipalName 'MFA Registered' = if ($User.IsMfaRegistered) { 'Yes' } else { 'No' } 'MFA Capable' = if ($User.IsMfaCapable) { 'Yes' } else { 'No' } 'SSPR Enabled' = if ($User.IsSsprEnabled) { 'Yes' } else { 'No' } } $NoMfaObj.Add([pscustomobject](ConvertTo-HashToYN $noMfaInObj)) | Out-Null } $null = (& { if ($HealthCheck.EntraID.MFA) { $null = ($NoMfaObj | Set-Style -Style Critical | Out-Null) } }) $NoMfaTableParams = @{ Name = "Users Without MFA - $TenantId"; List = $false; ColumnWidths = 22, 36, 14, 14, 14 } if ($Report.ShowTableCaptions) { $NoMfaTableParams['Caption'] = "- $($NoMfaTableParams.Name)" } $NoMfaObj | Table @NoMfaTableParams # Store for Excel export $null = ($script:ExcelSheets['Users Without MFA'] = $NoMfaObj) } } else { Paragraph "All users in tenant $TenantId have MFA registered. No remediation required." } } #endregion } } catch { Write-AbrSectionError -Section 'MFA Registration Details' -Message "MFA registration data unavailable. Requires Reports.Read.All scope - reconnect if using KeepSession. Error: $($_.Exception.Message)" Paragraph "MFA registration data could not be retrieved. Ensure the Reports.Read.All permission is granted." } #endregion #region Per-User MFA State (legacy per-user MFA enforcement) # Fix #16 -- StrongAuthenticationRequirements is not available in v1.0 Graph. # Must use the beta endpoint via Invoke-MgGraphRequest. if ($InfoLevel.MFA -ge 2) { try { Write-Host " - Retrieving per-user MFA state (legacy, via beta endpoint)..." $BetaUsers = Invoke-MgGraphRequest -Method GET ` -Uri 'https://graph.microsoft.com/beta/users?$select=id,displayName,userPrincipalName,strongAuthenticationRequirements&$top=999' ` -ErrorAction SilentlyContinue $UsersWithState = if ($BetaUsers -and $BetaUsers.value) { $BetaUsers.value } else { @() } # Handle paging while ($BetaUsers.'@odata.nextLink') { $BetaUsers = Invoke-MgGraphRequest -Method GET -Uri $BetaUsers.'@odata.nextLink' -ErrorAction SilentlyContinue if ($BetaUsers -and $BetaUsers.value) { $UsersWithState += $BetaUsers.value } } if ($UsersWithState) { $MfaStateObj = [System.Collections.ArrayList]::new() foreach ($User in ($UsersWithState | Sort-Object displayName)) { $Reqs = $User.strongAuthenticationRequirements $State = if ($Reqs -and @($Reqs).Count -gt 0) { $Reqs[0].state } else { 'Disabled' } $stateInObj = [ordered] @{ 'Display Name' = $User.displayName 'UPN' = $User.userPrincipalName 'Per-User MFA State' = if ($State) { $State } else { 'Disabled' } } $MfaStateObj.Add([pscustomobject]$stateInObj) | Out-Null } # Only show section if at least some per-user MFA is configured $HasLegacy = @($MfaStateObj | Where-Object { $_.'Per-User MFA State' -ne 'Disabled' }) if ($HasLegacy.Count -gt 0) { Section -Style Heading3 'Legacy Per-User MFA State' { Paragraph "The following users have per-user MFA state configured in tenant $TenantId. Microsoft recommends migrating to Conditional Access-based MFA enforcement." BlankLine $null = (& { if ($HealthCheck.EntraID.MFA) { $null = ($MfaStateObj | Where-Object { $_.'Per-User MFA State' -eq 'Enabled' } | Set-Style -Style Warning | Out-Null) $null = ($MfaStateObj | Where-Object { $_.'Per-User MFA State' -eq 'Disabled' } | Set-Style -Style Critical | Out-Null) } }) $MfaStateTableParams = @{ Name = "Per-User MFA State - $TenantId"; List = $false; ColumnWidths = 30, 50, 20 } if ($Report.ShowTableCaptions) { $MfaStateTableParams['Caption'] = "- $($MfaStateTableParams.Name)" } # Materialise filtered result -- never pipe Where-Object directly into Table (empty pipe crashes PScribo) $LegacyMfaRows = @($MfaStateObj | Where-Object { $_.'Per-User MFA State' -ne 'Disabled' }) if ($LegacyMfaRows.Count -gt 0) { $LegacyMfaRows | Table @MfaStateTableParams } else { Paragraph "No users with non-disabled per-user MFA state found." } # Store for Excel export $null = ($script:ExcelSheets['Legacy MFA State'] = $LegacyMfaRows) } } } } catch { Write-AbrSectionError -Section 'Per-User MFA State' -Message "$($_.Exception.Message)" } } #endregion #region ACSC E8 MFA Assessment BlankLine Paragraph "ACSC Essential Eight Maturity Level Assessment -- Multi-Factor Authentication:" BlankLine try { # Gather metrics from data already collected above $TotalMfaUsers = if ($RegDetails) { @($RegDetails).Count } else { 0 } $MfaCapableCount = if ($RegDetails) { @($RegDetails | Where-Object { $_.IsMfaCapable }).Count } else { 0 } $NoMfaCount = if ($RegDetails) { @($RegDetails | Where-Object { -not $_.IsMfaCapable }).Count } else { 0 } $MfaPct = if ($TotalMfaUsers -gt 0) { [math]::Round(($MfaCapableCount / $TotalMfaUsers) * 100, 0) } else { 0 } # Check if phishing-resistant methods are registered by any user $PhishResistantMethods = @('fido2','windowsHelloForBusiness','x509Certificate') $UsersWithPhishResistant = 0 if ($RegDetails) { $UsersWithPhishResistant = @($RegDetails | Where-Object { $methods = $_.AuthMethods $methods -and ($PhishResistantMethods | Where-Object { $methods -contains $_ }).Count -gt 0 }).Count } $PhishPct = if ($TotalMfaUsers -gt 0) { [math]::Round(($UsersWithPhishResistant / $TotalMfaUsers) * 100, 0) } else { 0 } #region MFA Coverage Donut Chart -- generated outside PScribo scope try { if ($TotalMfaUsers -gt 0 -and (Get-Command New-AbrDonutChart -ErrorAction SilentlyContinue)) { $mfaSegs = if ($UsersWithPhishResistant -gt 0) { @( @{ Label = 'Phishing-Resistant'; Value = [int]$UsersWithPhishResistant; Color = '#1a6eb5' } @{ Label = 'MFA (Other)'; Value = [int][math]::Max(0,$MfaCapableCount-$UsersWithPhishResistant); Color = '#2d8f4e' } @{ Label = 'Not MFA Capable'; Value = [int]$NoMfaCount; Color = '#c0392b' } ) } else { @( @{ Label = 'MFA Capable'; Value = [int]$MfaCapableCount; Color = '#2d8f4e' } @{ Label = 'Not MFA Capable'; Value = [int]$NoMfaCount; Color = '#c0392b' } ) } $script:Charts['MFA'] = New-AbrDonutChart -Segments $mfaSegs -CentreText "$MfaPct%" -SubText 'MFA Capable' -Title "MFA Coverage -- $TenantId" } } catch { Write-AbrDebugLog "MFA chart generation failed: $($_.Exception.Message)" 'WARN' 'CHART' } #endregion # chart rendered after summary table above #region ACSC E8 Assessment (definitions from Src/Compliance/ACSC.E8.json) # Legacy per-user MFA count for CIS 5.1.2.1 $LegacyMfaEnabledCount = if ($script:LegacyMfaRows) { @($script:LegacyMfaRows | Where-Object { $_.'Per-User MFA State' -in @('Enabled','Enforced') }).Count } else { 0 } # Store for TenantSettings section $script:LegacyMfaEnabledCount = $LegacyMfaEnabledCount $_ComplianceVars = @{ 'MfaPct' = $MfaPct 'MfaCapableCount' = $MfaCapableCount 'TotalMfaUsers' = $TotalMfaUsers 'NoMfaCount' = $NoMfaCount 'PhishPct' = $PhishPct 'UsersWithPhishResistant'= $UsersWithPhishResistant 'LegacyMfaEnabledCount' = $LegacyMfaEnabledCount } $E8MfaChecks = Build-AbrComplianceChecks ` -Definitions (Get-AbrE8Checks -Section 'MFA') ` -Framework E8 ` -CallerVariables $_ComplianceVars New-AbrE8AssessmentTable -Checks $E8MfaChecks -Name 'MFA Assessment' -TenantId $TenantId # Consolidated into ACSC E8 Assessment sheet if ($E8MfaChecks) { $null = $script:E8AllChecks.AddRange([object[]](@($E8MfaChecks | Select-Object @{N='Section';E={'MFA'}}, ML, Control, Status, Detail ))) } #endregion } catch { Write-AbrSectionError -Section 'E8 MFA Assessment' -Message "$($_.Exception.Message)" } #endregion #region CIS Baseline MFA Assessment if ($script:IncludeCISBaseline) { BlankLine Paragraph "CIS Microsoft 365 Foundations Benchmark Assessment -- Multi-Factor Authentication:" BlankLine try { #region CIS Assessment (definitions from Src/Compliance/CIS.M365.json) $CISMfaChecks = Build-AbrComplianceChecks ` -Definitions (Get-AbrCISChecks -Section 'MFA') ` -Framework CIS ` -CallerVariables $_ComplianceVars New-AbrCISAssessmentTable -Checks $CISMfaChecks -Name 'MFA' -TenantId $TenantId # Consolidated into CIS Assessment sheet if ($CISMfaChecks) { $null = $script:CISAllChecks.AddRange([object[]](@($CISMfaChecks | Select-Object @{N='Section';E={'MFA'}}, CISControl, Level, Status, Detail ))) } #endregion } catch { Write-AbrSectionError -Section 'CIS MFA Assessment' -Message "$($_.Exception.Message)" } } #endregion } # end Section MFA #endregion } end { Show-AbrDebugExecutionTime -End -TitleMessage 'MFA' } } |