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 } } } } |