298 lines
11 KiB
PowerShell
298 lines
11 KiB
PowerShell
#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
|
|
}
|
|
}
|