get-O365InactiveUsers.ps1

<#PSScriptInfo
.VERSION 3.4
.GUID ed277db7-b089-48db-b1ee-6297160d647d
.AUTHOR
 Maarten Peeters - Cloud Securitea - https://www.cloudsecuritea.com
.COMPANYNAME
 Cloud Securitea
.COPYRIGHT
.TAGS
 office365, inactive users, audit,AAD,Azure Active Directory
.LICENSEURI
.PROJECTURI
.ICONURI
.EXTERNALMODULEDEPENDENCIES
 MSOnline
.RELEASENOTES
 Version 1.0: Original published version.
 Version 2.0: Updated a few comment sections.
 Version 3.0: Changed the example.
 Version 3.1: Added AdminUPN parameter as Exchange Online otherwise closes the connection after 1 hour.
 Version 3.2: Changed parameter name
 Version 3.3: Updated a few comment sections.
 Version 3.4: Added script from https://techcommunity.microsoft.com/t5/Exchange/60-minutes-timeout-on-MFA-Session/m-p/559224 to counter the timeout
#>
 

<#
.SYNOPSIS
 Quickly generate an overview of inactive users based on Office 365 audit logs
 
.DESCRIPTION
 This script will generate a HTML file to list all inactive users and the last logon of users based on the audit log.
 Inactive users will normally be collected using the users mailbox but external and users without a license don't have a mailbox.
 We will be using the audit log to find successful and failed logons for internal users and page viewes for external users.
 It will take around 6-8 seconds per user as we are querying the log for each user.
 
.PARAMETER LogPath
 Enter the full path to store the HTML report of the Office 365 inactive users
 For example: C:\Install
 
.PARAMETER days
Enter the amount of days ago you want to capture data from the audit log
For example: 90
 
.PARAMETER adminUPN
Enter the full user principal name of your admin account to be used for the Exchange Online session.
It will otherwise close after an hour.
For example: admin@<tenant>.onmicrosoft.com
 
.EXAMPLE
 Get-O365InactiveUsers.ps1 -LogPath "C:\Install" -days 90 -AdminUPN "admin@<tenant>.onmicrosoft.com"
 
 .NOTES
 Version: 3.4
 Author: Maarten Peeters
 Creation Date: 18-09-2019
 Purpose/Change: Quickly generate an overview of inactive users based on the audit log
#>


param(
    [Parameter(mandatory=$true)]
    [string] $LogPath,
    [Parameter(mandatory=$true)]
    [int] $days,
    [Parameter(mandatory=$true)]
    [string] $adminUPN
)

