Get-MsIdAzureUsers.ps1

<#
.SYNOPSIS
    Returns a list of users that have signed into the Azure portal, Azure CLI, or Azure PowerShell over the last 30 days by querying the sign-in logs.

    If your tenant is a [Microsoft Entra ID Free](https://learn.microsoft.com/entra/identity/monitoring-health/reference-reports-data-retention#activity-reports), the sign-in logs need to be downloaded from

    - Required permission scopes: **Directory.Read.All**, **AuditLog.Read.All**
    - Required Microsoft Entra role: **Global Reader**

.DESCRIPTION
    - Entra ID free tenants have access to sign-in logs for the last 7 days.
    - Entra ID premium tenants have access to sign-in logs for the last 30 days.

.EXAMPLE
    PS > Connect-MgGraph -Scopes Directory.Read.All, AuditLog.Read.All
    PS > Get-MsIdAzureUsers

    Queries all available logs and returns all the users that have signed into Azure.

.EXAMPLE
    PS > Get-MsIdAzureUsers -Days 3

    Queries the logs for the last three days and returns all the users that have signed into Azure during this period.

.EXAMPLE
    PS > Get-MsIdAzureUsers -SignInsJsonPath ./signIns.json

    Uses the sign-ins json file downloaded from the Microsoft Portal and returns all the users that have signed into Azure during this period.

#>


function Get-MsIdAzureUsers {
    [CmdletBinding(HelpUri = 'https://azuread.github.io/MSIdentityTools/commands/Get-MsIdAzureUsers')]
    param (
        # Optional. Path to the sign-ins JSON file. If provided, the report will be generated from this file instead of querying the sign-ins.
        [string]
        $SignInsJsonPath,

        # Number of days to query sign-in logs. Defaults to 30 days for premium tenants and 7 days for free tenants
        [ValidateScript({
                $_ -ge 0 -and $_ -le 30
            },
            ErrorMessage = "Logs are only available for the last 7 days for free tenants and 30 days for premium tenants. Please enter a number between 0 and 30."
        )]
        [int]
        $Days
    )

    $mfaEnforcedApps = @(
        @{
            AppId       = "c44b4083-3bb0-49c1-b47d-974e53cbdf3c"
            DisplayName = "Azure Portal"
        },
        @{
            AppId       = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
            DisplayName = "Microsoft Azure CLI"
        },
        @{
            AppId       = "1950a258-227b-4e31-a9cf-717495945fc2"
            DisplayName = "Microsoft Azure PowerShell"
        },
        @{
            AppId       = "0c1307d4-29d6-4389-a11c-5cbe7f65d7fa"
            DisplayName = "Azure mobile app"
        },
        @{
            AppId       = "618dd325-23f6-4b6f-8380-4df78026e39b"
            DisplayName = "Microsoft 365 Admin portal"
        }
    )

    function Main() {

        if (!(Test-MgModulePrerequisites @('AuditLog.Read.All', 'Directory.Read.All'))) { return }

        if ($SignInsJsonPath) {
            $users = Get-JsonFileContent -SignInsJsonPath $SignInsJsonPath
        }
        else {
            $users = GetAzureUsers $Days
        }

        if ($users) {
            $users.Values
        }
        else {
            return $null
        }
    }

    function GetAzureUsers($pastDays) {

        # Get the date range to query by subtracting the number of days from today set to midnight
        $appFilter = GetAppFilter
        $statusFilter = "and status/errorcode eq 0"
        $dateFilter = GetDateFilter $pastDays

        # Create an array of filter and join with 'and'
        $filter = "$appFilter $statusFilter $dateFilter"
        Write-Verbose "Graph filter: $filter"
        $select = "userId,userPrincipalName,userDisplayName,appId,createdDateTime,authenticationRequirement,status"

        Write-Progress -Activity "Querying sign-in logs..."

        $earliestDate = GetEarliestDate $filter
        if ($null -eq $earliestDate) {
            Write-Host "No Azure sign-ins found." -ForegroundColor Green
            return
        }

        if ($Days) { $dayDiff = $Days }
        else { $dayDiff = (Get-Date).Subtract($earliestDate).Days }
        Write-Host "Getting sign-in logs for the last $dayDiff days (from $earliestDate to now)..." -ForegroundColor Green
        $graphUri = "$graphBaseUri/beta/auditLogs/signIns?`$filter=$filter"

        Write-Verbose "Getting sign-in logs $graphUri"
        $resultsJson = Invoke-GraphRequest -Uri $graphUri -Method GET
        $nextLink = Get-ObjectPropertyValue $resultsJson -Property '@odata.nextLink'

        $latestDate = $resultsJson.value[0].createdDateTime
        # Create a key/value dictionary to store users by userId
        $azureUsers = @{}

        $count = 0
        do {
            foreach ($item in $resultsJson.value) {
                $count++
                # Check if user exists in the dictionary and create a new object if not
                [string]$userId = $item.userId
                $user = $azureUsers[$userId]

                $hasSignedInWithMfa = GetHasSignedInWithMfa $item

                if ($null -eq $user) {
                    $user = [pscustomobject]@{
                        UserId                    = $item.userId
                        UserPrincipalName         = $item.userPrincipalName
                        UserDisplayName           = $item.userDisplayName
                        AzureAppName              = ""
                        AzureAppId                = @($item.appId)
                        AuthenticationRequirement = $item.authenticationRequirement
                        HasSignedInWithMfa        = $hasSignedInWithMfa
                    }
                    $azureUsers[$userId] = $user
                }
                else {
                    # Add the app if it doesn't already exist
                    if ($user.AzureAppId -notcontains $item.appId) {
                        $user.AzureAppId += $item.appId
                    }
                    # Flag as MFA if user signed in at least once
                    if(!$user.HasSignedInWithMfa -and $hasSignedInWithMfa){
                        $user.HasSignedInWithMfa = $hasSignedInWithMfa
                    }

                    # Set user auth requirement to MFA if MFA was enforced at least once
                    if ($user.AuthenticationRequirement -ne "multiFactorAuthentication" `
                            -and $item.authenticationRequirement -eq "multiFactorAuthentication") {
                        $user.AuthenticationRequirement = $item.authenticationRequirement
                    }
                }
            }

            if ($null -ne $nextLink) {
                $latestProcessedDate = $resultsJson.value[$resultsJson.value.Count - 1].createdDateTime
                $percent = GetProgressPercent $earliestDate $latestDate $latestProcessedDate
                Write-Verbose $percent
                $formattedDate = GetDateDisplayFormat $latestProcessedDate
                $status = "Found $($azureUsers.Count) Azure users. Now processing $formattedDate ($([int]$percent)% completed)"
                Write-Progress -Activity "Checking sign-in logs" -Status $status -PercentComplete $percent
                $resultsJson = Invoke-GraphRequest -Uri $nextLink
            }
            $nextLink = Get-ObjectPropertyValue $resultsJson -Property '@odata.nextLink'
        } while ($null -ne $nextLink)

        # Update the Azure App name for each user
        foreach ($user in $azureUsers.Values) {
            $appNames = @()
            foreach ($appId in $user.AzureAppId) {
                $app = $mfaEnforcedApps | Where-Object { $_.AppId -eq $appId }
                if ($app) {
                    $appNames += $app.DisplayName
                }
            }
            $user.AzureAppName = $appNames -join ", "
        }
        return $azureUsers
    }

    function GetProgressPercent($earliestDate, $latestDate, $processedDate) {
        Write-Verbose "Earliest date: $earliestDate"
        Write-Verbose "Processed date: $processedDate"
        $totalSeconds = ($latestDate - $earliestDate).TotalSeconds
        $processedSeconds = ($latestDate - $processedDate).TotalSeconds
        $percent = ($processedSeconds / $totalSeconds) * 100
        return $percent
    }

    function GetHasSignedInWithMfa($signInItem) {
        $hasSignedInWithMfa = $false
        # Check if MFA was enforced for this succesful sign in
        if($signInItem.authenticationRequirement -eq 'multiFactorAuthentication'){
            $hasSignedInWithMfa = $true
        }
        else { # authenticationRequirement was singleFactorAuthentication
            # Could be a federated sign in where MFA claim was sent even though Entra didn't enforce MFA
            $additionalDetails = Get-ObjectPropertyValue $signInItem.status -Property 'additionalDetails'
            if($additionalDetails -eq 'MFA requirement satisfied by claim in the token'){
                $hasSignedInWithMfa = $true
            }
        }
        return $hasSignedInWithMfa
    }

    function GetEarliestDate($filter) {

        $graphUri = "$graphBaseUri/beta/auditLogs/signIns?`$select=createdDateTime&`$filter=$filter&`$top=1&`$orderby=createdDateTime asc"

        Write-Verbose "Getting earliest date in logs $graphUri"
        $resultsJson = Invoke-GraphRequest -Uri $graphUri -Method GET -SkipHttpErrorCheck

        $err = Get-ObjectPropertyValue $resultsJson -Property "error"
        if ($err) {
            if ($err.code -eq "Authentication_RequestFromUnsupportedUserRole") {
                Write-Host "The signed-in user needs to be assigned the Microsoft Entra Global Reader role." -ForegroundColor Green
            }
            elseif ($err.code -eq "Authentication_RequestFromNonPremiumTenantOrB2CTenant") {
                Write-Host "You are using an Entra ID Free tenant which requires additional steps to download the sign-in logs." -ForegroundColor Green
                Write-Host
                Write-Host "Follow these steps to download the sign-in logs." -ForegroundColor Green
                Write-Host "- Sign-in to https://entra.microsoft.com" -ForegroundColor Green
                Write-Host "- From the left navigation select: Identity → Monitoring & health → Sign-in logs." -ForegroundColor Green
                Write-Host "- Select the 'Date' filter and set to 'Last 7 days'" -ForegroundColor Green
                Write-Host "- Select 'Add filters' → 'Application' and click 'Apply'" -ForegroundColor Green
                Write-Host "- Type in 'Azure' and click 'Apply'" -ForegroundColor Green
                Write-Host "- Select 'Download' → 'Download JSON'" -ForegroundColor Green
                Write-Host "- Set the 'File Name' of the first textbox to 'signins' and click 'Download'." -ForegroundColor Green
                Write-Host "- Once the file is downloaded, copy it to the folder where the export command will be run." -ForegroundColor Green
                Write-Host
                Write-Host "Re-run this command with the -SignInsJsonPath parameter." -ForegroundColor Green
                Write-Host "E.g.> Export-MsIdAzureMfaReport ./report.xlsx -SignInsJsonPath ./signins.json" -ForegroundColor Yellow
            }
            Write-Error $err.message -ErrorAction Stop
        }

        $minDate = $null
        if ($resultsJson.value.Count -ne 0) {
            $minDate = $resultsJson.value[0].createdDateTime
        }

        return $minDate
    }

    function GetDateFilter($pastDays) {
        # Get the date range to query by subtracting the number of days from today set to midnight
        $dateFilter = $null
        if ($pastDays -and $pastDays -gt 0) {
            $dateStart = (Get-Date -Hour 0 -Minute 0 -Second 0).AddDays(-$pastDays)

            # convert the date to the correct format
            $tmzFormat = "yyyy-MM-ddTHH:mm:ssZ"
            $dateStartString = $dateStart.ToString($tmzFormat)

            $dateFilter = "and createdDateTime ge $dateStartString"
        }
        return $dateFilter
    }

    function GetAppFilter() {
        $allAppFilter = $mfaEnforcedApps.AppId -join "' or appid eq '"
        $allAppFilter = "(appid eq '$allAppFilter')"
        return $allAppFilter
    }

    function Get-JsonFileContent ($signInsJsonPath) {
        Write-Verbose "Reading sign-ins from $signInsJsonPath"
        $signIns = Get-Content $signInsJsonPath -Raw | ConvertFrom-Json

        $azureUsers = @{}
        $count = 0

        foreach ($item in $signIns) {
            $count++
            # Check if user exists in the dictionary and create a new object if not
            [string]$userId = $item.userId
            $user = $azureUsers[$userId]
            if ($null -eq $user) {
                $user = [pscustomobject]@{
                    UserId                    = $item.userId
                    UserPrincipalName         = $item.userPrincipalName
                    UserDisplayName           = $item.userDisplayName
                    AzureAppName              = ""
                    AzureAppId                = @($item.appId)
                    AuthenticationRequirement = $item.authenticationRequirement
                }
                $azureUsers[$userId] = $user
            }
            else {
                # Add the app if it doesn't already exist
                if ($user.AzureAppId -notcontains $item.appId) {
                    $user.AzureAppId += $item.appId
                }
                # Flag as MFA if user signed in at least once
                if ($user.AuthenticationRequirement -ne "multiFactorAuthentication" `
                        -and $item.authenticationRequirement -eq "multiFactorAuthentication") {
                    $user.AuthenticationRequirement = $item.authenticationRequirement
                }
            }
        }

        # Update the Azure App name for each user
        foreach ($user in $azureUsers.Values) {
            $appNames = @()
            foreach ($appId in $user.AzureAppId) {
                $app = $mfaEnforcedApps | Where-Object { $_.AppId -eq $appId }
                if ($app) {
                    $appNames += $app.DisplayName
                }
            }
            $user.AzureAppName = $appNames -join ", "
        }
        return $azureUsers
    }

    function WriteExportProgress(
        # The current step of the overal generation
        [ValidateSet("Logs")]
        $MainStep,
        $Status = "Processing...",
        # The percentage of completion within the child step
        $ChildPercent,
        [switch]$ForceRefresh) {
        $percent = 0
        switch ($MainStep) {
            "Logs" {
                $percent = GetNextPercent $ChildPercent 0 100
                $activity = "Checking sign-in logs"
            }
        }

        if ($ForceRefresh.IsPresent) {
            Start-Sleep -Milliseconds 250
        }
        Write-Progress -Id 0 -Activity $activity -PercentComplete $percent -Status $Status
    }
    function GetNextPercent($childPercent, $parentPercent, $nextPercent) {
        if ($childPercent -eq 0) { return $parentPercent }

        $gap = $nextPercent - $parentPercent
        return (($childPercent / 100) * $gap) + $parentPercent
    }

    function GetDateDisplayFormat($date) {
        return $date.ToString("dd MMM yyyy h:00 tt")
    }

    $graphBaseUri = Get-GraphBaseUri
    # Call main function
    Main
}