Improve SGU logon resilience and client UX

This commit is contained in:
2026-09-01 10:37:40 -06:00
parent 3d0897316d
commit da01343985
27 changed files with 869 additions and 37 deletions
+8 -1
View File
@@ -31,6 +31,12 @@ display name is a conservative fallback until a richer role-specific page is
verified. Missing or changed presentation HTML never blocks authentication or verified. Missing or changed presentation HTML never blocks authentication or
password synchronization. password synchronization.
Operational documentation:
- [Broker location, health, timeout, and recovery](docs/broker-operations.md)
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
| Prefix | Role | Default OU | | Prefix | Role | Default OU |
|---|---|---| |---|---|---|
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | | `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
@@ -60,7 +66,8 @@ Prerequisites are captured in `.vsconfig`; the pinned SDK is .NET `10.0.400`.
```powershell ```powershell
dotnet restore .\SGU-CredentialProvider.sln dotnet restore .\SGU-CredentialProvider.sln
dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore
dotnet test .\SGU-CredentialProvider.sln -c Release --no-build --no-restore dotnet test --project .\tests\SGU.AuthBroker.Core.Tests\SGU.AuthBroker.Core.Tests.csproj -c Release
dotnet test --project .\tests\SGU.CredentialProvider.Tests\SGU.CredentialProvider.Tests.csproj -c Release
.\scripts\Publish-Lab.ps1 .\scripts\Publish-Lab.ps1
``` ```
+10
View File
@@ -52,6 +52,16 @@ digits of the requested `AD` identity before any scraped metadata is trusted.
Missing metadata does not clear existing AD values and never changes the Missing metadata does not clear existing AD values and never changes the
password outcome. password outcome.
Human-readable SGU values are decoded with BOM/header/meta detection, strict
UTF-8 validation, and a Windows-1252 fallback for the legacy portal. Names and
titles are normalized with Spanish-aware casing; particles such as `de`, `del`
and `de la` remain lowercase and Unicode accents are preserved. Values that
still contain the Unicode replacement character are not written to AD.
When `RemoteDesktopGroupDn` is configured, the broker also adds each successfully
synchronized SGU user to that dedicated AD security group. The laboratory
Windows client maps the group into its local **Remote Desktop Users** group.
The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`, 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.
+79
View File
@@ -0,0 +1,79 @@
# Operación del SGU Authentication Broker
## Dónde se ejecuta
En el laboratorio, el broker se ejecuta en el controlador de dominio Windows
Server:
| Elemento | Valor |
|---|---|
| Equipo | `WIN-1AIQMMA1EPR.lci.lasalle.mx` |
| Servicio | `SGUAuthBroker` / `SGU Authentication Broker` |
| Ejecutable | `C:\Program Files\SGU\AuthBroker\SGU.AuthBroker.exe` |
| Configuración | `C:\Program Files\SGU\AuthBroker\appsettings.Production.json` |
| Endpoint cliente | `https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate` |
| Salud | `https://sgu-auth.lci.lasalle.mx:8443/health/live` |
| Transporte | HTTPS 1.1 con certificado de cliente obligatorio (mTLS) |
El Credential Provider no se comunica directamente con Active Directory ni
conserva una contraseña. Envía la clave y la contraseña originales al broker;
el broker valida SGU, actualiza la cuenta de dominio y devuelve solamente el
dominio y el nombre de usuario canónico.
## Comprobación rápida
Ejecutar como administrador en Windows Server:
```powershell
Get-CimInstance Win32_Service -Filter "Name='SGUAuthBroker'" |
Select-Object Name, State, StartMode, PathName, ProcessId
Get-NetTCPConnection -LocalPort 8443 -State Listen
Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)'
sc.exe qfailure SGUAuthBroker
```
Desde un cliente que tenga el certificado mTLS:
```powershell
Invoke-WebRequest `
-Uri https://sgu-auth.lci.lasalle.mx:8443/health/live `
-CertificateThumbprint CLIENT_CERT_THUMBPRINT
```
Una petición sin un certificado cliente válido debe ser rechazada durante TLS.
Eso es comportamiento esperado, no una caída del servicio.
## Timeouts y recuperación
- El Credential Provider espera hasta **20 segundos** por el broker.
- El broker espera hasta **15 segundos** por SGU. Un portal que normalmente
tarda alrededor de seis segundos queda dentro del margen sin bloquear LogonUI
indefinidamente.
- El instalador configura recuperación del servicio con reinicios a los 5, 15
y 60 segundos y reinicia el contador de fallos después de 24 horas.
- Si el broker o SGU no está disponible, el Credential Provider entrega la
contraseña intacta a Windows. AD o el caché de inicio de sesión de Windows aún
debe validarla; el fallback no concede acceso por sí mismo.
Reiniciar o volver a desplegar el servicio produce una interrupción breve en un
laboratorio con una sola instancia. Antes de una demostración, comprobar
`Running` y el listener 8443.
## Diagnóstico
```powershell
Get-WinEvent -FilterHashtable @{
LogName='Application'
StartTime=(Get-Date).AddHours(-1)
} | Where-Object ProviderName -eq 'SGU Authentication Broker' |
Select-Object TimeCreated, LevelDisplayName, Id, Message
Get-Content 'C:\Program Files\SGU\AuthBroker\appsettings.Production.json' -Raw |
ConvertFrom-Json |
Select-Object -ExpandProperty Broker
```
Nunca habilitar registro de cuerpos HTTP: contienen la contraseña durante la
solicitud. Los certificados, no las contraseñas, proporcionan la identidad entre
el cliente y el broker.
+60
View File
@@ -0,0 +1,60 @@
# ADR 0001: no usar Redis como caché de contraseñas
- Estado: aceptado
- Alcance: autenticación SGU y fallback sin conexión
## Contexto
SGU puede tardar varios segundos o quedar temporalmente fuera de servicio. Se
consideró instalar Redis persistente en el mismo Windows Server que hospeda el
broker y Active Directory, almacenando un hash con sal/clave o una contraseña
cifrada.
## Decisión
No se almacenarán contraseñas, contraseñas cifradas, hashes, HMAC ni otros
verificadores de contraseña en Redis. Tampoco se instalará Redis en el
controlador de dominio.
El mecanismo de continuidad es el ya proporcionado por Windows:
1. una autenticación SGU exitosa sincroniza la contraseña original a AD;
2. AD valida esa contraseña mientras el controlador es accesible;
3. Windows conserva su verificador de inicio de sesión de dominio para equipos
desconectados;
4. si SGU o el broker no responde, el Credential Provider entrega la misma
credencial a Windows y deja que AD/LSA decidan.
## Motivos
- Un hash no permite recuperar la contraseña original. Comparar la contraseña
presentada contra otro verificador solo duplicaría el material atacable que
ya mantienen AD y Windows.
- Una contraseña cifrada es un secreto reversible; colocar la clave de descifrado
junto al broker convierte el caché en una bóveda de credenciales reutilizables.
- Un caché persistente aceptaría durante más tiempo una contraseña revocada y
añadiría una tercera fuente de verdad entre SGU y AD.
- Redis persistente escribe RDB/AOF a disco. La documentación de Redis recomienda
controles adicionales como TLS, ACL y cifrado del lado cliente.
- Redis Open Source no tiene un servicio Windows nativo propio; Redis documenta
Memurai o WSL para Windows. Ninguna opción debe ampliar la superficie de ataque
de un controlador de dominio.
- Microsoft recomienda reducir el software instalado en controladores de dominio
y tratarlos como sistemas sensibles de propósito único.
Referencias:
- <https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/cached-domain-logon-information>
- <https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/securing-domain-controllers-against-attack>
- <https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/>
- <https://redis.io/docs/latest/operate/oss_and_stack/install/archive/install-redis/install-redis-on-windows/>
- <https://redis.io/docs/latest/operate/rs/security/recommended-security-practices/>
## Alternativa permitida en el futuro
Redis podría ser útil para metadatos no secretos —nombre normalizado,
dependencia, puesto o control de tasa distribuido— si existe más de una instancia
del broker. En ese caso debe ejecutarse en un servidor miembro o VM Linux
separada, usar TLS y ACL, tener TTL corto y nunca participar en la decisión de
aceptar una contraseña. Con una sola instancia, un caché en memoria es más
simple y no añade otra dependencia al inicio de sesión.
+16
View File
@@ -80,6 +80,7 @@ certificate thumbprint:
-PublishPath C:\Deploy\broker ` -PublishPath C:\Deploy\broker `
-ServerCertificateSubject sgu-auth.lci.lasalle.mx ` -ServerCertificateSubject sgu-auth.lci.lasalle.mx `
-AllowedClientThumbprints CLIENT_CERT_THUMBPRINT ` -AllowedClientThumbprints CLIENT_CERT_THUMBPRINT `
-RemoteDesktopGroupDn 'CN=SG-Laboratorio-Usuarios-RDP,OU=Laboratorio,DC=lci,DC=lasalle,DC=mx' `
-CreateMissingOus ` -CreateMissingOus `
-DisableCertificateRevocationCheckForLab -DisableCertificateRevocationCheckForLab
``` ```
@@ -88,6 +89,8 @@ Verify the service and managed OUs:
```powershell ```powershell
Get-Service SGUAuthBroker Get-Service SGUAuthBroker
Get-NetTCPConnection -LocalPort 8443 -State Listen
sc.exe qfailure SGUAuthBroker
Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx' Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
``` ```
@@ -116,6 +119,7 @@ On Windows 10:
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate ` -BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT ` -ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT ` -ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
-TimeoutSeconds 20 `
-InstallDotNetRuntime ` -InstallDotNetRuntime `
-DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe -DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe
``` ```
@@ -124,6 +128,18 @@ 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 select **Acceso institucional SGU** under sign-in options. Keep the built-in
Windows password tile visible. Windows password tile visible.
Before testing through Hyper-V Enhanced Session/RDP, enable the dedicated lab
group and Windows PowerShell Remoting:
```powershell
.\Enable-LabRemoteAccess.ps1 `
-RemoteDesktopPrincipal 'LCI\SG-Laboratorio-Usuarios-RDP' `
-EnableAdministrativeFirewallGroups
```
See [`windows-client-onboarding.md`](windows-client-onboarding.md) for domain
join, RDP, WinRM, firewall, and error `0xC000015B` diagnostics.
## 6. Required end-to-end cases ## 6. Required end-to-end cases
1. Online valid `DO`, `AL`, and `AD` logons; verify each OU. 1. Online valid `DO`, `AL`, and `AD` logons; verify each OU.
+6
View File
@@ -52,6 +52,12 @@ infrastructure:
to Windows. Windows must still validate it against AD or the cached domain to Windows. Windows must still validate it against AD or the cached domain
verifier, so this does not grant access without the last synchronized password. verifier, so this does not grant access without the last synchronized password.
Redis is deliberately not used for password continuity. A second persistent
password verifier duplicates attackable credential material, while a reversible
encrypted password becomes a credential vault. AD and Windows cached domain
logon already implement the required last-known-password behavior. See
[`decisions/0001-no-password-cache.md`](decisions/0001-no-password-cache.md).
The installer never registers a Credential Provider filter and never disables The installer never registers a Credential Provider filter and never disables
Microsoft's password, PIN, smart-card, or Windows Hello providers. Microsoft's password, PIN, smart-card, or Windows Hello providers.
+117
View File
@@ -0,0 +1,117 @@
# Alta de un equipo Windows en el laboratorio LCI
Esta guía configura un Windows 10/11 Pro o Enterprise para el dominio, RDP con
NLA y PowerShell Remoting. Está pensada para la red aislada del laboratorio; no
abre estos servicios en el perfil de red pública.
## 1. Prerrequisitos
- Una cuenta con permiso para unir equipos al dominio.
- DNS primario del adaptador apuntando al controlador de dominio
`192.168.50.10`.
- Conectividad con `lci.lasalle.mx` y hora sincronizada.
- Windows Pro, Enterprise o Education para actuar como host RDP.
```powershell
Get-NetAdapter
Set-DnsClientServerAddress -InterfaceAlias 'Ethernet' `
-ServerAddresses 192.168.50.10
Resolve-DnsName -Type SRV _ldap._tcp.dc._msdcs.lci.lasalle.mx
```
## 2. Unir el equipo al dominio
Abrir Windows PowerShell como administrador. El prompt solicita la contraseña
de forma interactiva y evita ponerla en el historial:
```powershell
$domainCredential = Get-Credential -UserName 'LCI\Administrator'
Add-Computer `
-DomainName 'lci.lasalle.mx' `
-Credential $domainCredential `
-Restart
```
Después del reinicio:
```powershell
(Get-CimInstance Win32_ComputerSystem) |
Select-Object Name, Domain, PartOfDomain
```
## 3. Habilitar acceso remoto
El grupo de dominio `LCI\SG-Laboratorio-Usuarios-RDP` es el único principal de
usuarios SGU agregado al grupo local **Remote Desktop Users**. El broker agrega
automáticamente a ese grupo cada cuenta que sincroniza; no concede permisos de
administrador.
Copiar `scripts\Enable-LabRemoteAccess.ps1` al equipo y ejecutar. El bypass se
limita a este proceso y no cambia la directiva persistente del equipo:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\Enable-LabRemoteAccess.ps1 `
-RemoteDesktopPrincipal 'LCI\SG-Laboratorio-Usuarios-RDP' `
-EnableAdministrativeFirewallGroups
```
El script realiza de forma idempotente lo siguiente:
- habilita RDP y conserva Network Level Authentication;
- habilita las reglas RDP solo para el perfil Domain;
- autoriza el grupo SGU dedicado;
- habilita Windows PowerShell Remoting/WinRM;
- opcionalmente habilita administración remota de servicios, eventos y WMI,
también limitada al perfil Domain.
PowerShell Direct de Hyper-V no requiere abrir puertos en la VM y sigue siendo
la opción preferida para recuperación administrativa.
## 4. Instalar el Credential Provider
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\Install-CredentialProvider.ps1 `
-PublishPath C:\SGUDeploy\credential-provider `
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
-TimeoutSeconds 20
```
Los binarios se guardan en un subdirectorio `versions` identificado por su
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.
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.
## 5. Verificación
```powershell
Get-Service TermService, WinRM
Get-LocalGroupMember -Group 'Remote Desktop Users'
Test-WSMan localhost
Test-NetConnection sgu-auth.lci.lasalle.mx -Port 8443
```
Desde otro equipo administrador:
```powershell
Test-WSMan NOMBRE-DEL-EQUIPO
Enter-PSSession -ComputerName NOMBRE-DEL-EQUIPO -Credential LCI\Administrator
mstsc.exe /v:NOMBRE-DEL-EQUIPO
```
El mensaje “the user has not been granted the requested logon type” con evento
4625, `LogonType=10` y estado `0xC000015B` indica autorización RDP, no una
contraseña incorrecta. Comprobar que la cuenta pertenece a
`SG-Laboratorio-Usuarios-RDP` y que ese grupo aparece en **Remote Desktop Users**.
Las directivas de grupo de dominio prevalecen sobre la política local.
Microsoft documenta este derecho en:
<https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-userrights#allowlogonthroughremotedesktop>
y PowerShell Remoting en:
<https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/enable-psremoting>.
+33 -1
View File
@@ -22,6 +22,9 @@ param(
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx', [string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios = 'LCI',
[string]$UpnSuffix = 'lci.lasalle.mx', [string]$UpnSuffix = 'lci.lasalle.mx',
[string]$RemoteDesktopGroupDn = '',
[ValidateRange(10, 60)]
[int]$NtlmTimeoutSeconds = 15,
[switch]$CreateMissingOus, [switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab [switch]$DisableCertificateRevocationCheckForLab
) )
@@ -96,6 +99,15 @@ if ($CreateMissingOus) {
} }
} }
if ($RemoteDesktopGroupDn) {
Import-Module ActiveDirectory -ErrorAction Stop
$remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop
if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or
-not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
throw 'RemoteDesktopGroupDn must identify a security group beneath BaseDn.'
}
}
foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.json')) { foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.json')) {
if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) { if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
throw "PublishPath is missing $file." throw "PublishPath is missing $file."
@@ -124,7 +136,7 @@ $productionSettings = @{
Ntlm = @{ Ntlm = @{
Endpoint = $NtlmEndpoint Endpoint = $NtlmEndpoint
Domain = '' Domain = ''
TimeoutSeconds = 15 TimeoutSeconds = $NtlmTimeoutSeconds
MaxRedirects = 5 MaxRedirects = 5
AdministrativeProfilePath = $AdministrativeProfilePath AdministrativeProfilePath = $AdministrativeProfilePath
MenuProfilePath = $MenuProfilePath MenuProfilePath = $MenuProfilePath
@@ -139,6 +151,7 @@ $productionSettings = @{
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn" ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn" StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn" AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
CreateMissingOus = [bool]$CreateMissingOus CreateMissingOus = [bool]$CreateMissingOus
} }
} }
@@ -147,6 +160,13 @@ $productionSettings = @{
if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker Windows service')) { if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker Windows service')) {
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
Stop-Service -Name $serviceName -Force Stop-Service -Name $serviceName -Force
(Get-Service -Name $serviceName).WaitForStatus(
[System.ServiceProcess.ServiceControllerStatus]::Stopped,
[TimeSpan]::FromSeconds(15))
# A self-contained .NET process can briefly retain mapped runtime files
# after SCM reports Stopped. Give Windows time to release those handles.
Start-Sleep -Seconds 2
} }
New-Item -ItemType Directory -Path $installPath -Force | Out-Null New-Item -ItemType Directory -Path $installPath -Force | Out-Null
@@ -165,6 +185,18 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
-BinaryPathName ('"{0}"' -f (Join-Path $installPath 'SGU.AuthBroker.exe')) ` -BinaryPathName ('"{0}"' -f (Join-Path $installPath 'SGU.AuthBroker.exe')) `
-StartupType Automatic -StartupType Automatic
} }
else {
Set-Service -Name $serviceName -StartupType Automatic
}
& sc.exe failure $serviceName 'reset=' '86400' 'actions=' 'restart/5000/restart/15000/restart/60000' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Could not configure automatic recovery for SGUAuthBroker.'
}
& sc.exe failureflag $serviceName '1' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Could not enable recovery for non-crash SGUAuthBroker failures.'
}
if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) { if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' ` New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
+90
View File
@@ -0,0 +1,90 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[switch]$EnableAdministrativeFirewallGroups
)
$ErrorActionPreference = 'Stop'
$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.'
}
$computer = Get-CimInstance Win32_ComputerSystem
if (-not $computer.PartOfDomain) {
throw 'Join the computer to the domain before enabling domain-scoped remote access.'
}
$remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555')
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1]
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesktopPrincipal access")) {
Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
-Name fDenyTSConnections -Type DWord -Value 0
Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' `
-Name UserAuthentication -Type DWord -Value 1
Set-Service -Name TermService -StartupType Automatic
Start-Service -Name TermService
Get-NetFirewallRule -Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' `
-ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$existingMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue)
if ($existingMembers.Name -notcontains $RemoteDesktopPrincipal) {
Add-LocalGroupMember -Group $remoteDesktopUsersGroup -Member $RemoteDesktopPrincipal
}
# Use Windows PowerShell so both the inbox and compatible remoting endpoints
# are configured even when this helper is launched from PowerShell 7.
$enableRemoting = Start-Process -FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
-ArgumentList @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command',
'Enable-PSRemoting -Force -SkipNetworkProfileCheck') `
-Wait -PassThru -WindowStyle Hidden
if ($enableRemoting.ExitCode -ne 0) {
throw "Enable-PSRemoting returned $($enableRemoting.ExitCode)."
}
Set-Service -Name WinRM -StartupType Automatic
Start-Service -Name WinRM
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' `
-ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP-PUBLIC' -ErrorAction SilentlyContinue |
Disable-NetFirewallRule
if ($EnableAdministrativeFirewallGroups) {
$administrativeRules = @(
'RemoteEventLogSvc-In-TCP',
'RemoteEventLogSvc-NP-In-TCP',
'RemoteEventLogSvc-RPCSS-In-TCP',
'RemoteSvcAdmin-In-TCP',
'RemoteSvcAdmin-NP-In-TCP',
'RemoteSvcAdmin-RPCSS-In-TCP',
'WMI-RPCSS-In-TCP',
'WMI-WINMGMT-In-TCP',
'WMI-ASYNC-In-TCP'
)
Get-NetFirewallRule -Name $administrativeRules -ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
}
}
$rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue)
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain
RemoteDesktopEnabled = (Get-ItemPropertyValue `
'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
-Name fDenyTSConnections) -eq 0
NetworkLevelAuthentication = (Get-ItemPropertyValue `
'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' `
-Name UserAuthentication) -eq 1
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
PrincipalIsAuthorized = $rdpMembers.Name -contains $RemoteDesktopPrincipal
TermService = (Get-Service TermService).Status
WinRM = (Get-Service WinRM).Status
FirewallProfile = 'Domain'
}
+31 -4
View File
@@ -17,8 +17,8 @@ param(
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios = 'LCI',
[ValidateRange(2, 30)] [ValidateRange(2, 60)]
[int]$TimeoutSeconds = 6, [int]$TimeoutSeconds = 20,
[switch]$InstallDotNetRuntime, [switch]$InstallDotNetRuntime,
@@ -27,7 +27,7 @@ param(
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}' $providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$installPath = Join-Path $env:ProgramFiles 'SGU\CredentialProvider' $installRoot = Join-Path $env:ProgramFiles 'SGU\CredentialProvider'
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json' $settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId" $providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32" $classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
@@ -100,6 +100,30 @@ foreach ($file in $requiredFiles) {
} }
} }
$resolvedPublishPath = (Resolve-Path -LiteralPath $PublishPath).Path.TrimEnd('\')
$packageManifest = Get-ChildItem -LiteralPath $resolvedPublishPath -Recurse -File |
Sort-Object FullName |
ForEach-Object {
$relativePath = $_.FullName.Substring($resolvedPublishPath.Length).TrimStart('\')
'{0}={1}' -f $relativePath, (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
}
$manifestBytes = [Text.Encoding]::UTF8.GetBytes(($packageManifest -join "`n"))
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$packageHash = -join ($sha256.ComputeHash($manifestBytes) | ForEach-Object { $_.ToString('x2') })
}
finally {
$sha256.Dispose()
}
$versionId = $packageHash.Substring(0, 16)
$installPath = Join-Path $installRoot "versions\$versionId"
$completeMarker = Join-Path $installPath '.complete'
if ((Test-Path -LiteralPath $installPath) -and -not (Test-Path -LiteralPath $completeMarker)) {
$installPath = '{0}-{1}' -f $installPath, ([Guid]::NewGuid().ToString('N').Substring(0, 8))
$completeMarker = Join-Path $installPath '.complete'
}
$clientThumbprint = $ClientCertificateThumbprint -replace ' ', '' $clientThumbprint = $ClientCertificateThumbprint -replace ' ', ''
$serverThumbprint = $ServerCertificateThumbprint -replace ' ', '' $serverThumbprint = $ServerCertificateThumbprint -replace ' ', ''
if ($clientThumbprint.Length -ne 40 -or $serverThumbprint.Length -ne 40) { if ($clientThumbprint.Length -ne 40 -or $serverThumbprint.Length -ne 40) {
@@ -121,8 +145,11 @@ if (-not $serverCertificate) {
} }
if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credential Provider')) { if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credential Provider')) {
if (-not (Test-Path -LiteralPath $completeMarker)) {
New-Item -ItemType Directory -Path $installPath -Force | Out-Null New-Item -ItemType Directory -Path $installPath -Force | Out-Null
Copy-Item -Path (Join-Path $PublishPath '*') -Destination $installPath -Recurse -Force Copy-Item -Path (Join-Path $resolvedPublishPath '*') -Destination $installPath -Recurse -Force
[IO.File]::WriteAllText($completeMarker, $packageHash, [Text.UTF8Encoding]::new($false))
}
New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
$settingsJson = @{ $settingsJson = @{
@@ -0,0 +1,105 @@
using System.Text;
using System.Text.RegularExpressions;
namespace SGU.AuthBroker.Core.Profiles;
public static partial class SguHtmlDecoder
{
private const int MetaScanBytes = 8 * 1024;
static SguHtmlDecoder()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public static string Decode(ReadOnlySpan<byte> bytes, string? declaredCharset = null)
{
if (bytes.IsEmpty)
{
return string.Empty;
}
foreach (string charset in GetCandidateCharsets(bytes, declaredCharset))
{
try
{
Encoding baseEncoding = Encoding.GetEncoding(charset);
Encoding strictEncoding = Encoding.GetEncoding(
baseEncoding.CodePage,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
return strictEncoding.GetString(bytes);
}
catch (DecoderFallbackException)
{
// Some SGU responses declare UTF-8 but contain Windows-1252 bytes.
}
catch (ArgumentException)
{
// Ignore unknown declarations and continue with content detection.
}
}
return Encoding.GetEncoding(1252).GetString(bytes);
}
private static IEnumerable<string> GetCandidateCharsets(
ReadOnlySpan<byte> bytes,
string? declaredCharset)
{
List<string> candidates = [];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
AddCandidate(candidates, seen, DetectBom(bytes));
AddCandidate(candidates, seen, declaredCharset);
int scanLength = Math.Min(bytes.Length, MetaScanBytes);
string header = Encoding.Latin1.GetString(bytes[..scanLength]);
Match metaCharset = CharsetPattern().Match(header);
if (metaCharset.Success)
{
AddCandidate(candidates, seen, metaCharset.Groups["charset"].Value);
}
AddCandidate(candidates, seen, "utf-8");
AddCandidate(candidates, seen, "windows-1252");
return candidates;
}
private static void AddCandidate(
List<string> candidates,
HashSet<string> seen,
string? charset)
{
string? normalized = charset?.Trim().Trim('"', '\'');
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
{
candidates.Add(normalized);
}
}
private static string? DetectBom(ReadOnlySpan<byte> bytes)
{
if (bytes.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }))
{
return "utf-8";
}
if (bytes.StartsWith(new byte[] { 0xFF, 0xFE }))
{
return "utf-16";
}
if (bytes.StartsWith(new byte[] { 0xFE, 0xFF }))
{
return "unicodeFFFE";
}
return null;
}
[GeneratedRegex(
"charset\\s*=\\s*[\\\"']?\\s*(?<charset>[A-Za-z0-9._-]+)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex CharsetPattern();
}
@@ -27,11 +27,11 @@ public static class SguProfileParser
InstitutionalProfile profile = new( InstitutionalProfile profile = new(
EmployeeNumber: employeeNumber, EmployeeNumber: employeeNumber,
DisplayName: Limit(displayName, 256), DisplayName: NormalizeTitle(displayName, 256),
Email: NormalizeEmail(ExtractSpanText(html, EmailId)), Email: NormalizeEmail(ExtractSpanText(html, EmailId)),
EmployeeType: Limit(ExtractSpanText(html, EmployeeTypeId), 256), EmployeeType: NormalizeSentence(ExtractSpanText(html, EmployeeTypeId), 256),
JobTitle: Limit(ExtractSpanText(html, JobTitleId), 64), JobTitle: NormalizeTitle(ExtractSpanText(html, JobTitleId), 64),
Department: Limit(ExtractSpanText(html, DepartmentId), 64)); Department: NormalizeTitle(ExtractSpanText(html, DepartmentId), 64));
return profile.HasValues ? profile : null; return profile.HasValues ? profile : null;
} }
@@ -40,7 +40,7 @@ public static class SguProfileParser
ArgumentNullException.ThrowIfNull(html); ArgumentNullException.ThrowIfNull(html);
InstitutionalProfile profile = new( InstitutionalProfile profile = new(
DisplayName: Limit(ExtractSpanText(html, MenuNameId), 256)); DisplayName: NormalizeTitle(ExtractSpanText(html, MenuNameId), 256));
return profile.HasValues ? profile : null; return profile.HasValues ? profile : null;
} }
@@ -167,13 +167,27 @@ public static class SguProfileParser
return null; return null;
} }
return address.Address; return address.Address.ToLowerInvariant();
}
private static string? NormalizeTitle(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
}
private static string? NormalizeSentence(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToSentenceCase(candidate);
} }
private static string? Limit(string? value, int maximumLength) private static string? Limit(string? value, int maximumLength)
{ {
string? candidate = value?.Trim(); string? candidate = value?.Trim();
return string.IsNullOrEmpty(candidate) || candidate.Length > maximumLength return string.IsNullOrEmpty(candidate) ||
candidate.Length > maximumLength ||
candidate.Contains('\uFFFD')
? null ? null
: candidate; : candidate;
} }
@@ -0,0 +1,78 @@
using System.Globalization;
using System.Text;
namespace SGU.AuthBroker.Core.Profiles;
public static class SpanishTextNormalizer
{
private static readonly CultureInfo SpanishCulture = CultureInfo.GetCultureInfo("es-MX");
private static readonly HashSet<string> LowercaseParticles = new(
[
"a", "al", "da", "das", "de", "del", "do", "dos", "e", "el",
"la", "las", "los", "o", "u", "van", "von", "y"
],
StringComparer.OrdinalIgnoreCase);
public static string ToTitleCase(string value)
{
ArgumentNullException.ThrowIfNull(value);
string[] words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
for (int index = 0; index < words.Length; index++)
{
string lowercase = words[index].ToLower(SpanishCulture);
string comparisonToken = lowercase.Trim('(', ')', '[', ']', '{', '}', ',', '.', ';', ':');
words[index] = index > 0 && LowercaseParticles.Contains(comparisonToken)
? lowercase
: CapitalizeCompound(lowercase);
}
return string.Join(' ', words);
}
public static string ToSentenceCase(string value)
{
ArgumentNullException.ThrowIfNull(value);
string lowercase = value.ToLower(SpanishCulture);
StringBuilder result = new(lowercase);
for (int index = 0; index < result.Length; index++)
{
if (!char.IsLetter(result[index]))
{
continue;
}
result[index] = char.ToUpper(result[index], SpanishCulture);
break;
}
return result.ToString();
}
private static string CapitalizeCompound(string value)
{
StringBuilder result = new(value.Length);
bool capitalizeNextLetter = true;
foreach (char character in value)
{
if (capitalizeNextLetter && char.IsLetter(character))
{
result.Append(char.ToUpper(character, SpanishCulture));
capitalizeNextLetter = false;
}
else
{
result.Append(character);
}
if (character is '-' or '\'' or '\u2019')
{
capitalizeNextLetter = true;
}
}
return result.ToString();
}
}
@@ -78,6 +78,13 @@ public sealed class BrokerOptions
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn."); throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
} }
} }
if (!string.IsNullOrWhiteSpace(Directory.RemoteDesktopGroupDn) &&
(!Directory.RemoteDesktopGroupDn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase) ||
!Directory.RemoteDesktopGroupDn.EndsWith($",{Directory.BaseDn}", StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException("RemoteDesktopGroupDn must identify a group beneath BaseDn.");
}
} }
private static bool IsCertificateThumbprint(string value) private static bool IsCertificateThumbprint(string value)
@@ -130,6 +137,8 @@ public sealed class ActiveDirectoryOptions
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx"; public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
public bool CreateMissingOus { get; init; } public bool CreateMissingOus { get; init; }
public string GetOuDn(InstitutionalRole role) => role switch public string GetOuDn(InstitutionalRole role) => role switch
@@ -101,6 +101,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
user.CommitChanges(); user.CommitChanges();
TryApplyProfile(user, identity, profile); TryApplyProfile(user, identity, profile);
TryEnsureRemoteDesktopGroupMembership(user);
return new DirectorySyncResult( return new DirectorySyncResult(
options.DomainNetbios, options.DomainNetbios,
@@ -163,6 +164,37 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
} }
} }
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user)
{
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
{
return;
}
try
{
user.RefreshCache(["distinguishedName"]);
string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value);
if (string.IsNullOrWhiteSpace(userDn))
{
return;
}
using DirectoryEntry group = Bind(options.RemoteDesktopGroupDn);
_ = group.NativeObject;
if (!group.Properties["member"].Contains(userDn))
{
group.Properties["member"].Add(userDn);
group.CommitChanges();
}
}
catch
{
// Remote access is lab policy and must not invalidate a completed
// password synchronization if the optional group is unavailable.
}
}
private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root) private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
{ {
try try
@@ -1,5 +1,4 @@
using System.Net; using System.Net;
using System.Text;
using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles; using SGU.AuthBroker.Core.Profiles;
@@ -202,20 +201,9 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
buffer.Write(chunk, 0, read); buffer.Write(chunk, 0, read);
} }
string? charset = content.Headers.ContentType?.CharSet?.Trim('"', '\''); return SguHtmlDecoder.Decode(
Encoding encoding; buffer.GetBuffer().AsSpan(0, checked((int)buffer.Length)),
try content.Headers.ContentType?.CharSet);
{
encoding = string.IsNullOrWhiteSpace(charset)
? Encoding.UTF8
: Encoding.GetEncoding(charset);
}
catch (ArgumentException)
{
encoding = Encoding.UTF8;
}
return encoding.GetString(buffer.GetBuffer(), 0, checked((int)buffer.Length));
} }
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) => private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
+1
View File
@@ -46,6 +46,7 @@
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx", "ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx", "StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx", "AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"RemoteDesktopGroupDn": "",
"CreateMissingOus": false "CreateMissingOus": false
} }
} }
@@ -3,6 +3,7 @@ namespace SGU.CredentialProvider;
internal static class ControlKeys internal static class ControlKeys
{ {
public const string ProviderLabel = "ProviderLabel"; public const string ProviderLabel = "ProviderLabel";
public const string ProviderLogo = "ProviderLogo";
public const string InformationLabel = "InformationLabel"; public const string InformationLabel = "InformationLabel";
public const string UserName = "UserName"; public const string UserName = "UserName";
public const string Password = "Password"; public const string Password = "Password";
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
public string DomainNetbios { get; init; } = "LCI"; public string DomainNetbios { get; init; } = "LCI";
public int TimeoutSeconds { get; init; } = 6; public int TimeoutSeconds { get; init; } = 20;
public string ClientCertificateThumbprint { get; init; } = string.Empty; public string ClientCertificateThumbprint { get; init; } = string.Empty;
@@ -54,7 +54,7 @@ internal sealed class ProviderSettings
throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate."); throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
} }
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 30) if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 60)
{ {
throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid."); throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
} }
@@ -0,0 +1,33 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
namespace SGU.CredentialProvider;
internal static class ProviderTileIcon
{
public const int Size = 72;
public static Bitmap Create()
{
Bitmap bitmap = new(Size, Size, PixelFormat.Format32bppArgb);
using Graphics graphics = Graphics.FromImage(bitmap);
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
graphics.Clear(Color.FromArgb(0, 83, 155));
using Pen key = new(Color.White, 5.5f)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
graphics.DrawEllipse(key, 14, 14, 25, 25);
graphics.DrawLine(key, 35, 35, 57, 57);
graphics.DrawLine(key, 47, 47, 55, 39);
graphics.DrawLine(key, 53, 53, 61, 45);
return bitmap;
}
}
@@ -15,6 +15,10 @@ public sealed class SguCredentialProvider : CredentialProviderBase
public override IEnumerable<ControlBase> GetControls(UsageScenario cpus) public override IEnumerable<ControlBase> GetControls(UsageScenario cpus)
{ {
yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU"); yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU");
yield return new CredentialProviderLogoControl(
ControlKeys.ProviderLogo,
"Acceso institucional SGU",
ProviderTileIcon.Create());
yield return new SmallLabelControl( yield return new SmallLabelControl(
ControlKeys.InformationLabel, ControlKeys.InformationLabel,
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña."); "Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.");
@@ -1,7 +1,7 @@
{ {
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate", "BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
"DomainNetbios": "LCI", "DomainNetbios": "LCI",
"TimeoutSeconds": 6, "TimeoutSeconds": 20,
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000", "ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000" "ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
} }
@@ -0,0 +1,44 @@
using System.Text;
using SGU.AuthBroker.Core.Profiles;
using Xunit;
namespace SGU.AuthBroker.Core.Tests;
public sealed class SguHtmlDecoderTests
{
[Fact]
public void DecodesWindows1252WhenSguOmitsACharset()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
byte[] bytes = Encoding.GetEncoding(1252).GetBytes(
"<span>ANALISTA DE INMERSIÓN — FACULTAD DE INGENIERÍA</span>");
string decoded = SguHtmlDecoder.Decode(bytes);
Assert.Contains("INMERSIÓN", decoded, StringComparison.Ordinal);
Assert.Contains("INGENIERÍA", decoded, StringComparison.Ordinal);
Assert.Contains('—', decoded);
Assert.DoesNotContain('\uFFFD', decoded);
}
[Fact]
public void FallsBackWhenTheDeclaredUtf8CharsetIsIncorrect()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
byte[] bytes = Encoding.GetEncoding(1252).GetBytes("JESÚS GONZÁLEZ");
string decoded = SguHtmlDecoder.Decode(bytes, "utf-8");
Assert.Equal("JESÚS GONZÁLEZ", decoded);
}
[Fact]
public void HonorsValidUtf8WithoutADeclaration()
{
byte[] bytes = Encoding.UTF8.GetBytes("María del Carmen 🔐");
string decoded = SguHtmlDecoder.Decode(bytes);
Assert.Equal("María del Carmen 🔐", decoded);
}
}
@@ -38,11 +38,11 @@ public sealed class SguProfileParserTests
Assert.NotNull(profile); Assert.NotNull(profile);
Assert.Equal("017045", profile.EmployeeNumber); Assert.Equal("017045", profile.EmployeeNumber);
Assert.Equal("JESÚS ALEJANDRO ROSALES GONZÁLEZ", profile.DisplayName); Assert.Equal("Jesús Alejandro Rosales González", profile.DisplayName);
Assert.Equal("persona@lasalle.mx", profile.Email); Assert.Equal("persona@lasalle.mx", profile.Email);
Assert.Equal("SINDICALIZADO QUINCENAL (ACTIVO)", profile.EmployeeType); Assert.Equal("Sindicalizado quincenal (activo)", profile.EmployeeType);
Assert.Equal("ANALISTA DE PROYECTOS", profile.JobTitle); Assert.Equal("Analista de Proyectos", profile.JobTitle);
Assert.Equal("FACULTAD DE INGENIERÍA", profile.Department); Assert.Equal("Facultad de Ingeniería", profile.Department);
} }
[Fact] [Fact]
@@ -68,12 +68,44 @@ public sealed class SguProfileParserTests
InstitutionalProfile? profile = SguProfileParser.ParseMenu(html); InstitutionalProfile? profile = SguProfileParser.ParseMenu(html);
Assert.NotNull(profile); Assert.NotNull(profile);
Assert.Equal("MARÍA & JOSÉ", profile.DisplayName); Assert.Equal("María & José", profile.DisplayName);
Assert.Null(profile.Email); Assert.Null(profile.Email);
Assert.Null(profile.JobTitle); Assert.Null(profile.JobTitle);
Assert.Null(profile.Department); Assert.Null(profile.Department);
} }
[Theory]
[InlineData("MARÍA DEL CARMEN", "María del Carmen")]
[InlineData("MIGUEL DE CERVANTES", "Miguel de Cervantes")]
[InlineData("CARLOS DE LA FUENTE", "Carlos de la Fuente")]
[InlineData("MARÍA-JOSÉ O'CONNOR", "María-José O'Connor")]
public void PreservesSpanishNameParticlesAndAccents(string source, string expected)
{
const string marker = "ctl00_lblNombreUsuario";
InstitutionalProfile? profile = SguProfileParser.ParseMenu(
$"<span id=\"{marker}\">{source}</span>");
Assert.NotNull(profile);
Assert.Equal(expected, profile.DisplayName);
}
[Fact]
public void RejectsReplacementCharactersInsteadOfWritingCorruptMetadata()
{
const string html = """
<span id="ctl00_contenedor_decEncabezado_lblNombre">017045 - JESS ROSALES</span>
<span id="ctl00_contenedor_decEncabezado_lblPuesto">ANALISTA DE INMERSIN</span>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrative(html, "017045");
Assert.NotNull(profile);
Assert.Null(profile.DisplayName);
Assert.Null(profile.JobTitle);
Assert.Equal("017045", profile.EmployeeNumber);
}
[Fact] [Fact]
public void MissingKnownFieldsProducesNoProfile() public void MissingKnownFieldsProducesNoProfile()
{ {
@@ -10,9 +10,11 @@ namespace SGU.CredentialProvider.SmokeProbe;
internal static class Program internal static class Program
{ {
private static readonly Guid ProviderClassId = new("D789CFD8-5AD4-489F-9B83-7EB5D9D09335"); private static readonly Guid ProviderClassId = new("D789CFD8-5AD4-489F-9B83-7EB5D9D09335");
private static readonly Guid ProviderLogoFieldType = new("2D837775-F6CD-464E-A745-482FD0B47493");
private static readonly string[] ExpectedLabels = private static readonly string[] ExpectedLabels =
[ [
"Acceso institucional SGU",
"Acceso institucional SGU", "Acceso institucional SGU",
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.", "Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.",
"Clave institucional", "Clave institucional",
@@ -52,6 +54,7 @@ internal static class Program
ThrowIfFailed(provider.GetFieldDescriptorCount(out uint fieldCount), "GetFieldDescriptorCount"); ThrowIfFailed(provider.GetFieldDescriptorCount(out uint fieldCount), "GetFieldDescriptorCount");
List<string> labels = []; List<string> labels = [];
bool providerLogoPresent = false;
for (uint index = 0; index < fieldCount; index++) for (uint index = 0; index < fieldCount; index++)
{ {
ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt"); ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt");
@@ -64,6 +67,8 @@ internal static class Program
{ {
FieldDescriptor descriptor = Marshal.PtrToStructure<FieldDescriptor>(descriptorPointer); FieldDescriptor descriptor = Marshal.PtrToStructure<FieldDescriptor>(descriptorPointer);
labels.Add(descriptor.Label ?? string.Empty); labels.Add(descriptor.Label ?? string.Empty);
providerLogoPresent |= descriptor.FieldType == FieldType.TileImage &&
descriptor.FieldTypeGuid == ProviderLogoFieldType;
} }
finally finally
{ {
@@ -81,6 +86,7 @@ internal static class Program
bool passed = fieldCount == ExpectedLabels.Length && bool passed = fieldCount == ExpectedLabels.Length &&
credentialCount == 1 && credentialCount == 1 &&
credential != IntPtr.Zero && credential != IntPtr.Zero &&
providerLogoPresent &&
labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal); labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal);
if (mode != "enumeration" && passed) if (mode != "enumeration" && passed)
@@ -96,6 +102,7 @@ internal static class Program
usageScenario = "Logon", usageScenario = "Logon",
fieldCount, fieldCount,
labels, labels,
providerLogoPresent,
credentialCount, credentialCount,
defaultIndex, defaultIndex,
autoLogon = autoLogon != 0 autoLogon = autoLogon != 0
@@ -7,6 +7,12 @@ namespace SGU.CredentialProvider.Tests;
public sealed class BrokerClientTests public sealed class BrokerClientTests
{ {
[Fact]
public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker()
{
Assert.Equal(20, new ProviderSettings().TimeoutSeconds);
}
[Fact] [Fact]
public async Task SendsTheOriginalPasswordWithoutDerivation() public async Task SendsTheOriginalPasswordWithoutDerivation()
{ {
@@ -0,0 +1,34 @@
using System.Drawing;
using Lithnet.CredentialProvider;
using Xunit;
namespace SGU.CredentialProvider.Tests;
public sealed class ProviderTileIconTests
{
[Fact]
public void ProviderPublishesASeventyTwoPixelLogoForSignInOptions()
{
SguCredentialProvider provider = new();
CredentialProviderLogoControl logo = Assert.Single(
provider.GetControls(UsageScenario.Logon).OfType<CredentialProviderLogoControl>());
Assert.Equal(ProviderTileIcon.Size, logo.Bitmap.Width);
Assert.Equal(ProviderTileIcon.Size, logo.Bitmap.Height);
Assert.Equal(Color.FromArgb(0, 83, 155).ToArgb(), logo.Bitmap.GetPixel(0, 0).ToArgb());
int lightPixels = 0;
for (int x = 0; x < logo.Bitmap.Width; x++)
{
for (int y = 0; y < logo.Bitmap.Height; y++)
{
if (logo.Bitmap.GetPixel(x, y).GetBrightness() > 0.7f)
{
lightPixels++;
}
}
}
Assert.InRange(lightPixels, 200, 2_000);
}
}