From 0b510082d437959bc71f0b60dab72a3f196999ab Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Wed, 9 Sep 2026 10:31:53 -0600 Subject: [PATCH] Validate local password expiration on Windows 10 --- scripts/Invoke-SguClientBootstrap.ps1 | 3 ++- scripts/Publish-GiteaRelease.ps1 | 1 + scripts/Set-SguStandardLocalUser.ps1 | 25 ++++++++++++++++++++++++- scripts/Test-SguClientEnrollment.ps1 | 16 +++++++++++++--- tests/ClientEnrollmentScripts.Tests.ps1 | 10 ++++++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/scripts/Invoke-SguClientBootstrap.ps1 b/scripts/Invoke-SguClientBootstrap.ps1 index 532d34e..5392939 100644 --- a/scripts/Invoke-SguClientBootstrap.ps1 +++ b/scripts/Invoke-SguClientBootstrap.ps1 @@ -593,7 +593,8 @@ finally { Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force } if (-not $winRmWasRunning) { - Stop-Service WinRM -Force -ErrorAction SilentlyContinue + Stop-Service WinRM -Force -NoWait -WarningAction SilentlyContinue ` + -ErrorAction SilentlyContinue } Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue $DomainCredential = $null diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index 7c83914..39f9920 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -121,6 +121,7 @@ Bootstrap reproducible para el laboratorio SGU. - El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU. - El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores. - La descripción de la cuenta local administrada respeta el límite de 48 caracteres de Windows 10 Enterprise. +- La validación de expiración de contraseña usa el indicador de cuenta compatible con Windows 10 y 11, en lugar de una propiedad que Windows 10 no expone. - El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral. - El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker. - Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host. diff --git a/scripts/Set-SguStandardLocalUser.ps1 b/scripts/Set-SguStandardLocalUser.ps1 index b176b53..575ca25 100644 --- a/scripts/Set-SguStandardLocalUser.ps1 +++ b/scripts/Set-SguStandardLocalUser.ps1 @@ -6,6 +6,14 @@ $ErrorActionPreference = 'Stop' $userName = 'alumno' $plainTextPassword = 'ingenieria' $description = 'Cuenta local estandar SGU para recuperacion' +$passwordNeverExpiresFlag = 0x10000 + +function Get-LocalUserFlags { + param([Parameter(Mandatory)][string]$Name) + + $directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,user") + return [int]$directoryEntry.InvokeGet('UserFlags') +} $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) @@ -40,6 +48,16 @@ try { -Description $description | Out-Null } + # Windows 10's Get-LocalUser object has PasswordExpires but does not expose + # PasswordNeverExpires. Enforce and verify the underlying UF_DONT_EXPIRE_PASSWD + # flag so the result is consistent across Windows 10 and Windows 11. + $directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$userName,user") + $userFlags = [int]$directoryEntry.InvokeGet('UserFlags') + if (($userFlags -band $passwordNeverExpiresFlag) -eq 0) { + $directoryEntry.InvokeSet('UserFlags', ($userFlags -bor $passwordNeverExpiresFlag)) + $directoryEntry.CommitChanges() + } + $user = Get-LocalUser -Name $userName -ErrorAction Stop $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') $usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') @@ -74,11 +92,16 @@ if (@($verifiedAdministrators).SID.Value -contains $verifiedUser.SID.Value) { if ($verifiedUsers.SID.Value -notcontains $verifiedUser.SID.Value) { throw "The local account '$userName' does not belong to the local Users group." } +$verifiedPasswordNeverExpires = + ((Get-LocalUserFlags -Name $userName) -band $passwordNeverExpiresFlag) -ne 0 +if (-not $verifiedPasswordNeverExpires) { + throw "The local account '$userName' password is not configured to never expire." +} [pscustomobject]@{ UserName = $verifiedUser.Name Enabled = $verifiedUser.Enabled IsAdministrator = $false IsStandardUser = $true - PasswordNeverExpires = $verifiedUser.PasswordNeverExpires + PasswordNeverExpires = $verifiedPasswordNeverExpires } diff --git a/scripts/Test-SguClientEnrollment.ps1 b/scripts/Test-SguClientEnrollment.ps1 index dfc8183..44e7d84 100644 --- a/scripts/Test-SguClientEnrollment.ps1 +++ b/scripts/Test-SguClientEnrollment.ps1 @@ -20,6 +20,7 @@ $interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\P $settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json' $issues = [Collections.Generic.List[string]]::new() $standardLocalUserName = 'alumno' +$passwordNeverExpiresFlag = 0x10000 $computer = Get-CimInstance Win32_ComputerSystem if ($RequireDomainJoined -and -not $computer.PartOfDomain) { @@ -93,8 +94,7 @@ $standardLocalUserPresent = [bool]$standardLocalUser $standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled $standardLocalUserIsAdministrator = $false $standardLocalUserInUsersGroup = $false -$standardLocalUserPasswordNeverExpires = - $standardLocalUserPresent -and $standardLocalUser.PasswordNeverExpires +$standardLocalUserPasswordNeverExpires = $false if ($standardLocalUserPresent) { $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') $usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') @@ -106,6 +106,16 @@ if ($standardLocalUserPresent) { $administratorMembers.SID.Value -contains $standardLocalUser.SID.Value $standardLocalUserInUsersGroup = $standardMembers.SID.Value -contains $standardLocalUser.SID.Value + try { + $directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$standardLocalUserName,user") + $userFlags = [int]$directoryEntry.InvokeGet('UserFlags') + $standardLocalUserPasswordNeverExpires = + ($userFlags -band $passwordNeverExpiresFlag) -ne 0 + } + catch { + # Report the account as invalid when Windows cannot read its flags. + $standardLocalUserPasswordNeverExpires = $false + } } if (-not $standardLocalUserPresent) { $issues.Add("The required standard local user '$standardLocalUserName' is missing.") @@ -120,7 +130,7 @@ elseif (-not $standardLocalUserInUsersGroup) { $issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.") } elseif (-not $standardLocalUserPasswordNeverExpires) { - $issues.Add("The required standard local user '$standardLocalUserName' does not retain its enrollment password.") + $issues.Add("The required standard local user '$standardLocalUserName' password is not configured to never expire.") } $settings = $null diff --git a/tests/ClientEnrollmentScripts.Tests.ps1 b/tests/ClientEnrollmentScripts.Tests.ps1 index 3bb3a4e..5a34b1f 100644 --- a/tests/ClientEnrollmentScripts.Tests.ps1 +++ b/tests/ClientEnrollmentScripts.Tests.ps1 @@ -1,5 +1,6 @@ $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $localUserScriptPath = Join-Path $repositoryRoot 'scripts\Set-SguStandardLocalUser.ps1' +$enrollmentTestScriptPath = Join-Path $repositoryRoot 'scripts\Test-SguClientEnrollment.ps1' $tokens = $null $parseErrors = $null @@ -28,4 +29,13 @@ Describe 'SGU Windows client enrollment scripts' { $source | Should Match "\$userName = 'alumno'" $source | Should Match "\$plainTextPassword = 'ingenieria'" } + + It 'uses the cross-version Windows account flag for password expiration' { + $localUserSource = Get-Content -LiteralPath $localUserScriptPath -Raw + $enrollmentTestSource = Get-Content -LiteralPath $enrollmentTestScriptPath -Raw + $localUserSource | Should Match '\$passwordNeverExpiresFlag = 0x10000' + $enrollmentTestSource | Should Match '\$passwordNeverExpiresFlag = 0x10000' + $localUserSource | Should Not Match '\$verifiedUser\.PasswordNeverExpires' + $enrollmentTestSource | Should Not Match '\$standardLocalUser\.PasswordNeverExpires' + } }