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. in PowerShell scripts. .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 Generates a comprehensive user security report. .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( # Tenant ID of the Microsoft 365 tenant. [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')] [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [string]$TenantId, # Application (client) ID of the registered app. [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, # Client secret credential when using secret-based authentication. [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')] [Alias('ApplicationSecret')] [ValidateNotNullOrEmpty()] [SecureString]$ClientSecret, # Certificate thumbprint for certificate-based authentication. [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')] [ValidateNotNullOrEmpty()] [string]$CertificateThumbprint, # Use interactive authentication (no app registration required). [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')] [switch]$Interactive, # Number of days of sign-in history to analyze. [Parameter()] [ValidateRange(1, 365)] [int]$SignInLookbackDays = 90, # Switch to exclude disabled accounts. [Parameter()] [switch]$ExcludeDisabledUsers, # Switch to exclude guest accounts. [Parameter()] [switch]$ExcludeGuestUsers, # Maximum number of users to process (testing limit). [Parameter()] [ValidateRange(1, 100000)] [int]$MaxUsers ) begin { # Load .CSV with SKU Translation table for retrieving friendly license names $SkuHashTable = @{} Get-SkuTranslationTable | Group-Object GUID | ForEach-Object { $SkuHashTable[$_.Name] = ($_.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 'Starting user security report generation...' -InformationAction Continue } process { try { # Establish connection $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-Object { $_.AccountEnabled -eq $true } } if ($ExcludeGuestUsers) { $FilteredUsers = $FilteredUsers | Where-Object { $_.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() foreach ($User in $FilteredUsers) { try { # Get corresponding MFA data $MfaData = $MfaLookup[$User.UserPrincipalName] # Translate assigned licenses to friendly name try { $UserLicenses = $User.AssignedLicenses | ForEach-Object { Resolve-SkuName -SkuId $_.SkuId -SkuHashTable $SkuHashTable } | Where-Object { $_ } | 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-Object { $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((New-TimeSpan -Start $LastSignInDate -End (Get-Date)).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((New-TimeSpan -Start $LastSuccessfulSignInDate -End (Get-Date)).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((New-TimeSpan -Start $LastPasswordChangeDate -End (Get-Date)).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 } # Create comprehensive user security entry $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 } } # Generate comprehensive summary statistics using single-pass accumulation $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 } } # Build comprehensive report Write-Information "User security report completed - $($UserSecurityReport.Count) users processed" -InformationAction Continue [PSCustomObject]@{ Summary = $Summary UserDetails = $UserSecurityReport | Sort-Object DisplayName MfaMethodAnalysis = $UserSecurityReport | Where-Object { $_.MfaMethodsRegistered } | Group-Object { $_.MfaMethodsRegistered } | Select-Object Name, Count | Sort-Object Count -Descending LicenseAnalysis = $UserSecurityReport | Where-Object { $_.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 { # Only disconnect if we established the connection Disconnect-TntGraphSession -ConnectionState $ConnectionInfo } } } |