Public/Get-TntM365UserReport.ps1

function Get-TntM365UserReport {
    <#
    .SYNOPSIS
        Generates a security report of all Microsoft 365 users including licenses, sign-in activity, and MFA status.
 
    .DESCRIPTION
        This function connects to Microsoft Graph using an app registration and retrieves detailed security
        information for all users in the tenant. It provides insights into user licensing, authentication
        methods, last sign-in activity, password changes, and MFA device registrations.
 
    .PARAMETER TenantId
        The Azure AD Tenant ID (GUID) to connect to.
 
    .PARAMETER ClientId
        The Application (Client) ID of the app registration created for security reporting.
 
    .PARAMETER ClientSecret
        The client secret for the app registration. Accepts SecureString or plain String.
 
    .PARAMETER CertificateThumbprint
        The thumbprint of the certificate to use for authentication instead of client secret.
 
    .PARAMETER SignInLookbackDays
        Number of days to look back for sign-in activity. Defaults to 90 days.
 
    .PARAMETER ExcludeDisabledUsers
        Switch to exclude disabled user accounts from the report. By default, disabled users are included.
 
    .PARAMETER ExcludeGuestUsers
        Switch to exclude guest user accounts from the report. By default, guest users are included.
 
    .PARAMETER MaxUsers
        Maximum number of users to process. Useful for testing or large tenant limits.
 
    .EXAMPLE
        Get-TntM365UserReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret
 
    .EXAMPLE
        Get-TntM365UserReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret |
            ConvertTo-Json -Depth 10 | Out-File -Path 'UserReport.json'
 
        Exports the report to JSON format.
 
    .EXAMPLE
        $Report = Get-TntM365UserReport @params -ExcludeDisabledUsers -ExcludeGuestUsers
        $Report.UserDetails | Where-Object { $_.IsMfaRegistered -eq $false } | Format-Table
 
        Retrieves only enabled member users and displays those without MFA.
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject
        Returns a structured report object containing:
        - Summary: User counts, MFA adoption rates, license statistics
        - UserDetails: Detailed information for each user
        - MfaMethodAnalysis: MFA method usage breakdown
        - LicenseAnalysis: License distribution analysis
 
    .NOTES
        Author: Tom de Leeuw
        Website: https://systom.dev
        Module: TenantReports
 
        Required Azure AD Application Permissions:
        - User.Read.All (Application)
        - UserAuthenticationMethod.Read.All (Application)
        - AuditLog.Read.All (Application)
        - Reports.Read.All (Application)
        - Directory.Read.All (Application)
        - Organization.Read.All (Application)
 
    .LINK
        https://systom.dev
    #>


    [CmdletBinding(DefaultParameterSetName = 'ClientSecret')]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
        [Alias('ApplicationId')]
        [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')]
        [string]$ClientId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Alias('ApplicationSecret')]
        [ValidateNotNullOrEmpty()]
        [SecureString]$ClientSecret,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [ValidateNotNullOrEmpty()]
        [string]$CertificateThumbprint,

        [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')]
        [switch]$Interactive,

        [Parameter()]
        [ValidateRange(1, 365)]
        [int]$SignInLookbackDays = 90,

        [Parameter()]
        [switch]$ExcludeDisabledUsers,

        [Parameter()]
        [switch]$ExcludeGuestUsers,

        [Parameter()]
        [ValidateRange(1, 100000)]
        [int]$MaxUsers
    )

    begin {
        # Load .CSV with SKU Translation table for retrieving friendly license names
        $SkuHashTable = @{}
        foreach ($SkuGroup in (Get-SkuTranslationTable | Group-Object GUID)) {
            $SkuHashTable[$SkuGroup.Name] = ($SkuGroup.Group | Select-Object -First 1).Product_Display_Name
        }

        # MFA Method types array for individual property mapping
        $AllMethods = @(
            [pscustomobject]@{type = 'microsoftAuthenticatorPasswordless'; Name = 'Microsoft Authenticator Passwordless'; Strength = 'Strong' }
            [pscustomobject]@{type = 'fido2SecurityKey'; AltName = 'Fido2'; Name = 'Fido2 Security Key'; Strength = 'Strong' }
            [pscustomobject]@{type = 'passKeyDeviceBound'; AltName = 'Fido2'; Name = 'Device Bound Passkey'; Strength = 'Strong' }
            [pscustomobject]@{type = 'passKeyDeviceBoundAuthenticator'; AltName = 'Fido2'; Name = 'Microsoft Authenticator Passkey'; Strength = 'Strong' }
            [pscustomobject]@{type = 'passKeyDeviceBoundWindowsHello'; AltName = 'Fido2'; Name = 'Windows Hello Passkey'; Strength = 'Strong' }
            [pscustomobject]@{type = 'microsoftAuthenticatorPush'; AltName = 'MicrosoftAuthenticator'; Name = 'Microsoft Authenticator App'; Strength = 'Strong' }
            [pscustomobject]@{type = 'softwareOneTimePasscode'; AltName = 'SoftwareOath'; Name = 'Software OTP'; Strength = 'Strong' }
            [pscustomobject]@{type = 'hardwareOneTimePasscode'; AltName = 'HardwareOath'; Name = 'Hardware OTP'; Strength = 'Strong' }
            [pscustomobject]@{type = 'windowsHelloForBusiness'; AltName = 'windowsHelloForBusiness'; Name = 'Windows Hello for Business'; Strength = 'Strong' }
            [pscustomobject]@{type = 'temporaryAccessPass'; AltName = 'TemporaryAccessPass'; Name = 'Temporary Access Pass'; Strength = 'Strong' }
            [pscustomobject]@{type = 'macOsSecureEnclaveKey'; Name = 'MacOS Secure Enclave Key'; Strength = 'Strong' }
            [pscustomobject]@{type = 'SMS'; AltName = 'SMS'; Name = 'SMS'; Strength = 'Weak' }
            [pscustomobject]@{type = 'Voice Call'; AltName = 'voice'; Name = 'Voice Call'; Strength = 'Weak' }
            [pscustomobject]@{type = 'email'; AltName = 'Email'; Name = 'Email'; Strength = 'Weak' }
            [pscustomobject]@{type = 'alternateMobilePhone'; AltName = 'Voice'; Name = 'Alternative Mobile Phone'; Strength = 'Weak' }
            [pscustomobject]@{type = 'securityQuestion'; AltName = 'Security Questions'; Name = 'Security Questions'; Strength = 'Weak' }
        )

        Write-Information 'STARTED : User report generation...' -InformationAction Continue
    }

    process {
        try {
            $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters
            $ConnectionInfo = Connect-TntGraphSession @ConnectionParams

            # Retrieve users with required properties - Using Get-MgBetaUser to access lastSuccessfulSignInDateTime
            Write-Verbose 'Retrieving user accounts...'
            $UserProperties = @(
                'Id', 'DisplayName', 'UserPrincipalName', 'AccountEnabled', 'Mail',
                'UserType', 'CreatedDateTime', 'LastPasswordChangeDateTime',
                'SignInActivity', 'AssignedLicenses', 'UsageLocation'
            )
            $AllUsers = Get-MgBetaUser -All -Property $UserProperties -ErrorAction Stop

            # Apply initial filters
            $FilteredUsers = $AllUsers
            if ($ExcludeDisabledUsers) {
                $FilteredUsers = $FilteredUsers.Where({ $_.AccountEnabled -eq $true })
            }
            if ($ExcludeGuestUsers) {
                $FilteredUsers = $FilteredUsers.Where({ $_.UserType -ne 'Guest' })
            }
            if ($MaxUsers) {
                $FilteredUsers = $FilteredUsers | Select-Object -First $MaxUsers
            }

            Write-Verbose "Found $($FilteredUsers.Count) users to process after filtering"

            # Get subscription information for license translation
            Write-Verbose 'Retrieving subscription information for license mapping fallback...'
            $SubscribedSkus = @{}
            try {
                $Skus = Get-MgSubscribedSku -All -ErrorAction SilentlyContinue
                foreach ($Sku in $Skus) {
                    $SubscribedSkus[$Sku.SkuId] = $Sku.SkuPartNumber
                }
            } catch {
                Write-Warning "Unable to retrieve subscription information for license mapping: $($_.Exception.Message)"
            }

            # Retrieve MFA registration data for all users
            try {
                Write-Verbose 'Retrieving MFA registration information...'
                $MfaRegistrationData = Get-MgReportAuthenticationMethodUserRegistrationDetail -All -ErrorAction Stop
            } catch {
                Write-Error "Error retrieving MFA registration information: $($_.Exception.Message)"
            }

            # Create lookup table for MFA data by UserPrincipalName
            $MfaLookup = @{}
            foreach ($MfaUser in $MfaRegistrationData) {
                if ($MfaUser.UserPrincipalName) {
                    $MfaLookup[$MfaUser.UserPrincipalName] = $MfaUser
                }
            }

            Write-Verbose "Processing $($FilteredUsers.Count) users..."

            # Process each user and combine data
            $UserSecurityReport = [System.Collections.Generic.List[PSObject]]::new()

            # Cache current time outside the loop to avoid repeated Get-Date calls
            $Now = [DateTime]::Now

            foreach ($User in $FilteredUsers) {
                try {
                    # Get corresponding MFA data
                    $MfaData = $MfaLookup[$User.UserPrincipalName]

                    # Translate assigned licenses to friendly name
                    try {
                        $UserLicenses = $User.AssignedLicenses.ForEach({
                            Resolve-SkuName -SkuId $_.SkuId -SkuHashTable $SkuHashTable
                        }).Where({ $_ }) | Sort-Object -Unique -ErrorAction Stop
                    } catch {
                        Write-Error 'Could not translate license with SKU Translation table. Falling back to native Graph method.'
                        $UserLicenses = $User.AssignedLicenses.ForEach({
                            $SkuId = $_.SkuId
                            if ($SubscribedSkus.ContainsKey($SkuId)) {
                                $SubscribedSkus[$SkuId]
                            } else {
                                "Unknown License ($SkuId)"
                            }
                        })
                    }

                    # Parse sign-in dates safely
                    # LastSignInDate includes both successful and failed sign-ins
                    $LastSignInDate = $null
                    $DaysSinceLastSignIn = $null

                    if ($User.SignInActivity.LastSignInDateTime) {
                        try {
                            $LastSignInDate = [DateTime]$User.SignInActivity.LastSignInDateTime
                            $DaysSinceLastSignIn = [Math]::Abs(($Now - $LastSignInDate).Days)
                        } catch {
                            $LastSignInDate = 'Invalid Date'
                            $DaysSinceLastSignIn = 'N/A'
                        }
                    } else {
                        $LastSignInDate = 'Never'
                        $DaysSinceLastSignIn = 'N/A'
                    }

                    # Parse successful sign-in date (Beta API only - excludes failed sign-ins)
                    $LastSuccessfulSignInDate = $null
                    $DaysSinceLastSuccessfulSignIn = $null

                    if ($User.SignInActivity.lastSuccessfulSignInDateTime) {
                        try {
                            $LastSuccessfulSignInDate = [DateTime]$User.SignInActivity.lastSuccessfulSignInDateTime
                            $DaysSinceLastSuccessfulSignIn = [Math]::Abs(($Now - $LastSuccessfulSignInDate).Days)
                        } catch {
                            $LastSuccessfulSignInDate = 'Invalid Date'
                            $DaysSinceLastSuccessfulSignIn = 'N/A'
                        }
                    } else {
                        $LastSuccessfulSignInDate = 'N/A'
                        $DaysSinceLastSuccessfulSignIn = 'N/A'
                    }

                    # Parse password change date
                    $LastPasswordChangeDate = $null
                    $DaysSincePasswordChange = $null

                    if ($User.LastPasswordChangeDateTime) {
                        try {
                            $LastPasswordChangeDate = [DateTime]$User.LastPasswordChangeDateTime
                            $DaysSincePasswordChange = [Math]::Abs(($Now - $LastPasswordChangeDate).Days)
                        } catch {
                            $LastPasswordChangeDate = 'Invalid Date'
                            $DaysSincePasswordChange = 'N/A'
                        }
                    } else {
                        $LastPasswordChangeDate = 'N/A'
                        $DaysSincePasswordChange = 'N/A'
                    }

                    # Determine MFA status and methods
                    $MfaStatus = 'Unknown'
                    $MfaMethods = @()
                    $IsPasswordlessCapable = $false
                    $IsSsprRegistered = $false
                    $DefaultMfaMethod = 'None'

                    if ($MfaData) {
                        $MfaStatus = if ($MfaData.IsMfaRegistered -eq $true) { 'Registered' } else { 'Not Registered' }
                        $MfaMethods = if ($MfaData.MethodsRegistered) { $MfaData.MethodsRegistered } else { @() }
                        $IsPasswordlessCapable = $MfaData.IsPasswordlessCapable -eq $true
                        $IsSsprRegistered = $MfaData.IsSsprRegistered -eq $true
                        $DefaultMfaMethod = if ($MfaData.UserPreferredMethodForSecondaryAuthentication) { $MfaData.UserPreferredMethodForSecondaryAuthentication } else { 'None' }
                    }

                    # Create individual boolean properties for each MFA method
                    $MfaMethodProperties = @{}
                    foreach ($Method in $AllMethods) {
                        $PropertyName = "Has$($Method.Name.Replace(' ', '').Replace('-', ''))"
                        $MfaMethodProperties[$PropertyName] = $MfaMethods -contains $Method.type
                    }

                    $UserEntry = [PSCustomObject]@{
                        #UserId = $User.Id
                        DisplayName                        = $User.DisplayName
                        UserPrincipalName                  = $User.UserPrincipalName
                        EmailAddress                       = $User.Mail ?? 'N/A'
                        AccountEnabled                     = $User.AccountEnabled
                        UserType                           = $User.UserType
                        CreatedDateTime                    = $User.CreatedDateTime
                        UsageLocation                      = $User.UsageLocation

                        # License Information
                        AssignedLicenses                   = ($UserLicenses -join ', ')
                        LicenseCount                       = $UserLicenses.Count

                        # Sign-in Activity
                        LastSignInDate                     = $LastSignInDate
                        DaysSinceLastSignIn                = $DaysSinceLastSignIn
                        LastSuccessfulSignInDate           = $LastSuccessfulSignInDate
                        DaysSinceLastSuccessfulSignIn      = $DaysSinceLastSuccessfulSignIn
                        IsInactive                         = if ($null -ne $DaysSinceLastSuccessfulSignIn -and $DaysSinceLastSuccessfulSignIn -is [int]) {
                            if ($DaysSinceLastSuccessfulSignIn -gt $SignInLookbackDays) { $true } else { $false }
                        } else {
                            if ($DaysSinceLastSignIn -gt $SignInLookbackDays) { $true } else { $false }
                        }

                        # Password Information
                        LastPasswordChangeDate             = $LastPasswordChangeDate
                        DaysSincePasswordChange            = $DaysSincePasswordChange

                        # MFA Information
                        IsAdmin                            = if ($MfaData) { $MfaData.IsAdmin } else { $false }
                        MfaStatus                          = $MfaStatus
                        IsMfaCapable                       = if ($MfaData) { $MfaData.IsMfaCapable } else { $false }
                        IsMfaRegistered                    = if ($MfaData) { $MfaData.IsMfaRegistered } else { $false }
                        DefaultMfaMethod                   = $DefaultMfaMethod
                        SystemPreferredMfaEnabled          = if ($MfaData) { $MfaData.IsSystemPreferredAuthenticationMethodEnabled } else { $false }

                        MfaMethodsRegistered               = ($MfaMethods -join ', ')
                        MfaMethodCount                     = $MfaMethods.Count
                        IsPasswordlessCapable              = $IsPasswordlessCapable

                        # Individual MFA method properties
                        MicrosoftAuthenticatorPasswordless = $MfaMethodProperties['HasMicrosoftAuthenticatorPasswordless']
                        Fido2SecurityKey                   = $MfaMethodProperties['HasFido2SecurityKey']
                        DeviceBoundPasskey                 = $MfaMethodProperties['HasDeviceBoundPasskey']
                        MicrosoftAuthenticatorPasskey      = $MfaMethodProperties['HasMicrosoftAuthenticatorPasskey']
                        WindowsHelloPasskey                = $MfaMethodProperties['HasWindowsHelloPasskey']
                        MicrosoftAuthenticatorApp          = $MfaMethodProperties['HasMicrosoftAuthenticatorApp']
                        SoftwareOTP                        = $MfaMethodProperties['HasSoftwareOTP']
                        HardwareOTP                        = $MfaMethodProperties['HasHardwareOTP']
                        WindowsHelloforBusiness            = $MfaMethodProperties['HasWindowsHelloforBusiness']
                        TemporaryAccessPass                = $MfaMethodProperties['HasTemporaryAccessPass']
                        MacOSSecureEnclaveKey              = $MfaMethodProperties['HasMacOSSecureEnclaveKey']
                        SMS                                = $MfaMethodProperties['HasSMS']
                        VoiceCall                          = $MfaMethodProperties['HasVoiceCall']
                        Email                              = $MfaMethodProperties['HasEmail']
                        AlternativeMobilePhone             = $MfaMethodProperties['HasAlternativeMobilePhone']
                        SecurityQuestions                  = $MfaMethodProperties['HasSecurityQuestions']

                        # SSPR Information
                        IsSsprRegistered                   = $IsSsprRegistered
                        IsSsprCapable                      = if ($MfaData) { $MfaData.IsSsprCapable } else { $false }
                    }

                    $UserSecurityReport.Add($UserEntry)
                } catch {
                    Write-Warning "Error processing user $($User.UserPrincipalName): $($_.Exception.Message)"
                    continue
                }
            }

            $Stats = @{
                EnabledUsers             = 0
                DisabledUsers            = 0
                GuestUsers               = 0
                AdminUsers               = 0
                LicensedUsers            = 0
                UnlicensedUsers          = 0
                MfaRegisteredUsers       = 0
                MfaNotRegisteredUsers    = 0
                MfaCapableUsers          = 0
                PasswordlessCapableUsers = 0
                SsprRegisteredUsers      = 0
                SsprCapableUsers         = 0
                InactiveUsers            = 0
                NeverSignedInUsers       = 0
            }

            foreach ($User in $UserSecurityReport) {
                if ($User.AccountEnabled) { $Stats.EnabledUsers++ } else { $Stats.DisabledUsers++ }
                if ($User.UserType -eq 'Guest') { $Stats.GuestUsers++ }
                if ($User.IsAdmin) { $Stats.AdminUsers++ }
                if ($User.LicenseCount -gt 0) { $Stats.LicensedUsers++ } else { $Stats.UnlicensedUsers++ }
                if ($User.IsMfaRegistered) { $Stats.MfaRegisteredUsers++ } else { $Stats.MfaNotRegisteredUsers++ }
                if ($User.IsMfaCapable) { $Stats.MfaCapableUsers++ }
                if ($User.IsPasswordlessCapable) { $Stats.PasswordlessCapableUsers++ }
                if ($User.IsSsprRegistered) { $Stats.SsprRegisteredUsers++ }
                if ($User.IsSsprCapable) { $Stats.SsprCapableUsers++ }
                if ($User.IsInactive) { $Stats.InactiveUsers++ }
                if ($User.LastSignInDate -eq 'Never') { $Stats.NeverSignedInUsers++ }
            }

            $TotalUsers = $UserSecurityReport.Count
            $Summary = [PSCustomObject]@{
                ReportGeneratedDate      = Get-Date
                TenantId                 = $TenantId

                # User counts
                TotalUsers               = $TotalUsers
                EnabledUsers             = $Stats.EnabledUsers
                DisabledUsers            = $Stats.DisabledUsers
                GuestUsers               = $Stats.GuestUsers
                AdminUsers               = $Stats.AdminUsers

                # License statistics
                LicensedUsers            = $Stats.LicensedUsers
                UnlicensedUsers          = $Stats.UnlicensedUsers

                # MFA statistics
                MfaRegisteredUsers       = $Stats.MfaRegisteredUsers
                MfaNotRegisteredUsers    = $Stats.MfaNotRegisteredUsers
                MfaCapableUsers          = $Stats.MfaCapableUsers
                PasswordlessCapableUsers = $Stats.PasswordlessCapableUsers

                # SSPR statistics
                SsprRegisteredUsers      = $Stats.SsprRegisteredUsers
                SsprCapableUsers         = $Stats.SsprCapableUsers

                # Activity statistics
                InactiveUsers            = $Stats.InactiveUsers
                NeverSignedInUsers       = $Stats.NeverSignedInUsers

                # Security posture percentages
                MfaAdoptionRate          = if ($TotalUsers -gt 0) {
                    [Math]::Round(($Stats.MfaRegisteredUsers / $TotalUsers) * 100, 2)
                } else { 0 }

                SsprAdoptionRate         = if ($TotalUsers -gt 0) {
                    [Math]::Round(($Stats.SsprRegisteredUsers / $TotalUsers) * 100, 2)
                } else { 0 }
            }

            Write-Information "FINISHED : User report - $($UserSecurityReport.Count) users processed" -InformationAction Continue

            [PSCustomObject]@{
                Summary           = $Summary
                UserDetails       = $UserSecurityReport | Sort-Object DisplayName
                MfaMethodAnalysis = $UserSecurityReport.Where({ $_.MfaMethodsRegistered }) |
                    Group-Object { $_.MfaMethodsRegistered } |
                    Select-Object Name, Count |
                    Sort-Object Count -Descending
                LicenseAnalysis   = $UserSecurityReport.Where({ $_.AssignedLicenses }) |
                    Group-Object { $_.AssignedLicenses } |
                    Select-Object Name, Count |
                    Sort-Object Count -Descending
            }
        } catch {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.Exception]::new("Get-TntM365UserReport failed: $($_.Exception.Message)", $_.Exception),
                'GetTntM365UserReportError',
                [System.Management.Automation.ErrorCategory]::OperationStopped,
                $TenantId
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        } finally {
            if ($ConnectionInfo.ShouldDisconnect) {
                Disconnect-TntGraphSession -ConnectionState $ConnectionInfo
            }
        }
    }
}