#Requires -Modules ActiveDirectory <# .SYNOPSIS Exports a list 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. PERFORMANCE: Uses regex-based XML extraction instead of DOM parsing and bulk AD user preloading for significantly faster 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 # Build logon types lookup (HashSet for fast contains-check) $LogonTypesAllowed = [System.Collections.Generic.HashSet[string]]::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 "UserLastLogonComputer_$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 "" $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 } } # Logon type display labels $LogonTypeLabels = @{ '2' = 'Interactive' '3' = 'Network' '10' = 'RDP' '11' = 'Cached' } 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 with regex (FAST) # ========================================== # Events come newest-first from Get-WinEvent, so the first time we see # a user is already their most recent logon - we can skip all later entries. $UserLogons = @{} $ProcessedCount = 0 $SkippedCount = 0 $i = 0 # Pre-compiled regex patterns for extracting fields from raw XML # This is ~10-20x faster than [xml] DOM parsing + Where-Object pipelines $rxLogonType = [regex]'Name="LogonType">(\d+)<' $rxTargetUser = [regex]'Name="TargetUserName">([^<]+)<' $rxTargetDomain = [regex]'Name="TargetDomainName">([^<]+)<' $rxWorkstation = [regex]'Name="WorkstationName">([^<]*)<' $rxIPAddress = [regex]'Name="IpAddress">([^<]*)<' foreach ($Event in $Events) { $i++ if ($i % 10000 -eq 0) { Write-Host " Processing event $i of $($Events.Count)... ($ProcessedCount matched, $SkippedCount skipped as already seen)" -ForegroundColor Gray } # Get raw XML string - DO NOT parse as [xml], just regex it $xmlStr = $Event.ToXml() # 1. Check logon type FIRST (cheapest filter) $m = $rxLogonType.Match($xmlStr) if (-not $m.Success) { continue } $LogonType = $m.Groups[1].Value if (-not $LogonTypesAllowed.Contains($LogonType)) { continue } # 2. Extract username and domain $m = $rxTargetUser.Match($xmlStr) if (-not $m.Success) { continue } $Username = $m.Groups[1].Value # 3. Quick filters before extracting more fields if ([string]::IsNullOrWhiteSpace($Username)) { continue } if ($Username.EndsWith('$')) { continue } if ($ExcludeUsers.Contains($Username)) { continue } $m = $rxTargetDomain.Match($xmlStr) $Domain = if ($m.Success) { $m.Groups[1].Value } else { '' } if ($ExcludeDomains.Contains($Domain)) { continue } # 4. Already seen this user? Skip (events are newest-first) $UserKey = "$Domain\$Username" if ($UserLogons.ContainsKey($UserKey)) { $SkippedCount++ continue } # 5. Only NOW extract the expensive fields (workstation, IP) $m = $rxWorkstation.Match($xmlStr) $Workstation = if ($m.Success) { $m.Groups[1].Value } else { '' } $m = $rxIPAddress.Match($xmlStr) $IPAddress = if ($m.Success) { $m.Groups[1].Value } else { '' } $LogonTime = $Event.TimeCreated # Determine computer name and DNS name $Computer = $null $DNSName = '' if (-not $SkipIPResolve -and -not [string]::IsNullOrWhiteSpace($IPAddress) -and $IPAddress -ne '-') { $resolved = Resolve-IPToDNSName -IP $IPAddress if ($resolved.Short) { $Computer = $resolved.Short } if ($resolved.FQDN) { $DNSName = $resolved.FQDN } } # Fall back to WorkstationName if IP didn't resolve if ([string]::IsNullOrWhiteSpace($Computer)) { if (-not [string]::IsNullOrWhiteSpace($Workstation) -and $Workstation -ne '-') { $Computer = $Workstation.ToUpper() } else { $Computer = "Unknown" } } $ProcessedCount++ # Store result - display name from bulk preloaded cache $DisplayName = if ($UserDisplayNames.ContainsKey($Username)) { $UserDisplayNames[$Username] } else { '' } $TypeLabel = if ($LogonTypeLabels.ContainsKey($LogonType)) { $LogonTypeLabels[$LogonType] } else { "Type $LogonType" } $UserLogons[$UserKey] = [PSCustomObject]@{ Domain = $Domain Username = $Username DisplayName = $DisplayName Computer = $Computer DNSName = $DNSName LogonTime = $LogonTime LogonType = $TypeLabel } } $ProcessTime = (Get-Date) - $ScriptStart Write-Host "Processed $ProcessedCount unique user logons ($SkippedCount duplicate events skipped)." -ForegroundColor Yellow $Results = $UserLogons.Values | Sort-Object Domain, Username if ($Results.Count -eq 0) { Write-Host "`nNo user logon events found matching criteria." -ForegroundColor Yellow return } # ========================================== # PHASE 4: Export # ========================================== $Results | Select-Object Domain, Username, DisplayName, Computer, DNSName, LogonTime, LogonType | Export-Csv -Path $CsvFile -NoTypeInformation -Encoding UTF8 Write-Host "`n===== Results =====" -ForegroundColor Green Write-Host "Total users found: $($Results.Count)" Write-Host "Output saved to: $CsvFile" Write-Host "Completed in: $($ProcessTime.Minutes)m $($ProcessTime.Seconds)s" -ForegroundColor Cyan Write-Host "" # Display summary table $Results | Format-Table Domain, Username, DisplayName, Computer, DNSName, LogonTime, LogonType -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 } }