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 © " #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 } |