Improve SGU logon resilience and client UX
This commit is contained in:
@@ -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
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `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
|
||||
dotnet restore .\SGU-CredentialProvider.sln
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
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`,
|
||||
and `Administrativos` are direct child OUs beneath it.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -80,6 +80,7 @@ certificate thumbprint:
|
||||
-PublishPath C:\Deploy\broker `
|
||||
-ServerCertificateSubject sgu-auth.lci.lasalle.mx `
|
||||
-AllowedClientThumbprints CLIENT_CERT_THUMBPRINT `
|
||||
-RemoteDesktopGroupDn 'CN=SG-Laboratorio-Usuarios-RDP,OU=Laboratorio,DC=lci,DC=lasalle,DC=mx' `
|
||||
-CreateMissingOus `
|
||||
-DisableCertificateRevocationCheckForLab
|
||||
```
|
||||
@@ -88,6 +89,8 @@ Verify the service and managed OUs:
|
||||
|
||||
```powershell
|
||||
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'
|
||||
```
|
||||
|
||||
@@ -116,6 +119,7 @@ On Windows 10:
|
||||
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
|
||||
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
|
||||
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
|
||||
-TimeoutSeconds 20 `
|
||||
-InstallDotNetRuntime `
|
||||
-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
|
||||
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
|
||||
|
||||
1. Online valid `DO`, `AL`, and `AD` logons; verify each OU.
|
||||
|
||||
@@ -52,6 +52,12 @@ infrastructure:
|
||||
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.
|
||||
|
||||
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
|
||||
Microsoft's password, PIN, smart-card, or Windows Hello providers.
|
||||
|
||||
|
||||
@@ -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>.
|
||||
@@ -22,6 +22,9 @@ param(
|
||||
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
|
||||
[string]$DomainNetbios = 'LCI',
|
||||
[string]$UpnSuffix = 'lci.lasalle.mx',
|
||||
[string]$RemoteDesktopGroupDn = '',
|
||||
[ValidateRange(10, 60)]
|
||||
[int]$NtlmTimeoutSeconds = 15,
|
||||
[switch]$CreateMissingOus,
|
||||
[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')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
|
||||
throw "PublishPath is missing $file."
|
||||
@@ -124,7 +136,7 @@ $productionSettings = @{
|
||||
Ntlm = @{
|
||||
Endpoint = $NtlmEndpoint
|
||||
Domain = ''
|
||||
TimeoutSeconds = 15
|
||||
TimeoutSeconds = $NtlmTimeoutSeconds
|
||||
MaxRedirects = 5
|
||||
AdministrativeProfilePath = $AdministrativeProfilePath
|
||||
MenuProfilePath = $MenuProfilePath
|
||||
@@ -139,6 +151,7 @@ $productionSettings = @{
|
||||
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
|
||||
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
|
||||
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
|
||||
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
|
||||
CreateMissingOus = [bool]$CreateMissingOus
|
||||
}
|
||||
}
|
||||
@@ -147,6 +160,13 @@ $productionSettings = @{
|
||||
if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker Windows service')) {
|
||||
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
||||
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
|
||||
@@ -165,6 +185,18 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
|
||||
-BinaryPathName ('"{0}"' -f (Join-Path $installPath 'SGU.AuthBroker.exe')) `
|
||||
-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)) {
|
||||
New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -17,8 +17,8 @@ param(
|
||||
|
||||
[string]$DomainNetbios = 'LCI',
|
||||
|
||||
[ValidateRange(2, 30)]
|
||||
[int]$TimeoutSeconds = 6,
|
||||
[ValidateRange(2, 60)]
|
||||
[int]$TimeoutSeconds = 20,
|
||||
|
||||
[switch]$InstallDotNetRuntime,
|
||||
|
||||
@@ -27,7 +27,7 @@ param(
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$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'
|
||||
$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
|
||||
$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 ' ', ''
|
||||
$serverThumbprint = $ServerCertificateThumbprint -replace ' ', ''
|
||||
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')) {
|
||||
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $PublishPath '*') -Destination $installPath -Recurse -Force
|
||||
if (-not (Test-Path -LiteralPath $completeMarker)) {
|
||||
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
|
||||
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
|
||||
$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(
|
||||
EmployeeNumber: employeeNumber,
|
||||
DisplayName: Limit(displayName, 256),
|
||||
DisplayName: NormalizeTitle(displayName, 256),
|
||||
Email: NormalizeEmail(ExtractSpanText(html, EmailId)),
|
||||
EmployeeType: Limit(ExtractSpanText(html, EmployeeTypeId), 256),
|
||||
JobTitle: Limit(ExtractSpanText(html, JobTitleId), 64),
|
||||
Department: Limit(ExtractSpanText(html, DepartmentId), 64));
|
||||
EmployeeType: NormalizeSentence(ExtractSpanText(html, EmployeeTypeId), 256),
|
||||
JobTitle: NormalizeTitle(ExtractSpanText(html, JobTitleId), 64),
|
||||
Department: NormalizeTitle(ExtractSpanText(html, DepartmentId), 64));
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public static class SguProfileParser
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
|
||||
InstitutionalProfile profile = new(
|
||||
DisplayName: Limit(ExtractSpanText(html, MenuNameId), 256));
|
||||
DisplayName: NormalizeTitle(ExtractSpanText(html, MenuNameId), 256));
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
@@ -167,13 +167,27 @@ public static class SguProfileParser
|
||||
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)
|
||||
{
|
||||
string? candidate = value?.Trim();
|
||||
return string.IsNullOrEmpty(candidate) || candidate.Length > maximumLength
|
||||
return string.IsNullOrEmpty(candidate) ||
|
||||
candidate.Length > maximumLength ||
|
||||
candidate.Contains('\uFFFD')
|
||||
? null
|
||||
: 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.");
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -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 RemoteDesktopGroupDn { get; init; } = string.Empty;
|
||||
|
||||
public bool CreateMissingOus { get; init; }
|
||||
|
||||
public string GetOuDn(InstitutionalRole role) => role switch
|
||||
|
||||
@@ -101,6 +101,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
user.CommitChanges();
|
||||
|
||||
TryApplyProfile(user, identity, profile);
|
||||
TryEnsureRemoteDesktopGroupMembership(user);
|
||||
|
||||
return new DirectorySyncResult(
|
||||
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)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
@@ -202,20 +201,9 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
|
||||
string? charset = content.Headers.ContentType?.CharSet?.Trim('"', '\'');
|
||||
Encoding encoding;
|
||||
try
|
||||
{
|
||||
encoding = string.IsNullOrWhiteSpace(charset)
|
||||
? Encoding.UTF8
|
||||
: Encoding.GetEncoding(charset);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
encoding = Encoding.UTF8;
|
||||
}
|
||||
|
||||
return encoding.GetString(buffer.GetBuffer(), 0, checked((int)buffer.Length));
|
||||
return SguHtmlDecoder.Decode(
|
||||
buffer.GetBuffer().AsSpan(0, checked((int)buffer.Length)),
|
||||
content.Headers.ContentType?.CharSet);
|
||||
}
|
||||
|
||||
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ProfessorOuDn": "OU=Docentes,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",
|
||||
"RemoteDesktopGroupDn": "",
|
||||
"CreateMissingOus": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace SGU.CredentialProvider;
|
||||
internal static class ControlKeys
|
||||
{
|
||||
public const string ProviderLabel = "ProviderLabel";
|
||||
public const string ProviderLogo = "ProviderLogo";
|
||||
public const string InformationLabel = "InformationLabel";
|
||||
public const string UserName = "UserName";
|
||||
public const string Password = "Password";
|
||||
|
||||
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
|
||||
|
||||
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;
|
||||
|
||||
@@ -54,7 +54,7 @@ internal sealed class ProviderSettings
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU");
|
||||
yield return new CredentialProviderLogoControl(
|
||||
ControlKeys.ProviderLogo,
|
||||
"Acceso institucional SGU",
|
||||
ProviderTileIcon.Create());
|
||||
yield return new SmallLabelControl(
|
||||
ControlKeys.InformationLabel,
|
||||
"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",
|
||||
"DomainNetbios": "LCI",
|
||||
"TimeoutSeconds": 6,
|
||||
"TimeoutSeconds": 20,
|
||||
"ClientCertificateThumbprint": "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.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("SINDICALIZADO QUINCENAL (ACTIVO)", profile.EmployeeType);
|
||||
Assert.Equal("ANALISTA DE PROYECTOS", profile.JobTitle);
|
||||
Assert.Equal("FACULTAD DE INGENIERÍA", profile.Department);
|
||||
Assert.Equal("Sindicalizado quincenal (activo)", profile.EmployeeType);
|
||||
Assert.Equal("Analista de Proyectos", profile.JobTitle);
|
||||
Assert.Equal("Facultad de Ingeniería", profile.Department);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -68,12 +68,44 @@ public sealed class SguProfileParserTests
|
||||
InstitutionalProfile? profile = SguProfileParser.ParseMenu(html);
|
||||
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("MARÍA & JOSÉ", profile.DisplayName);
|
||||
Assert.Equal("María & José", profile.DisplayName);
|
||||
Assert.Null(profile.Email);
|
||||
Assert.Null(profile.JobTitle);
|
||||
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 - JES�S ROSALES</span>
|
||||
<span id="ctl00_contenedor_decEncabezado_lblPuesto">ANALISTA DE INMERSI�N</span>
|
||||
""";
|
||||
|
||||
InstitutionalProfile? profile = SguProfileParser.ParseAdministrative(html, "017045");
|
||||
|
||||
Assert.NotNull(profile);
|
||||
Assert.Null(profile.DisplayName);
|
||||
Assert.Null(profile.JobTitle);
|
||||
Assert.Equal("017045", profile.EmployeeNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingKnownFieldsProducesNoProfile()
|
||||
{
|
||||
|
||||
@@ -10,9 +10,11 @@ namespace SGU.CredentialProvider.SmokeProbe;
|
||||
internal static class Program
|
||||
{
|
||||
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 =
|
||||
[
|
||||
"Acceso institucional SGU",
|
||||
"Acceso institucional SGU",
|
||||
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.",
|
||||
"Clave institucional",
|
||||
@@ -52,6 +54,7 @@ internal static class Program
|
||||
ThrowIfFailed(provider.GetFieldDescriptorCount(out uint fieldCount), "GetFieldDescriptorCount");
|
||||
|
||||
List<string> labels = [];
|
||||
bool providerLogoPresent = false;
|
||||
for (uint index = 0; index < fieldCount; index++)
|
||||
{
|
||||
ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt");
|
||||
@@ -64,6 +67,8 @@ internal static class Program
|
||||
{
|
||||
FieldDescriptor descriptor = Marshal.PtrToStructure<FieldDescriptor>(descriptorPointer);
|
||||
labels.Add(descriptor.Label ?? string.Empty);
|
||||
providerLogoPresent |= descriptor.FieldType == FieldType.TileImage &&
|
||||
descriptor.FieldTypeGuid == ProviderLogoFieldType;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -81,6 +86,7 @@ internal static class Program
|
||||
bool passed = fieldCount == ExpectedLabels.Length &&
|
||||
credentialCount == 1 &&
|
||||
credential != IntPtr.Zero &&
|
||||
providerLogoPresent &&
|
||||
labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal);
|
||||
|
||||
if (mode != "enumeration" && passed)
|
||||
@@ -96,6 +102,7 @@ internal static class Program
|
||||
usageScenario = "Logon",
|
||||
fieldCount,
|
||||
labels,
|
||||
providerLogoPresent,
|
||||
credentialCount,
|
||||
defaultIndex,
|
||||
autoLogon = autoLogon != 0
|
||||
|
||||
@@ -7,6 +7,12 @@ namespace SGU.CredentialProvider.Tests;
|
||||
|
||||
public sealed class BrokerClientTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker()
|
||||
{
|
||||
Assert.Equal(20, new ProviderSettings().TimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user