try{
    #######################################################################################################################
    # This part is used from https://techcommunity.microsoft.com/t5/Exchange/60-minutes-timeout-on-MFA-Session/m-p/559224 #
    #######################################################################################################################
    function Invoke-ExoOnlineConnection{
        [cmdletbinding()]
        Param
        (
        [Parameter(Mandatory=$false)]
            [switch]$Checktimer,
        [Parameter(mandatory=$false, valuefrompipeline=$false)]
            [switch]$RepairPSSession
        )
        
        begin{}
        
        process{
            #determine if PsSession is loaded in memory
            $ExosessionInfo = Get-PsSession

            #calculate session time
            if ($global:ExosessionStartTime){
                 $global:ExosessionTotalTime = ((Get-Date) - $global:ExosessionStartTime)
            }
            
            #need to loop through each session a user might have opened previously
            foreach ($ExosessionItem in $ExosessionInfo){

                #check session timer to know if we need to break the connection in advance of a timeout. Break and make new after 40 minutes.
                if ($ExosessionItem.ComputerName.Contains("outlook.office365.com") -and $ExosessionItem.State -eq "Opened" -and $global:ExosessionTotalTime.TotalSeconds -ge "1000"){
                    Write-Verbose -Message "The PowerShell session has been running for $($global:ExosessionTotalTime.TotalMinutes) minutes. We need to shut it down and create a new session due to the access token expiration at 60 minutes."
                    $ExosessionItem | Remove-PSSession
                    Start-Sleep -Seconds 3
                    $strSessionFound = $false
                    $global:ExosessionTotalTime = $null #reset the timer
                } else { Write-Verbose -Message "The PowerShell session has been running for $($global:ExosessionTotalTime.TotalMinutes) minutes.)"}
                
                #Force repair PSSession
                if ($ExosessionItem.ComputerName.Contains("outlook.office365.com") -and $RepairPSSession){
                    Write-Verbose -Message "Attempting to repair broken PowerShell session to Exchange Online using cached credential."
                    $ExosessionItem | Remove-PSSession
                    Start-Sleep -Seconds 3
                    $strSessionFound = $false
                    $global:ExosessionTotalTime = $null
                }elseif ($ExosessionItem.ComputerName.Contains("outlook.office365.com") -and $ExosessionItem.State -eq "Opened"){
                    $strSessionFound = $true
                }
            }
            if (!$strSessionFound){
                Write-Verbose -Message "Creating new Exchange Online PowerShell session..."
                Write-Host "Creating new Exchange Online PowerShell session..." -foregroundcolor cyan
                try{
                        $ExoSession  = New-ExoPSSession -UserPrincipalName $adminUPN -ConnectionUri "https://outlook.office365.com/powershell-liveid/" -ErrorAction SilentlyContinue -ErrorVariable $newOnlineSessionError
                }
                catch{

                Write-Verbose -Message "Throw error..."
                   throw;
                } finally {
                    if ($newOnlineSessionError) {
                        Write-Verbose -Message "Final error..."
                        throw $newOnlineSessionError
                    }
                }
                Write-Verbose -Message "Importing remote PowerShell session..."
                $global:ExosessionStartTime = (Get-Date)
                Import-PSSession $ExoSession -AllowClobber | Out-Null
            }
        }
        end{}
    }
    ############
    # End part #
    ############
    
    #Verify if MSOnline Module is available
    if (Get-Module -ListAvailable -Name MSonline) {
        #Import MSOnline Module
        import-module MSOnline -ErrorAction SilentlyContinue

        #Verify if the New Exchange Online Module is installed
        try{
            $file = Get-ChildItem -Path $("$($env:LOCALAPPDATA)\Apps\2.0\") -Filter Microsoft.Exchange.Management.ExoPowershellModule.dll -Recurse

            #Test if logpath exists
            If(Test-Path $LogPath) { 
                #Start script
                Try{
                    #Object collections
                    $userCollection = @()

                    #Connect to the correct O365 Tenant
                    Connect-MsolService

                    #Connect to the Exchange Online
                    Import-Module $((Get-ChildItem -Path $("$($env:LOCALAPPDATA)\Apps\2.0\") -Filter Microsoft.Exchange.Management.ExoPowershellModule.dll -Recurse ).FullName|?{$_ -notmatch "_none_"}|select -First 1)
                    $EXOSession = New-ExoPSSession -UserPrincipalName $adminUPN
                    Import-PSSession $EXOSession -allowclobber | out-null
                    
                    #Retrieve all users and order them by UPN
                    $users = Get-MsolUser -All | Select-Object UserPrincipalName, isLicensed, DisplayName, WhenCreated | Sort-Object UserPrincipalName
                    write-host "There are currently $($users.count) users in the AAD" -foregroundcolor cyan

                    #prepare the dates
                    $enddate = get-date
                    $startdate = $enddate.AddDays( 0 - $days )
                    
                    #loop through all users to get the latest logondate
                    $i = 1
                    foreach ($user in $users){
                        write-host "Processing user $($i) - $($user.UserPrincipalName)" -foregroundcolor white

                        #clear variables
                        $convertedoutput = $null

                        #Create unique session name for the audit log query
                        $sessionName = $user.UserPrincipalName

                        $j = 0
                        Do {
                            #search the audit log differently for externals as I couldn't see successful or failed logons
                            if ($user.UserPrincipalName -like "*#EXT#@*"){
                                $AuditOutput = Search-UnifiedAuditLog -StartDate $startdate -EndDate $enddate -UserIds $user.UserPrincipalName -SessionId $sessionName -SessionCommand ReturnLargeSet -Operations PageViewed -ResultSize 5000
                            }
                            else{
                                $AuditOutput = Search-UnifiedAuditLog -StartDate $startdate -EndDate $enddate -UserIds $user.UserPrincipalName -SessionId $sessionName -SessionCommand ReturnLargeSet -Operations UserLoggedIn, UserLoginFailed -ResultSize 5000
                            }
                        
                            # If the count is 0, no records to process
                            if ($AuditOutput.Count -gt 0) {
                                #Get the data
                                $ConvertedOutput = $AuditOutput | Select-Object CreationDate, operations

                                if ($AuditOutput.Count -lt 5000) {
                                    $AuditOutput = @()
                                } else {
                                    $j++
                                }
                            }
                        } Until ($AuditOutput.Count -eq 0)

                        #The user is very active if the limit has been reached. This doesn't matter for this script but it will take more time.
                        if ($ConvertedOutput.Count -ge 50000) {
                            write-host "To many logins for user $($user.UserPrincipalName), it has been actively used" -foregroundcolor yellow
                        }

                        # Get the latest login date, latest operation and the number of days since the last action
                        if ( $ConvertedOutput.Count -gt 0 ) {
                            $logon = ($ConvertedOutput | Sort-Object CreationDate | Select-Object -last 1 -Property CreationDate).CreationDate
                            $operations = ($ConvertedOutput | Sort-Object CreationDate | Select-Object -last 1 -Property operations).operations
                            $timespan = New-TimeSpan -Start $logon -End $enddate
                            $daysLastLoggedOn = $timespan.days
                        } else {
                            $logon = $null
                            $timespan = $null
                            $operations = $null
                            $daysLastLoggedOn = "$($days)+"
                        }
                        
                        #place the user in the collection
                        $userCollection += new-object psobject -property @{UserPrincipalName = $user.UserPrincipalName;LastLogonTimestamp = $logon;CreationDate = $user.WhenCreated;LogonCount = $ConvertedOutput.Count;isLicensed = $user.isLicensed;DisplayName = $user.DisplayName;DaysLastLoggedOn = $daysLastLoggedOn;Status = $operations}
                        
                        $i++

                        #reparting Exchange connection every 500 users
                        if ($i % 500 -eq 0){
                            Invoke-ExoOnlineConnection -RepairPSSession
                        }
                    }

                    #sort output
                    $loggedOnUsers = $userCollection | Where-Object { $_.LogonCount -gt 0 }
                    $inactiveUsers = $userCollection | Where-Object { $_.LogonCount -eq 0 }

                    #We now have our collections so we are building the HTML page to get a direct view
                    #List of all inactive users
                    $article = "<h2>List of all inactive users</h2>"
                    $article += "<table>
                                <tr>
                                    <th>UPN</th>
                                    <th>Created</th>
                                    <th>DisplayName</th>
                                    <th>isLicensed</th>
                                    <th>Amount of logons</th>
                                    <th>Last logon timestamp</th>
                                    <th>days ago</th>
                                    <th>Status</th>
                                </tr>"

                    
                    foreach($inactiveUser in $inactiveUsers){
                    $article += "<tr>
                                    <td>$($inactiveUser.UserPrincipalName)</td>
                                    <td>$($inactiveUser.CreationDate)</td>
                                    <td>$($inactiveUser.DisplayName)</td>
                                    <td>$($inactiveUser.isLicensed)</td>
                                    <td>$($inactiveUser.LogonCount)</td>
                                    <td>$($inactiveUser.LastLogonTimestamp)</td>
                                    <td>$($inactiveUser.DaysLastLoggedOn)</td>
                                    <td>$($inactiveUser.Status)</td>
                                </tr>"

                    }
                    
                    $article += "</table>"

                    #List of all active users
                    $article += "<h2>List of all active users</h2>"
                    $article += "<table>
                                <tr>
                                    <th>UPN</th>
                                    <th>Created</th>
                                    <th>DisplayName</th>
                                    <th>isLicensed</th>
                                    <th>Amount of logons</th>
                                    <th>Last logon timestamp</th>
                                    <th>days ago</th>
                                    <th>Status</th>
                                </tr>"

                    
                    foreach($loggedonuser in $loggedonusers){
                    $article += "<tr>
                                    <td>$($loggedonuser.UserPrincipalName)</td>
                                    <td>$($loggedonuser.CreationDate)</td>
                                    <td>$($loggedonuser.DisplayName)</td>
                                    <td>$($loggedonuser.isLicensed)</td>
                                    <td>$($loggedonuser.LogonCount)</td>
                                    <td>$($loggedonuser.LastLogonTimestamp)</td>
                                    <td>$($loggedonuser.DaysLastLoggedOn)</td>
                                    <td>$($loggedonuser.Status)</td>
                                </tr>"

                    }
                    
                    $article += "</table>"

                    $date = get-date
                    $today = $date.ToString("ddMMyyyy_HHmm")
                    $LogPath = Join-Path $LogPath "HTMLInactiveUserReport_$($today).html"    
                    
                    #Head
                    $head = "
                    <html xmlns=`"http://www.w3.org/1999/xhtml`">
                        <head>
                            <style>
                                @charset `"UTF-8`";
 
                                @media print {
                                    body {-webkit-print-color-adjust: exact;}
                                }
                     
                                div.container {
                                    width: 100%;
                                    border: 1px solid gray;
                                }
                                 
                                header {
                                    padding: 0.1em;
                                    color: white;
                                    background-color: #000033;
                                    color: white;
                                    clear: left;
                                    text-align: center;
                                    border-bottom: 2px solid #FF0066
                                }
                                 
                                footer {
                                    padding: 0.1em;
                                    color: white;
                                    background-color: #000033;
                                    color: white;
                                    clear: left;
                                    text-align: center;
                                    border-top: 2px solid #FF0066
                                }
 
                                article {
                                    margin-left: 20px;
                                    min-width:600px;
                                    min-height: 600px;
                                    padding: 1em;
                                }
                                 
                                th{
                                    border:1px Solid Black;
                                    border-Collapse:collapse;
                                    background-color:#000033;
                                    color:white;
                                }
                                 
                                th{
                                    border:1px Solid Black;
                                    border-Collapse:collapse;
                                }
 
                            </style>
                        </head>
                    "

                    
                    #Header
                    $date = (get-date).tostring("dd-MM-yyyy")
                    $header = "
                        <h1>Inactive users Report</h1>
                        <h5>$($date)</h5>
                    "

                    
                    #Footer
                    $Footer = "
                        Copyright &copy;
                    "

                    
                    #Full HTML
                    $HTML = "
                        $($Head)
                        <body class=`"Inventory`">
                            <div class=`"container`">
                                <header>
                                    $($Header)
                                </header>
                                 
                                <article>
                                    $($article)
                                </article>
                                         
                                <footer>
                                    $($footer)
                                </footer>
                            </div>
                        </body>
                        </html>
                    "
 
                    add-content $HTML -path $LogPath

                    Write-Host "Office 365 inactive users overview created at $($LogPath), it will also open automatically in 5 seconds" -foregroundcolor green

                    Remove-PSSession $EXOSession

                    start-sleep -s 5
                    Invoke-Item $LogPath
                }
                catch{
                    write-host "Error occurred: $($_.Exception.Message), please post this error on https://www.cloudsecuritea.com" -foregroundcolor red
                }
            } Else { 
                Write-Host "The path $($LogPath) could not be found. Please enter a correct path to store the Office 365 subscription and license overview" -foregroundcolor yellow
            }
        }
        catch{Write-Host "The new Exchange Online module is not installed. Please install using the link in the blog" -foregroundcolor yellow}
    } else {
        Write-Host "MSOnline module not loaded. Please install the MSOnline module with Install-Module MSOnline" -foregroundcolor yellow
    }
}
catch{
    write-host "Error occurred: $($_.Exception.Message)" -foregroundcolor red
}