diff --git a/docs/architecture.md b/docs/architecture.md index 77e5b0f..dad3c0e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,16 +73,20 @@ Every synchronized user also receives `Universidad La Salle` in the AD The generic SGU credential is rendered as a dedicated branded tile instead of being grouped below the anonymous **Other user** tile. Machine policy assigns -the SGU CLSID as the default provider while retaining the built-in Microsoft -password provider. It enumerates one `CPFT_TILE_IMAGE` and places the -`CPFT_LARGE_TEXT` heading immediately after it with `CPFS_DISPLAY_IN_BOTH`, -matching Microsoft's supported field ordering. LogonUI owns field typography: -on Windows 10 and 11, the account-name title used by **Other user** is shell UI, -not a style that a generic Credential Provider can request. Do not add a second -tile image or a synthetic Windows account to imitate that title. +the SGU CLSID as the default provider, hides the last signed-in identity, and +disables local-user enumeration while retaining the built-in Microsoft password +provider and its **Other user** recovery path. It enumerates one +`CPFT_TILE_IMAGE` and places the `CPFT_LARGE_TEXT` heading immediately after it +with `CPFS_DISPLAY_IN_SELECTED_TILE`. LogonUI owns field typography and vertical +tile order: on Windows 10 and 11, the account-name title used by **Other user** +is shell UI, not a style that a generic Credential Provider can request. Do not +add a second tile image, filter the system password provider, or create a +synthetic Windows account to imitate that title or ordering. The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`, -and `Administrativos` are direct child OUs beneath it. +and `Administrativos` are direct child OUs beneath it. The domain GPO +`SGU - User session restrictions` is linked to this root and enables the +per-user `DisableLockWorkstation` policy for the complete hierarchy. Per-user synchronization is serialized inside the broker to prevent concurrent create/reset races. Production deployments should run the broker as a gMSA with diff --git a/docs/client-enrollment.md b/docs/client-enrollment.md index 64e5fce..818453b 100644 --- a/docs/client-enrollment.md +++ b/docs/client-enrollment.md @@ -57,7 +57,8 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass ` Orden de la transacción: 1. instala .NET y los binarios versionados; -2. registra COM y configura SGU como proveedor predeterminado; +2. registra COM, configura SGU como proveedor predeterminado y oculta el + último usuario que cerró sesión; 3. instala el guard de autorreparación; 4. exige health mTLS del broker y ejecuta las comprobaciones locales; 5. configura DNS del dominio; @@ -65,8 +66,25 @@ Orden de la transacción: 7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final. La directiva de Windows **Assign a default credential provider** selecciona SGU -por defecto. No se instala ningún filtro: el proveedor de contraseña de -Microsoft permanece disponible para cuentas creadas manualmente y recuperación. +por defecto. El instalador también habilita **Interactive logon: Don't display +last signed-in** y deshabilita **Enumerate local users on domain-joined +computers**. De este modo un usuario SGU desaparece después de cerrar sesión y +permanece el mosaico genérico **Other user**. No se instala ningún filtro: el +proveedor de contraseña de Microsoft permanece disponible para cuentas creadas +manualmente y recuperación. + +En el controlador de dominio, aplicar una vez la directiva de usuario al árbol +completo `Usuarios-SGU`: + +```powershell +.\Set-SguDomainUserPolicies.ps1 +``` + +El script crea y vincula de forma idempotente el GPO +`SGU - User session restrictions` y configura `DisableLockWorkstation=1` para +Administrativos, Alumnos y Docentes. La directiva elimina la acción manual de +bloqueo; no sustituye otras directivas independientes de protector de pantalla, +inactividad o desconexión de RDP. ## 3. Verificación y reparación @@ -82,21 +100,31 @@ Start-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard ``` Un resultado válido exige simultáneamente binario y registro COM, configuración, -certificados, .NET 10, proveedor SGU predeterminado y proveedor de contraseña de -Microsoft preservado. El script de reparación se encuentra en +certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto, +enumeración local deshabilitada y proveedor de contraseña de Microsoft +preservado. El script de reparación se encuentra en `C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y administradores. ## 4. Experiencia de LogonUI El proveedor usa un mosaico genérico dedicado, no una credencial asociada al SID -anónimo de **Other user**. Windows puede mostrar ahora la imagen institucional y -la etiqueta `Universidad La Salle · Acceso SGU`, además del encabezado -`Acceso institucional SGU`. El orden visual exacto de los iconos de opciones lo -decide Windows; lo administrable y exigido es cuál aparece seleccionado por -defecto. +anónimo de **Other user**. Windows muestra la imagen institucional y la etiqueta +`Universidad La Salle · Acceso SGU`; el encabezado `Acceso institucional SGU` +se publica como `CPFT_LARGE_TEXT` solamente en el mosaico seleccionado. LogonUI +elige la familia, el peso y el tamaño final y no expone opciones de tipografía a +un Credential Provider. + +La configuración soportada garantiza que SGU queda seleccionado por defecto y +que no se conserva el último usuario. El orden vertical exacto de mosaicos lo +decide LogonUI y no tiene una directiva pública. Por seguridad no se filtra ni +se elimina **Other user**: Microsoft recomienda conservar al menos un proveedor +del sistema como ruta de recuperación. Referencias de Microsoft: - +- +- +- - diff --git a/docs/lab-runbook.md b/docs/lab-runbook.md index e358266..d46f411 100644 --- a/docs/lab-runbook.md +++ b/docs/lab-runbook.md @@ -129,9 +129,18 @@ On Windows 10: -DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe ``` -Use Lithnet's `Invoke-CredUI` test utility when available, or lock the VM and -select **Acceso institucional SGU** under sign-in options. Keep the built-in -Windows password tile visible. +Use Lithnet's `Invoke-CredUI` test utility when available, or sign out/restart +the VM and select **Acceso institucional SGU**. Keep the built-in Windows +password provider available through **Other user**. + +On the domain controller, apply the SGU user policy once: + +```powershell +.\Set-SguDomainUserPolicies.ps1 +``` + +It links `SGU - User session restrictions` to `OU=Usuarios-SGU` and prevents +managed users from manually locking the workstation. Before testing through Hyper-V Enhanced Session/RDP, enable the dedicated lab group and Windows PowerShell Remoting: diff --git a/docs/windows-client-onboarding.md b/docs/windows-client-onboarding.md index 90c4e9b..31439bc 100644 --- a/docs/windows-client-onboarding.md +++ b/docs/windows-client-onboarding.md @@ -90,12 +90,13 @@ contenido. De esta forma se puede preparar una actualización aunque `LogonUI.exe` todavía tenga cargada la DLL anterior; el reinicio obligatorio activa la nueva versión. -El instalador también asigna SGU como proveedor predeterminado mediante la -directiva de equipo de Windows. No modifica ni oculta el proveedor de contraseña -de Microsoft. +El instalador también asigna SGU como proveedor predeterminado, oculta el último +usuario que cerró sesión y deshabilita la enumeración de usuarios locales en el +equipo unido al dominio. No modifica ni oculta el proveedor de contraseña de +Microsoft; **Other user** permanece como ruta de recuperación. -Bloquear el equipo, abrir **Sign-in options** y elegir el icono azul con llave -del acceso SGU. El proveedor de contraseña de Microsoft debe permanecer visible. +Cerrar sesión o reiniciar el equipo. El acceso SGU debe quedar seleccionado por +defecto y el proveedor de contraseña de Microsoft debe permanecer disponible. ## 5. Verificación diff --git a/scripts/Install-CredentialProvider.ps1 b/scripts/Install-CredentialProvider.ps1 index 349e754..01baf69 100644 --- a/scripts/Install-CredentialProvider.ps1 +++ b/scripts/Install-CredentialProvider.ps1 @@ -34,6 +34,7 @@ $settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json $providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId" $classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32" $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' +$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) @@ -181,13 +182,34 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti Set-Item -Path $providerRegistryPath -Value 'Universidad La Salle · Acceso SGU' if (-not $DoNotSetAsDefaultCredentialProvider) { - New-Item -Path $defaultProviderPolicyPath -Force | Out-Null + if (-not (Test-Path -LiteralPath $defaultProviderPolicyPath)) { + New-Item -Path $defaultProviderPolicyPath -Force | Out-Null + } New-ItemProperty -Path $defaultProviderPolicyPath ` -Name DefaultCredentialProvider ` -Value $providerClassId ` -PropertyType String ` -Force | Out-Null } + + # Do not leave a signed-out SGU identity exposed as a persistent user tile. + # The Microsoft password provider remains registered and supplies Other user. + if (-not (Test-Path -LiteralPath $interactiveLogonPolicyPath)) { + New-Item -Path $interactiveLogonPolicyPath -Force | Out-Null + } + New-ItemProperty -Path $interactiveLogonPolicyPath ` + -Name DontDisplayLastUserName ` + -Value 1 ` + -PropertyType DWord ` + -Force | Out-Null + if (-not (Test-Path -LiteralPath $defaultProviderPolicyPath)) { + New-Item -Path $defaultProviderPolicyPath -Force | Out-Null + } + New-ItemProperty -Path $defaultProviderPolicyPath ` + -Name EnumerateLocalUsers ` + -Value 0 ` + -PropertyType DWord ` + -Force | Out-Null } $defaultProviderConfigured = $false @@ -207,5 +229,11 @@ catch { SettingsPath = $settingsPath Registered = Test-Path -LiteralPath $providerRegistryPath DefaultProviderConfigured = $defaultProviderConfigured + LastSignedInUserHidden = (Get-ItemPropertyValue ` + -LiteralPath $interactiveLogonPolicyPath ` + -Name DontDisplayLastUserName) -eq 1 + LocalUserEnumerationDisabled = (Get-ItemPropertyValue ` + -LiteralPath $defaultProviderPolicyPath ` + -Name EnumerateLocalUsers) -eq 0 SystemPasswordProviderPreserved = $true } diff --git a/scripts/Set-SguDomainUserPolicies.ps1 b/scripts/Set-SguDomainUserPolicies.ps1 new file mode 100644 index 0000000..22b87e9 --- /dev/null +++ b/scripts/Set-SguDomainUserPolicies.ps1 @@ -0,0 +1,95 @@ +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$TargetOuDn = 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx', + [string]$GpoName = 'SGU - User session restrictions', + [string]$DomainController = $env:COMPUTERNAME +) + +$ErrorActionPreference = 'Stop' +$policyKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System' +$policyValueName = 'DisableLockWorkstation' + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this script from an elevated Windows PowerShell session on a domain controller or management host.' +} + +Import-Module ActiveDirectory -ErrorAction Stop +Import-Module GroupPolicy -ErrorAction Stop + +$targetOu = Get-ADOrganizationalUnit ` + -Identity $TargetOuDn ` + -Server $DomainController ` + -ErrorAction Stop +$domainDn = ($targetOu.DistinguishedName -split ',DC=', 2)[1] +if (-not $domainDn) { + throw 'TargetOuDn does not contain a domain distinguished name.' +} +$domainName = ($domainDn -replace ',DC=', '.') + +$gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue +if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU user policy GPO')) { + $gpo = New-GPO -Name $GpoName -Domain $domainName -Server $DomainController +} +if (-not $gpo) { + throw "The GPO '$GpoName' does not exist and was not created." +} + +$existingLink = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks | + Where-Object DisplayName -eq $GpoName | + Select-Object -First 1 +$existingLinkEnabled = $existingLink -and ( + $existingLink.Enabled -eq $true -or + [string]$existingLink.Enabled -eq 'Yes') +if (-not $existingLink) { + if ($PSCmdlet.ShouldProcess($TargetOuDn, "Link and enable '$GpoName'")) { + New-GPLink ` + -Name $GpoName ` + -Target $TargetOuDn ` + -Domain $domainName ` + -Server $DomainController ` + -LinkEnabled Yes | Out-Null + } +} +elseif (-not $existingLinkEnabled -and + $PSCmdlet.ShouldProcess($TargetOuDn, "Enable the '$GpoName' link")) { + Set-GPLink ` + -Name $GpoName ` + -Target $TargetOuDn ` + -Domain $domainName ` + -Server $DomainController ` + -LinkEnabled Yes | Out-Null +} + +if ($PSCmdlet.ShouldProcess($GpoName, 'Prevent SGU users from manually locking workstations')) { + Set-GPRegistryValue ` + -Name $GpoName ` + -Domain $domainName ` + -Server $DomainController ` + -Key $policyKey ` + -ValueName $policyValueName ` + -Type DWord ` + -Value 1 | Out-Null +} + +$configuredValue = Get-GPRegistryValue ` + -Name $GpoName ` + -Domain $domainName ` + -Server $DomainController ` + -Key $policyKey ` + -ValueName $policyValueName +$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks | + Where-Object DisplayName -eq $GpoName | + Select-Object -First 1 +$linkEnabled = $link -and ( + $link.Enabled -eq $true -or + [string]$link.Enabled -eq 'Yes') + +[pscustomobject]@{ + GpoName = $GpoName + GpoId = $gpo.Id + TargetOu = $TargetOuDn + LinkEnabled = [bool]$linkEnabled + DisableLockWorkstation = [int]$configuredValue.Value +} diff --git a/scripts/Test-SguClientEnrollment.ps1 b/scripts/Test-SguClientEnrollment.ps1 index 7680255..443ccae 100644 --- a/scripts/Test-SguClientEnrollment.ps1 +++ b/scripts/Test-SguClientEnrollment.ps1 @@ -14,6 +14,7 @@ $providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authent $passwordProviderRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$passwordProviderClassId" $classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32" $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' +$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' $settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json' $issues = [Collections.Generic.List[string]]::new() @@ -51,6 +52,34 @@ if (-not $defaultProviderConfigured) { $issues.Add('The SGU provider is not assigned as the machine default credential provider.') } +$lastSignedInUserHidden = $false +try { + $lastSignedInUserHidden = (Get-ItemPropertyValue ` + -LiteralPath $interactiveLogonPolicyPath ` + -Name DontDisplayLastUserName ` + -ErrorAction Stop) -eq 1 +} +catch { + # Report the missing or unreadable policy as a failed enrollment check. +} +if (-not $lastSignedInUserHidden) { + $issues.Add('The last signed-in user is not hidden from LogonUI.') +} + +$localUserEnumerationDisabled = $false +try { + $localUserEnumerationDisabled = (Get-ItemPropertyValue ` + -LiteralPath $defaultProviderPolicyPath ` + -Name EnumerateLocalUsers ` + -ErrorAction Stop) -eq 0 +} +catch { + # Report the missing or unreadable policy as a failed enrollment check. +} +if (-not $localUserEnumerationDisabled) { + $issues.Add('Local user enumeration is not explicitly disabled for the domain client.') +} + $passwordProviderPreserved = Test-Path -LiteralPath $passwordProviderRegistryPath if (-not $passwordProviderPreserved) { $issues.Add('The built-in Microsoft password provider registration is missing.') @@ -137,6 +166,8 @@ $result = [pscustomobject]@{ ProviderBinary = $registeredDll ProviderBinaryPresent = [bool]$providerBinaryPresent DefaultProviderConfigured = $defaultProviderConfigured + LastSignedInUserHidden = $lastSignedInUserHidden + LocalUserEnumerationDisabled = $localUserEnumerationDisabled PasswordProviderPreserved = $passwordProviderPreserved SettingsPresent = [bool]$settings ClientCertificatePresent = [bool]$clientCertificatePresent diff --git a/src/SGU.CredentialProvider/SguCredentialProvider.cs b/src/SGU.CredentialProvider/SguCredentialProvider.cs index e9fcb56..764a089 100644 --- a/src/SGU.CredentialProvider/SguCredentialProvider.cs +++ b/src/SGU.CredentialProvider/SguCredentialProvider.cs @@ -23,7 +23,9 @@ public sealed class SguCredentialProvider : CredentialProviderBase ProviderTileIcon.Create()); yield return new LargeLabelControl(ControlKeys.Heading, "Acceso institucional SGU") { - State = FieldState.DisplayInBoth + // Isolate the CPFT_LARGE_TEXT heading from the compact provider tile + // for the selected-layout variant. LogonUI still owns the typography. + State = FieldState.DisplayInSelectedTile }; yield return new SmallLabelControl( ControlKeys.InformationLabel, diff --git a/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs b/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs index 9239d0f..0bbf5b6 100644 --- a/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs +++ b/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs @@ -44,7 +44,7 @@ public sealed class ProviderTileIconTests SguCredentialTile tile = Assert.IsType(provider.CreateGenericTile()); Assert.Equal("Acceso institucional SGU", heading.Label); - Assert.Equal(FieldState.DisplayInBoth, heading.State); + Assert.Equal(FieldState.DisplayInSelectedTile, heading.State); Assert.Equal(GenericTileDisplayMode.DisplayAsDedicatedTile, tile.GenericTileDisplayMode); } }