#Requires -Version 5.1

<#
.SYNOPSIS
Gets user logon, logoff, lock, and unlock activity from Windows event logs.

.EXAMPLE
$Start = '2026-07-03T00:00:00Z'
$End = '2026-07-06T00:00:00Z'
.\Get-UserLogonActivity.ps1

.EXAMPLE
.\Get-UserLogonActivity.ps1
#>

$StartVariable = Get-Variable -Name Start -ErrorAction SilentlyContinue
$EndVariable = Get-Variable -Name End -ErrorAction SilentlyContinue
$UseDateRange = ($null -ne $StartVariable -and $null -ne $EndVariable)

If (($null -ne $StartVariable) -xor ($null -ne $EndVariable)) {
    Write-Error '$Start and $End must either both be defined or both be omitted. If omitted, the script returns the latest 15 activity events.'
    Exit 1
}

Function ConvertTo-ActivityDateTime {
    Param(
        [Parameter(Mandatory=$true)]
        [AllowNull()]
        [object]$Value,

        [Parameter(Mandatory=$true)]
        [string]$ParameterName
    )

    If ($Value -is [DateTimeOffset]) {
        Return $Value
    }

    If ($Value -is [DateTime]) {
        Return [DateTimeOffset]$Value
    }

    $TrimmedValue = ([string]$Value).Trim()
    $Culture = [Globalization.CultureInfo]::InvariantCulture
    $ParsedDateTime = [DateTimeOffset]::MinValue

    $UtcFormats = [string[]]@(
        "yyyy-MM-dd'T'HH:mm:ss'Z'",
        "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'"
    )

    $OffsetFormats = [string[]]@(
        "yyyy-MM-dd'T'HH:mm:sszzz",
        "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz"
    )

    $LocalFormats = [string[]]@(
        "yyyy-MM-dd'T'HH:mm:ss",
        "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF",
        "yyyy-MM-dd HH:mm:ss",
        "yyyy-MM-dd",
        "MM/dd/yyyy HH:mm:ss",
        "MM/dd/yyyy"
    )

    $UtcStyles = [Globalization.DateTimeStyles]::AllowWhiteSpaces -bor [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal
    $LocalStyles = [Globalization.DateTimeStyles]::AllowWhiteSpaces -bor [Globalization.DateTimeStyles]::AssumeLocal

    If ([DateTimeOffset]::TryParseExact($TrimmedValue, $UtcFormats, $Culture, $UtcStyles, [ref]$ParsedDateTime)) {
        Return $ParsedDateTime
    }

    If ([DateTimeOffset]::TryParseExact($TrimmedValue, $OffsetFormats, $Culture, [Globalization.DateTimeStyles]::AllowWhiteSpaces, [ref]$ParsedDateTime)) {
        Return $ParsedDateTime
    }

    If ([DateTimeOffset]::TryParseExact($TrimmedValue, $LocalFormats, $Culture, $LocalStyles, [ref]$ParsedDateTime)) {
        Return $ParsedDateTime
    }

    If ([DateTimeOffset]::TryParse($TrimmedValue, $Culture, $LocalStyles, [ref]$ParsedDateTime)) {
        Return $ParsedDateTime
    }

    Throw "Invalid -${ParameterName} datetime '${Value}'. Use ISO 8601 UTC format: yyyy-MM-ddTHH:mm:ssZ. Example: 2026-07-03T00:00:00Z"
}

Function Get-EventDataValue {
    Param(
        [Parameter(Mandatory=$true)]
        [xml]$EventXml,

        [Parameter(Mandatory=$true)]
        [string]$Name
    )

    $Data = $EventXml.Event.EventData.Data | Where-Object { $_.Name -eq $Name } | Select-Object -First 1
    If ($null -eq $Data) { Return $null }

    Return [string]$Data.'#text'
}

Function Get-EventDataValueFromList {
    Param(
        [Parameter(Mandatory=$true)]
        [xml]$EventXml,

        [Parameter(Mandatory=$true)]
        [string[]]$Names
    )

    ForEach ($Name in $Names) {
        $Value = Get-EventDataValue -EventXml $EventXml -Name $Name
        If (-not [string]::IsNullOrWhiteSpace($Value) -and $Value -ne '-') {
            Return $Value
        }
    }

    Return $null
}

Function Test-UserLogonActivityUser {
    Param(
        [AllowNull()]
        [string]$UserName
    )

    If ([string]::IsNullOrWhiteSpace($UserName)) { Return $false }

    $LeafUserName = ($UserName -split '\\')[-1]

    If ($LeafUserName -eq '-') { Return $false }
    If ($LeafUserName.EndsWith('$')) { Return $false }
    If ($LeafUserName -in @('ANONYMOUS LOGON', 'LOCAL SERVICE', 'NETWORK SERVICE', 'SYSTEM')) { Return $false }
    If ($LeafUserName -match '^(DWM|UMFD)-\d+$') { Return $false }

    Return $true
}

Function Format-UserName {
    Param(
        [AllowNull()]
        [string]$DomainName,

        [Parameter(Mandatory=$true)]
        [string]$UserName
    )

    If ([string]::IsNullOrWhiteSpace($DomainName) -or $DomainName -eq '-') {
        Return $UserName
    }

    Return "${DomainName}\${UserName}"
}

Function Resolve-UserLogonActivitySid {
    Param(
        [AllowNull()]
        [object]$Sid
    )

    If ($null -eq $Sid) { Return $null }

    $SidString = ([string]$Sid).Trim()
    If ([string]::IsNullOrWhiteSpace($SidString) -or $SidString -eq '-') { Return $null }

    Try {
        Return (New-Object Security.Principal.SecurityIdentifier($SidString)).Translate([Security.Principal.NTAccount]).Value
    }
    Catch {
        $ProfilePath = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\${SidString}" -Name ProfileImagePath -ErrorAction SilentlyContinue).ProfileImagePath

        If (-not [string]::IsNullOrWhiteSpace($ProfilePath)) {
            Return (Split-Path -Path $ProfilePath -Leaf)
        }

        Return $SidString
    }
}

Function Get-UserLogonActivitySidFromEvent {
    Param(
        [Parameter(Mandatory=$true)]
        [object]$Event
    )

    $EventXml = [xml]$Event.ToXml()
    $UserSid = Get-EventDataValueFromList -EventXml $EventXml -Names @('UserSid', 'TargetUserSid', 'SubjectUserSid')

    If (-not [string]::IsNullOrWhiteSpace($UserSid)) {
        Return $UserSid
    }

    ForEach ($Property in $Event.Properties) {
        $PropertyValue = [string]$Property.Value

        If ($PropertyValue -match '^S-\d-\d+-.+') {
            Return $PropertyValue
        }
    }

    Return [string]$EventXml.Event.System.Security.UserID
}

Function New-UserLogonActivityRecord {
    Param(
        [Parameter(Mandatory=$true)]
        [datetime]$TimeCreated,

        [Parameter(Mandatory=$true)]
        [string]$User,

        [Parameter(Mandatory=$true)]
        [ValidateSet('logon', 'logoff', 'unlock', 'lock')]
        [string]$Activity
    )

    If (-not (Test-UserLogonActivityUser -UserName $User)) { Return $null }

    [PSCustomObject]@{
        Date = $TimeCreated.ToString('yyyy-MM-dd')
        Time = $TimeCreated.ToString('HH:mm:ss')
        User = $User
        Activity = $Activity
        TimeCreated = $TimeCreated
    }
}

Function ConvertTo-WinlogonUserActivity {
    Param(
        [Parameter(Mandatory=$true)]
        [object]$Event
    )

    Switch ($Event.Id) {
        7001 { $Activity = 'logon' }
        7002 { $Activity = 'logoff' }
        Default { Return $null }
    }

    $User = Resolve-UserLogonActivitySid -Sid (Get-UserLogonActivitySidFromEvent -Event $Event)
    If ([string]::IsNullOrWhiteSpace($User)) { Return $null }

    New-UserLogonActivityRecord -TimeCreated $Event.TimeCreated -User $User -Activity $Activity
}

Function ConvertTo-SecurityUserActivity {
    Param(
        [Parameter(Mandatory=$true)]
        [object]$Event
    )

    $EventXml = [xml]$Event.ToXml()
    $UserName = Get-EventDataValueFromList -EventXml $EventXml -Names @('TargetUserName', 'SubjectUserName')
    $DomainName = Get-EventDataValueFromList -EventXml $EventXml -Names @('TargetDomainName', 'SubjectDomainName')
    $LogonType = Get-EventDataValue -EventXml $EventXml -Name 'LogonType'
    $Activity = $null

    Switch ($Event.Id) {
        4624 {
            If ($LogonType -ne '7') { Return $null }
            $Activity = 'unlock'
        }
        4800 {
            $Activity = 'lock'
        }
        4801 {
            $Activity = 'unlock'
        }
        Default {
            Return $null
        }
    }

    New-UserLogonActivityRecord -TimeCreated $Event.TimeCreated -User (Format-UserName -DomainName $DomainName -UserName $UserName) -Activity $Activity
}

Function Get-UserLogonActivityWinEvent {
    Param(
        [Parameter(Mandatory=$true)]
        [hashtable]$FilterHashtable,

        [int]$MaxEvents = 0,

        [Parameter(Mandatory=$true)]
        [string]$LogDescription
    )

    $Parameters = @{
        FilterHashtable = $FilterHashtable
        ErrorAction = 'Stop'
    }

    If ($MaxEvents -gt 0) {
        $Parameters.MaxEvents = $MaxEvents
    }

    Try {
        Return @(Get-WinEvent @Parameters)
    }
    Catch {
        If ($_.FullyQualifiedErrorId -like '*NoMatchingEventsFound*' -or $_.Exception.Message -like '*No events were found*') {
            Return @()
        }

        Write-Error "Unable to read ${LogDescription}. Run PowerShell as administrator if access is denied. $($_.Exception.Message)"
        Return @()
    }
}

Function Add-UserLogonActivityRecord {
    Param(
        [Parameter(Mandatory=$true)]
        [object]$ActivityEvents,

        [AllowNull()]
        [object]$ActivityEvent
    )

    If ($null -ne $ActivityEvent) {
        $ActivityEvents.Add($ActivityEvent) | Out-Null
    }
}

Function Get-UniqueUserLogonActivity {
    Param(
        [AllowNull()]
        [object[]]$ActivityEvents
    )

    $UniqueEvents = New-Object System.Collections.Generic.List[object]

    ForEach ($ActivityEvent in (@($ActivityEvents) | Where-Object { $null -ne $_ } | Sort-Object -Property TimeCreated,User,Activity)) {
        $IsDuplicate = $false

        ForEach ($UniqueEvent in $UniqueEvents) {
            If (
                $UniqueEvent.User -eq $ActivityEvent.User -and
                $UniqueEvent.Activity -eq $ActivityEvent.Activity -and
                [Math]::Abs(($UniqueEvent.TimeCreated - $ActivityEvent.TimeCreated).TotalSeconds) -le 5
            ) {
                $IsDuplicate = $true
                Break
            }
        }

        If (-not $IsDuplicate) {
            $UniqueEvents.Add($ActivityEvent) | Out-Null
        }
    }

    Return $UniqueEvents
}

Function Write-UserLogonActivityOutput {
    Param(
        [AllowNull()]
        [object[]]$ActivityEvents
    )

    $Rows = @($ActivityEvents | Select-Object Date,Time,User,Activity)

    If ($Rows.Count -eq 0) {
        Write-Output 'No matching user logon activity events found.'
        Return
    }

    Write-Output (($Rows | Format-Table -AutoSize | Out-String -Width 240).TrimEnd())
}

# Winlogon 7001/7002 tracks user session logon/logoff more reliably than Security 4624/4634.
$WinlogonFilter = @{
    LogName = 'System'
    ProviderName = 'Microsoft-Windows-Winlogon'
    Id = @(7001, 7002)
}

$SecurityFilter = @{
    LogName = 'Security'
    Id = @(4624, 4800, 4801)
}

If ($UseDateRange) {
    Try {
        $StartDateTime = ConvertTo-ActivityDateTime -Value $Start -ParameterName 'Start'
        $EndDateTime = ConvertTo-ActivityDateTime -Value $End -ParameterName 'End'
    }
    Catch {
        Write-Error $_.Exception.Message
        Exit 1
    }

    If ($EndDateTime -le $StartDateTime) {
        Write-Error "-End must be later than -Start."
        Exit 1
    }

    $WinlogonFilter.StartTime = $StartDateTime.LocalDateTime
    $WinlogonFilter.EndTime = $EndDateTime.LocalDateTime
    $SecurityFilter.StartTime = $StartDateTime.LocalDateTime
    $SecurityFilter.EndTime = $EndDateTime.LocalDateTime

    $ActivityEvents = New-Object System.Collections.Generic.List[object]

    ForEach ($Event in (Get-UserLogonActivityWinEvent -FilterHashtable $WinlogonFilter -LogDescription 'System Winlogon event log')) {
        Add-UserLogonActivityRecord -ActivityEvents $ActivityEvents -ActivityEvent (ConvertTo-WinlogonUserActivity -Event $Event)
    }

    ForEach ($Event in (Get-UserLogonActivityWinEvent -FilterHashtable $SecurityFilter -LogDescription 'Security event log')) {
        Add-UserLogonActivityRecord -ActivityEvents $ActivityEvents -ActivityEvent (ConvertTo-SecurityUserActivity -Event $Event)
    }

    $OutputEvents = @(Get-UniqueUserLogonActivity -ActivityEvents $ActivityEvents | Sort-Object -Property TimeCreated)
    Write-UserLogonActivityOutput -ActivityEvents $OutputEvents
}
Else {
    $ActivityLimit = 15
    $SearchLimit = 200
    $MaxSearchLimit = 10000
    $OutputEvents = @()

    Do {
        $ActivityEvents = New-Object System.Collections.Generic.List[object]
        $WinlogonEvents = @(Get-UserLogonActivityWinEvent -FilterHashtable $WinlogonFilter -MaxEvents $SearchLimit -LogDescription 'System Winlogon event log')
        $SecurityEvents = @(Get-UserLogonActivityWinEvent -FilterHashtable $SecurityFilter -MaxEvents $SearchLimit -LogDescription 'Security event log')

        ForEach ($Event in $WinlogonEvents) {
            Add-UserLogonActivityRecord -ActivityEvents $ActivityEvents -ActivityEvent (ConvertTo-WinlogonUserActivity -Event $Event)
        }

        ForEach ($Event in $SecurityEvents) {
            Add-UserLogonActivityRecord -ActivityEvents $ActivityEvents -ActivityEvent (ConvertTo-SecurityUserActivity -Event $Event)
        }

        $OutputEvents = @(Get-UniqueUserLogonActivity -ActivityEvents $ActivityEvents | Sort-Object -Property TimeCreated -Descending | Select-Object -First $ActivityLimit)

        If ($OutputEvents.Count -ge $ActivityLimit -or (($WinlogonEvents.Count -lt $SearchLimit) -and ($SecurityEvents.Count -lt $SearchLimit)) -or $SearchLimit -ge $MaxSearchLimit) {
            Break
        }

        $SearchLimit = [Math]::Min(($SearchLimit * 2), $MaxSearchLimit)
    } While ($true)

    Write-UserLogonActivityOutput -ActivityEvents $OutputEvents
}
