# Version date: 2026-08-05 17:32 (CEST) # ------------------------------------------------------------ # Finds local user accounts that appear to still be on their original # temporary password (from CreateLocalUsers.ps1), months after the # migration, so they can be reviewed and disabled if no longer needed. # # This uses two independent signals for each account: # - LastLogon (from Get-LocalUser) - real authentication history, blank # if the account has never actually been logged into. # - The "must change password at next logon" flag (UF_PASSWORD_EXPIRED) # - this is set by CreateLocalUsers.ps1 on every account it creates, # and is designed to clear itself automatically the first time someone # logs in and changes their password. If it's still set, that person # has (almost always) never done so. # # Run interactively, by hand - not intended to run unattended. Only a # CSV record of what was actually disabled is kept (DisabledUsers.csv); # there is no separate session log, since the console output itself is # the review. # ------------------------------------------------------------ # Verify this is running with Administrator rights; stop with a clear # message if not, since disabling accounts requires elevation. if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Host "ERROR: Run this script as Administrator." ; exit } # By default, the record of disabled accounts is written to the same # folder this script is saved in - PowerShell's built-in $PSScriptRoot # variable always points there automatically. To use a different, fixed # folder instead, replace the line below with a fixed path. $CsvFolder = $PSScriptRoot $DisabledUsersFile = Join-Path $CsvFolder "DisabledUsers.csv" # Accounts never shown or touched by this script - built-in Windows # accounts by default. Add more names here (e.g. local admin accounts) # as needed, separated by commas. Matching is case-insensitive. $ExcludedUsers = @( "Administrator", "Guest", "Gast", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0" ) # Resolve the localized name of the built-in Administrators group # (language-independent, via well-known SID, so this also works on # non-English Windows), and get the names of everyone in it - admin # accounts (e.g. a custom one like "admincore") are never shown or # touched by this script, regardless of their logon history. $AdminGroupName = ([System.Security.Principal.SecurityIdentifier]"S-1-5-32-544").Translate([System.Security.Principal.NTAccount]).Value.Split("\")[1] $AdminUsers = @( Get-LocalGroupMember -Group $AdminGroupName -ErrorAction Stop | ForEach-Object { $_.Name.Split("\")[-1] } ) # Reads the "must change password at next logon" flag directly from # Windows for a given account name, via the same mechanism # CreateLocalUsers.ps1 uses to verify it when first setting it. function Test-StillHasTempPassword { param([string]$UserName) try { $UserFlags = ([ADSI]"WinNT://./$UserName,user").UserFlags.Value return [bool]($UserFlags -band 0x800000) } catch { return $false } } # -------------------------------------------------------------------------- # Build the full report: every local, non-admin account, with its logon # history and temporary-password status. # -------------------------------------------------------------------------- $AllUsers = Get-LocalUser | Where-Object { $_.Name -notin $ExcludedUsers -and $_.Name -notin $AdminUsers } | ForEach-Object { [PSCustomObject]@{ Name = $_.Name FullName = $_.FullName AccountEnabled = $_.Enabled LastLogon = $_.LastLogon StillTempPassword = Test-StillHasTempPassword $_.Name } } Write-Host "" Write-Host "========================================" Write-Host "All local user accounts (excluding admins)" Write-Host "========================================" $AllUsers | Sort-Object StillTempPassword -Descending | Select-Object Name, FullName, AccountEnabled, @{ Name = "LastLogon" Expression = { if ($_.LastLogon) { $_.LastLogon.ToString("yyyy-MM-dd") } else { "" } } }, StillTempPassword | Format-Table -AutoSize | Out-String | Write-Host # -------------------------------------------------------------------------- # Group 1: never logged in AND still has the temporary password - the # clean, unambiguous disable candidates. # -------------------------------------------------------------------------- $CleanCandidates = $AllUsers | Where-Object { -not $_.LastLogon -and $_.StillTempPassword } # -------------------------------------------------------------------------- # Group 2: mixed signal - only one of the two conditions is true. This # should normally be empty or very small; it can happen if "must change # password" didn't take effect on a real logon (a known Windows quirk), # or if the flag was cleared manually without an actual logon. # -------------------------------------------------------------------------- $MixedSignal = $AllUsers | Where-Object { ($_.LastLogon -and $_.StillTempPassword) -or (-not $_.LastLogon -and -not $_.StillTempPassword) } # If there is nothing to review at all, say so directly and stop here - # no point asking whether to review an empty list. if ($CleanCandidates.Count -eq 0 -and $MixedSignal.Count -eq 0) { Write-Host "" Write-Host "All users have logged in at least once and changed their startpassword." exit } $Proceed = Read-Host "Go through list of inactive users and offer disable option? (Y/N)" if ($Proceed -ine "Y") { Write-Host "Nothing done." exit } $DisabledCount = 0 $SkippedCount = 0 # Disables every account in $Users, or asks one by one, or skips the whole # group - shared by both groups below so the prompt behaves identically # for each. $ReasonFor is a scriptblock that returns the right CSV reason # text for a given user, since the two groups have different reasons. function Invoke-DisableGroup { param( [array]$Users, [scriptblock]$ReasonFor ) $Choice = Read-Host "Disable accounts from the last table? (A)ll (1)-by-1 (S)kip" foreach ($User in $Users) { $DoDisable = $false if ($Choice -ieq "A") { $DoDisable = $true } elseif ($Choice -eq "1") { $ItemConfirm = Read-Host "Disable $($User.Name)? (Y/N)" $DoDisable = ($ItemConfirm -ieq "Y") } if ($DoDisable) { try { Disable-LocalUser -Name $User.Name -ErrorAction Stop if ($User.FullName) { Write-Host "Disabled: $($User.Name) ($($User.FullName))" } else { Write-Host "Disabled: $($User.Name)" } [PSCustomObject]@{ Name = $User.Name DisabledAt = (Get-Date -Format "yyyy-MM-dd HH:mm:ss") Reason = & $ReasonFor $User } | Export-Csv -Path $DisabledUsersFile -NoTypeInformation -Encoding UTF8 -Append $script:DisabledCount++ } catch { Write-Host "ERROR disabling $($User.Name): $($_.Exception.Message)" } } else { $script:SkippedCount++ } } } # -------------------------------------------------------------------------- # Group 1: shown and processed if it has any entries. # -------------------------------------------------------------------------- if ($CleanCandidates.Count -gt 0) { Write-Host "" Write-Host "--- Never logged in, still has temporary password ---" $CleanCandidates | Select-Object Name, FullName, @{ Name = "LastLogon" Expression = { if ($_.LastLogon) { $_.LastLogon.ToString("yyyy-MM-dd") } else { "" } } }, StillTempPassword | Format-Table -AutoSize | Out-String | Write-Host Invoke-DisableGroup -Users $CleanCandidates -ReasonFor { "Never logged in - still has temporary password" } } # -------------------------------------------------------------------------- # Group 2: shown and processed if it has any entries. # -------------------------------------------------------------------------- if ($MixedSignal.Count -gt 0) { Write-Host "" Write-Host "--- Mixed signal (only one of the two conditions) ---" $MixedSignal | Select-Object Name, FullName, @{ Name = "LastLogon" Expression = { if ($_.LastLogon) { $_.LastLogon.ToString("yyyy-MM-dd") } else { "" } } }, StillTempPassword | Format-Table -AutoSize | Out-String | Write-Host Invoke-DisableGroup -Users $MixedSignal -ReasonFor { param($User) if ($User.LastLogon) { "Logged in, but temporary password flag was still set" } else { "Never logged in, but temporary password flag was already cleared" } } } Write-Host "" Write-Host "========================================" Write-Host "Summary: $DisabledCount account(s) disabled, $SkippedCount skipped." Write-Host "========================================" if ($DisabledCount -gt 0) { Write-Host "Record written to: $DisabledUsersFile" }