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.21
        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'
    }
}