From da0134398595d1e6ea5977785e31fd6b5e80a4e0 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Tue, 1 Sep 2026 10:37:40 -0600 Subject: [PATCH] Improve SGU logon resilience and client UX --- README.md | 9 +- docs/architecture.md | 10 ++ docs/broker-operations.md | 79 ++++++++++++ docs/decisions/0001-no-password-cache.md | 60 +++++++++ docs/lab-runbook.md | 16 +++ docs/security.md | 6 + docs/windows-client-onboarding.md | 117 ++++++++++++++++++ scripts/Deploy-AuthBroker.ps1 | 34 ++++- scripts/Enable-LabRemoteAccess.ps1 | 90 ++++++++++++++ scripts/Install-CredentialProvider.ps1 | 37 +++++- .../Profiles/SguHtmlDecoder.cs | 105 ++++++++++++++++ .../Profiles/SguProfileParser.cs | 28 +++-- .../Profiles/SpanishTextNormalizer.cs | 78 ++++++++++++ src/SGU.AuthBroker/Options/BrokerOptions.cs | 9 ++ .../Services/ActiveDirectorySynchronizer.cs | 32 +++++ .../Services/NtlmCredentialValidator.cs | 18 +-- src/SGU.AuthBroker/appsettings.json | 1 + src/SGU.CredentialProvider/ControlKeys.cs | 1 + .../ProviderSettings.cs | 4 +- .../ProviderTileIcon.cs | 33 +++++ .../SguCredentialProvider.cs | 4 + .../settings.example.json | 2 +- .../SguHtmlDecoderTests.cs | 44 +++++++ .../SguProfileParserTests.cs | 42 ++++++- .../Program.cs | 7 ++ .../BrokerClientTests.cs | 6 + .../ProviderTileIconTests.cs | 34 +++++ 27 files changed, 869 insertions(+), 37 deletions(-) create mode 100644 docs/broker-operations.md create mode 100644 docs/decisions/0001-no-password-cache.md create mode 100644 docs/windows-client-onboarding.md create mode 100644 scripts/Enable-LabRemoteAccess.ps1 create mode 100644 src/SGU.AuthBroker.Core/Profiles/SguHtmlDecoder.cs create mode 100644 src/SGU.AuthBroker.Core/Profiles/SpanishTextNormalizer.cs create mode 100644 src/SGU.CredentialProvider/ProviderTileIcon.cs create mode 100644 tests/SGU.AuthBroker.Core.Tests/SguHtmlDecoderTests.cs create mode 100644 tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs diff --git a/README.md b/README.md index 7406a02..5d61bb1 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/architecture.md b/docs/architecture.md index d695d0b..71021cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/broker-operations.md b/docs/broker-operations.md new file mode 100644 index 0000000..2d057ce --- /dev/null +++ b/docs/broker-operations.md @@ -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. diff --git a/docs/decisions/0001-no-password-cache.md b/docs/decisions/0001-no-password-cache.md new file mode 100644 index 0000000..e9701ea --- /dev/null +++ b/docs/decisions/0001-no-password-cache.md @@ -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: + +- +- +- +- +- + +## 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. diff --git a/docs/lab-runbook.md b/docs/lab-runbook.md index 684973e..77b20d8 100644 --- a/docs/lab-runbook.md +++ b/docs/lab-runbook.md @@ -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. diff --git a/docs/security.md b/docs/security.md index 82290bf..e30a437 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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. diff --git a/docs/windows-client-onboarding.md b/docs/windows-client-onboarding.md new file mode 100644 index 0000000..eb9be41 --- /dev/null +++ b/docs/windows-client-onboarding.md @@ -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: + +y PowerShell Remoting en: +. diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 index cd3cee6..9814b4d 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -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)' ` diff --git a/scripts/Enable-LabRemoteAccess.ps1 b/scripts/Enable-LabRemoteAccess.ps1 new file mode 100644 index 0000000..aedd79a --- /dev/null +++ b/scripts/Enable-LabRemoteAccess.ps1 @@ -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' +} diff --git a/scripts/Install-CredentialProvider.ps1 b/scripts/Install-CredentialProvider.ps1 index 3c2549b..7642b4a 100644 --- a/scripts/Install-CredentialProvider.ps1 +++ b/scripts/Install-CredentialProvider.ps1 @@ -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 = @{ diff --git a/src/SGU.AuthBroker.Core/Profiles/SguHtmlDecoder.cs b/src/SGU.AuthBroker.Core/Profiles/SguHtmlDecoder.cs new file mode 100644 index 0000000..9a5251e --- /dev/null +++ b/src/SGU.AuthBroker.Core/Profiles/SguHtmlDecoder.cs @@ -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 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 GetCandidateCharsets( + ReadOnlySpan bytes, + string? declaredCharset) + { + List candidates = []; + HashSet 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 candidates, + HashSet seen, + string? charset) + { + string? normalized = charset?.Trim().Trim('"', '\''); + if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized)) + { + candidates.Add(normalized); + } + } + + private static string? DetectBom(ReadOnlySpan 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*(?[A-Za-z0-9._-]+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex CharsetPattern(); +} diff --git a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs index 717aa22..020df40 100644 --- a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs +++ b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs @@ -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; } diff --git a/src/SGU.AuthBroker.Core/Profiles/SpanishTextNormalizer.cs b/src/SGU.AuthBroker.Core/Profiles/SpanishTextNormalizer.cs new file mode 100644 index 0000000..af546c3 --- /dev/null +++ b/src/SGU.AuthBroker.Core/Profiles/SpanishTextNormalizer.cs @@ -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 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(); + } +} diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs index 93981b4..e551b8c 100644 --- a/src/SGU.AuthBroker/Options/BrokerOptions.cs +++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs @@ -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 diff --git a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs index cf6b165..4c3114b 100644 --- a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs +++ b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs @@ -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 diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs index 74eee5b..94d9e46 100644 --- a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs +++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs @@ -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 allowedHosts) => diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json index f207497..004a4b1 100644 --- a/src/SGU.AuthBroker/appsettings.json +++ b/src/SGU.AuthBroker/appsettings.json @@ -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 } } diff --git a/src/SGU.CredentialProvider/ControlKeys.cs b/src/SGU.CredentialProvider/ControlKeys.cs index d04d31d..b2ebe77 100644 --- a/src/SGU.CredentialProvider/ControlKeys.cs +++ b/src/SGU.CredentialProvider/ControlKeys.cs @@ -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"; diff --git a/src/SGU.CredentialProvider/ProviderSettings.cs b/src/SGU.CredentialProvider/ProviderSettings.cs index 9b1199c..ed0ea90 100644 --- a/src/SGU.CredentialProvider/ProviderSettings.cs +++ b/src/SGU.CredentialProvider/ProviderSettings.cs @@ -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."); } diff --git a/src/SGU.CredentialProvider/ProviderTileIcon.cs b/src/SGU.CredentialProvider/ProviderTileIcon.cs new file mode 100644 index 0000000..68cc1d0 --- /dev/null +++ b/src/SGU.CredentialProvider/ProviderTileIcon.cs @@ -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; + } +} diff --git a/src/SGU.CredentialProvider/SguCredentialProvider.cs b/src/SGU.CredentialProvider/SguCredentialProvider.cs index 7e7d538..0e5bf71 100644 --- a/src/SGU.CredentialProvider/SguCredentialProvider.cs +++ b/src/SGU.CredentialProvider/SguCredentialProvider.cs @@ -15,6 +15,10 @@ public sealed class SguCredentialProvider : CredentialProviderBase public override IEnumerable 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."); diff --git a/src/SGU.CredentialProvider/settings.example.json b/src/SGU.CredentialProvider/settings.example.json index 84b654f..742ce6d 100644 --- a/src/SGU.CredentialProvider/settings.example.json +++ b/src/SGU.CredentialProvider/settings.example.json @@ -1,7 +1,7 @@ { "BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate", "DomainNetbios": "LCI", - "TimeoutSeconds": 6, + "TimeoutSeconds": 20, "ClientCertificateThumbprint": "0000000000000000000000000000000000000000", "ServerCertificateThumbprint": "0000000000000000000000000000000000000000" } diff --git a/tests/SGU.AuthBroker.Core.Tests/SguHtmlDecoderTests.cs b/tests/SGU.AuthBroker.Core.Tests/SguHtmlDecoderTests.cs new file mode 100644 index 0000000..43b80f5 --- /dev/null +++ b/tests/SGU.AuthBroker.Core.Tests/SguHtmlDecoderTests.cs @@ -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( + "ANALISTA DE INMERSIÓN — FACULTAD DE INGENIERÍA"); + + 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); + } +} diff --git a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs index e79b359..2b17235 100644 --- a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs +++ b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs @@ -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( + $"{source}"); + + Assert.NotNull(profile); + Assert.Equal(expected, profile.DisplayName); + } + + [Fact] + public void RejectsReplacementCharactersInsteadOfWritingCorruptMetadata() + { + const string html = """ + 017045 - JES�S ROSALES + ANALISTA DE INMERSI�N + """; + + 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() { diff --git a/tests/SGU.CredentialProvider.SmokeProbe/Program.cs b/tests/SGU.CredentialProvider.SmokeProbe/Program.cs index fe7708b..9fc246d 100644 --- a/tests/SGU.CredentialProvider.SmokeProbe/Program.cs +++ b/tests/SGU.CredentialProvider.SmokeProbe/Program.cs @@ -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 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(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 diff --git a/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs b/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs index 37f5b4c..868691a 100644 --- a/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs +++ b/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs @@ -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() { diff --git a/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs b/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs new file mode 100644 index 0000000..c5d792c --- /dev/null +++ b/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs @@ -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()); + + 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); + } +}