Files
AD-ComputerLastLogon/Get-UserLastLogonComputer.ps1

342 lines
13 KiB
PowerShell

#Requires -Modules ActiveDirectory
<#
.SYNOPSIS
Exports a CSV report of users and the last PC they logged into.
.DESCRIPTION
Queries Domain Controller security event logs for logon events (4624)
to determine which computer each user last authenticated from.
Resolves IP addresses to DNS names and looks up user display names.
CSV Report Columns: Date, Username, Display Name, PC Name, DNS, IP
PERFORMANCE: Uses event .Properties[] index access (no XML parsing)
and bulk AD user preloading for fast execution.
.PARAMETER OutputPath
Path for the output CSV file. Defaults to current directory.
.PARAMETER DaysBack
Number of days of event logs to search. Default is 7.
.PARAMETER ExcludeNetworkLogons
Exclude Type 3 (Network) logons. Default includes them.
.PARAMETER SkipIPResolve
Skip IP to hostname resolution. Faster but less useful.
.EXAMPLE
.\Get-UserLastLogonComputer.ps1
.EXAMPLE
.\Get-UserLastLogonComputer.ps1 -OutputPath "C:\Reports" -DaysBack 30
.NOTES
Must be run on a Domain Controller with appropriate permissions to read Security logs.
Logon Type 2 = Interactive (console)
Logon Type 3 = Network (file shares, etc.)
Logon Type 10 = RemoteInteractive (RDP)
Logon Type 11 = CachedInteractive
#>
[CmdletBinding()]
param(
[Parameter()]
[string]$OutputPath = (Get-Location).Path,
[Parameter()]
[int]$DaysBack = 7,
[Parameter()]
[switch]$ExcludeNetworkLogons,
[Parameter()]
[switch]$SkipIPResolve
)
$ScriptStart = Get-Date
# Validate output path exists
if (-not (Test-Path $OutputPath)) {
Write-Host "Output path '$OutputPath' does not exist. Creating it..." -ForegroundColor Yellow
try {
New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
} catch {
Write-Host "Error: Could not create output directory '$OutputPath'. $($_.Exception.Message)" -ForegroundColor Red
return
}
}
# Build logon types lookup (HashSet for fast contains-check using int)
$LogonTypesAllowed = [System.Collections.Generic.HashSet[int]]::new()
@(2, 10, 11) | ForEach-Object { [void]$LogonTypesAllowed.Add($_) }
if (-not $ExcludeNetworkLogons) {
[void]$LogonTypesAllowed.Add(3)
}
# System accounts to exclude (HashSet for O(1) lookup)
$ExcludeUsers = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::OrdinalIgnoreCase
)
@('SYSTEM', 'LOCAL SERVICE', 'NETWORK SERVICE', 'DWM-1', 'DWM-2', 'DWM-3', 'DWM-4',
'UMFD-0', 'UMFD-1', 'UMFD-2', 'UMFD-3', 'ANONYMOUS LOGON', '-') |
ForEach-Object { [void]$ExcludeUsers.Add($_) }
$ExcludeDomains = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::OrdinalIgnoreCase
)
@('Window Manager', 'Font Driver Host', 'NT AUTHORITY') |
ForEach-Object { [void]$ExcludeDomains.Add($_) }
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$CsvFile = Join-Path $OutputPath "UserLastLogonReport_$Timestamp.csv"
Write-Host "`nQuerying security event logs for the last $DaysBack days..." -ForegroundColor Cyan
Write-Host "Looking for logon types: $($LogonTypesAllowed -join ', ')" -ForegroundColor Cyan
Write-Host "CSV will be saved to: $CsvFile" -ForegroundColor Cyan
Write-Host ""
$StartDate = (Get-Date).AddDays(-$DaysBack)
# ==========================================
# PHASE 1: Bulk preload all AD users (one query)
# ==========================================
Write-Host "Pre-loading AD user display names (bulk query)..." -ForegroundColor Yellow
$UserDisplayNames = @{}
try {
Get-ADUser -Filter * -Properties DisplayName | ForEach-Object {
if ($_.DisplayName) {
$UserDisplayNames[$_.SamAccountName] = $_.DisplayName
}
}
Write-Host " Loaded $($UserDisplayNames.Count) user display names." -ForegroundColor Gray
} catch {
Write-Host " Warning: Could not bulk-load AD users. Display names will be empty." -ForegroundColor DarkYellow
}
# Cache for IP to DNS name resolution
$DNSCache = @{}
function Resolve-IPToDNSName {
param([string]$IP)
if ([string]::IsNullOrWhiteSpace($IP) -or $IP -eq '-' -or $IP -eq '::1' -or $IP -eq '127.0.0.1') {
return @{ Short = $null; FQDN = $null }
}
if ($DNSCache.ContainsKey($IP)) {
return $DNSCache[$IP]
}
try {
$fqdn = [System.Net.Dns]::GetHostEntry($IP).HostName
$shortName = ($fqdn -split '\.')[0].ToUpper()
$result = @{ Short = $shortName; FQDN = $fqdn.ToLower() }
$DNSCache[$IP] = $result
return $result
} catch {
try {
$computer = Get-ADComputer -Filter "IPv4Address -eq '$IP'" -Properties DNSHostName, Name -ErrorAction SilentlyContinue
if ($computer) {
$result = @{ Short = $computer.Name; FQDN = if ($computer.DNSHostName) { $computer.DNSHostName.ToLower() } else { '' } }
$DNSCache[$IP] = $result
return $result
}
} catch {}
$result = @{ Short = $null; FQDN = $null }
$DNSCache[$IP] = $result
return $result
}
}
try {
# ==========================================
# PHASE 2: Retrieve events
# ==========================================
Write-Host "Retrieving logon events from Security log..." -ForegroundColor Yellow
$FilterHash = @{
LogName = 'Security'
ID = 4624
StartTime = $StartDate
}
$Events = Get-WinEvent -FilterHashtable $FilterHash -ErrorAction Stop
Write-Host "Found $($Events.Count) total 4624 events. Processing..." -ForegroundColor Yellow
# ==========================================
# PHASE 3: Process events using Properties[] index
# ==========================================
# Event 4624 Properties index map:
# [0] SubjectUserSid [1] SubjectUserName
# [2] SubjectDomainName [3] SubjectLogonId
# [4] TargetUserSid [5] TargetUserName
# [6] TargetDomainName [7] TargetLogonId
# [8] LogonType [9] LogonProcessName
# [10] AuthenticationPackageName
# [11] WorkstationName [12] LogonGuid
# [13] TransmittedServices [14] LmPackageName
# [15] KeyLength [16] ProcessId
# [17] ProcessName [18] IpAddress
# [19] IpPort
#
# Using .Properties[n].Value is MUCH faster than XML parsing
# Dump first event to verify field mapping
$firstEvent = $Events[0]
$props = $firstEvent.Properties
Write-Host "`n DEBUG: First event field check:" -ForegroundColor DarkGray
Write-Host " [5] TargetUserName = '$($props[5].Value)'" -ForegroundColor DarkGray
Write-Host " [6] TargetDomainName = '$($props[6].Value)'" -ForegroundColor DarkGray
Write-Host " [8] LogonType = '$($props[8].Value)'" -ForegroundColor DarkGray
Write-Host " [11] WorkstationName = '$($props[11].Value)'" -ForegroundColor DarkGray
Write-Host " [18] IpAddress = '$($props[18].Value)'" -ForegroundColor DarkGray
Write-Host " Total properties count = $($props.Count)" -ForegroundColor DarkGray
Write-Host ""
$UserLogons = @{}
$ProcessedCount = 0
$SkippedCount = 0
$FilteredCount = 0
$i = 0
foreach ($Event in $Events) {
$i++
if ($i % 10000 -eq 0) {
Write-Host " Processing event $i of $($Events.Count)... ($ProcessedCount matched, $SkippedCount already seen, $FilteredCount filtered)" -ForegroundColor Gray
}
$props = $Event.Properties
# Safety check - skip events with unexpected property count
if ($props.Count -lt 19) {
$FilteredCount++
continue
}
# 1. Check logon type FIRST (cheapest filter)
$LogonType = [int]$props[8].Value
if (-not $LogonTypesAllowed.Contains($LogonType)) {
$FilteredCount++
continue
}
# 2. Extract username
$Username = [string]$props[5].Value
# 3. Quick filters
if ([string]::IsNullOrWhiteSpace($Username)) { $FilteredCount++; continue }
if ($Username.EndsWith('$')) { $FilteredCount++; continue }
if ($ExcludeUsers.Contains($Username)) { $FilteredCount++; continue }
$Domain = [string]$props[6].Value
if ($ExcludeDomains.Contains($Domain)) { $FilteredCount++; continue }
# 4. Already seen this user? Skip (events are newest-first)
$UserKey = "$Domain\$Username"
if ($UserLogons.ContainsKey($UserKey)) {
$SkippedCount++
continue
}
# 5. Extract workstation and IP (only for first occurrence)
$Workstation = [string]$props[11].Value
$IPAddress = [string]$props[18].Value
$LogonTime = $Event.TimeCreated
# Determine PC name and DNS name
$PCName = $null
$DNSName = ''
if (-not $SkipIPResolve -and -not [string]::IsNullOrWhiteSpace($IPAddress) -and $IPAddress -ne '-') {
$resolved = Resolve-IPToDNSName -IP $IPAddress
if ($resolved.Short) { $PCName = $resolved.Short }
if ($resolved.FQDN) { $DNSName = $resolved.FQDN }
}
# Fall back to WorkstationName if IP didn't resolve
if ([string]::IsNullOrWhiteSpace($PCName)) {
if (-not [string]::IsNullOrWhiteSpace($Workstation) -and $Workstation -ne '-') {
$PCName = $Workstation.ToUpper()
} else {
$PCName = 'Unknown'
}
}
# Clean up IP for display
$IPDisplay = if ($IPAddress -and $IPAddress -ne '-' -and $IPAddress -ne '::1' -and $IPAddress -ne '127.0.0.1') { $IPAddress } else { '' }
$ProcessedCount++
# Store result with report column names
$DisplayName = if ($UserDisplayNames.ContainsKey($Username)) { $UserDisplayNames[$Username] } else { '' }
$UserLogons[$UserKey] = [PSCustomObject]@{
'Date' = $LogonTime.ToString('yyyy-MM-dd HH:mm:ss')
'Username' = $Username
'Display Name' = $DisplayName
'PC Name' = $PCName
'DNS' = $DNSName
'IP' = $IPDisplay
}
}
$ProcessTime = (Get-Date) - $ScriptStart
Write-Host "`nProcessed $ProcessedCount unique user logons." -ForegroundColor Yellow
Write-Host " $SkippedCount duplicate events skipped (already seen user)." -ForegroundColor Gray
Write-Host " $FilteredCount events filtered (system/computer accounts, wrong logon type)." -ForegroundColor Gray
# ==========================================
# PHASE 4: Export CSV
# ==========================================
$Results = @($UserLogons.Values | Sort-Object Username)
if ($Results.Count -eq 0) {
Write-Host "`nNo user logon events found matching criteria." -ForegroundColor Yellow
return
}
# Write CSV
try {
$Results | Export-Csv -Path $CsvFile -NoTypeInformation -Encoding UTF8 -Force
if (Test-Path $CsvFile) {
$FileSize = (Get-Item $CsvFile).Length
Write-Host "`n===== Report Saved =====" -ForegroundColor Green
Write-Host "Total users: $($Results.Count)"
Write-Host "File size: $([math]::Round($FileSize / 1KB, 1)) KB"
Write-Host "Saved to: $CsvFile" -ForegroundColor Green
Write-Host "Completed in: $($ProcessTime.Minutes)m $($ProcessTime.Seconds)s" -ForegroundColor Cyan
} else {
Write-Host "`nError: CSV file was not created at '$CsvFile'" -ForegroundColor Red
}
} catch {
Write-Host "`nError saving CSV: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Attempting to save to Desktop instead..." -ForegroundColor Yellow
$FallbackPath = Join-Path ([Environment]::GetFolderPath('Desktop')) "UserLastLogonReport_$Timestamp.csv"
try {
$Results | Export-Csv -Path $FallbackPath -NoTypeInformation -Encoding UTF8 -Force
Write-Host "Saved to fallback location: $FallbackPath" -ForegroundColor Green
} catch {
Write-Host "Error: Could not save CSV. $($_.Exception.Message)" -ForegroundColor Red
}
}
Write-Host ""
# Display summary table in console
$Results | Format-Table -AutoSize
} catch [System.Exception] {
if ($_.Exception.Message -match "No events were found") {
Write-Host "`nNo logon events (Event ID 4624) found in the last $DaysBack days." -ForegroundColor Yellow
} elseif ($_.Exception.Message -match "Access is denied") {
Write-Host "`nError: Access denied. Run PowerShell as Administrator." -ForegroundColor Red
} else {
Write-Host "`nError: $($_.Exception.Message)" -ForegroundColor Red
}
}