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
|
verified. Missing or changed presentation HTML never blocks authentication or
|
||||||
password synchronization.
|
password synchronization.
|
||||||
|
|
||||||
|
Operational documentation:
|
||||||
|
|
||||||
|
- [Broker location, health, timeout, and recovery](docs/broker-operations.md)
|
||||||
|
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
|
||||||
|
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
|
||||||
|
|
||||||
| Prefix | Role | Default OU |
|
| Prefix | Role | Default OU |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
||||||
@@ -60,7 +66,8 @@ Prerequisites are captured in `.vsconfig`; the pinned SDK is .NET `10.0.400`.
|
|||||||
```powershell
|
```powershell
|
||||||
dotnet restore .\SGU-CredentialProvider.sln
|
dotnet restore .\SGU-CredentialProvider.sln
|
||||||
dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore
|
dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore
|
||||||
dotnet test .\SGU-CredentialProvider.sln -c Release --no-build --no-restore
|
dotnet test --project .\tests\SGU.AuthBroker.Core.Tests\SGU.AuthBroker.Core.Tests.csproj -c Release
|
||||||
|
dotnet test --project .\tests\SGU.CredentialProvider.Tests\SGU.CredentialProvider.Tests.csproj -c Release
|
||||||
.\scripts\Publish-Lab.ps1
|
.\scripts\Publish-Lab.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ digits of the requested `AD` identity before any scraped metadata is trusted.
|
|||||||
Missing metadata does not clear existing AD values and never changes the
|
Missing metadata does not clear existing AD values and never changes the
|
||||||
password outcome.
|
password outcome.
|
||||||
|
|
||||||
|
Human-readable SGU values are decoded with BOM/header/meta detection, strict
|
||||||
|
UTF-8 validation, and a Windows-1252 fallback for the legacy portal. Names and
|
||||||
|
titles are normalized with Spanish-aware casing; particles such as `de`, `del`
|
||||||
|
and `de la` remain lowercase and Unicode accents are preserved. Values that
|
||||||
|
still contain the Unicode replacement character are not written to AD.
|
||||||
|
|
||||||
|
When `RemoteDesktopGroupDn` is configured, the broker also adds each successfully
|
||||||
|
synchronized SGU user to that dedicated AD security group. The laboratory
|
||||||
|
Windows client maps the group into its local **Remote Desktop Users** group.
|
||||||
|
|
||||||
The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`,
|
The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`,
|
||||||
and `Administrativos` are direct child OUs beneath it.
|
and `Administrativos` are direct child OUs beneath it.
|
||||||
|
|
||||||
|
|||||||
@@ -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 `
|
-PublishPath C:\Deploy\broker `
|
||||||
-ServerCertificateSubject sgu-auth.lci.lasalle.mx `
|
-ServerCertificateSubject sgu-auth.lci.lasalle.mx `
|
||||||
-AllowedClientThumbprints CLIENT_CERT_THUMBPRINT `
|
-AllowedClientThumbprints CLIENT_CERT_THUMBPRINT `
|
||||||
|
-RemoteDesktopGroupDn 'CN=SG-Laboratorio-Usuarios-RDP,OU=Laboratorio,DC=lci,DC=lasalle,DC=mx' `
|
||||||
-CreateMissingOus `
|
-CreateMissingOus `
|
||||||
-DisableCertificateRevocationCheckForLab
|
-DisableCertificateRevocationCheckForLab
|
||||||
```
|
```
|
||||||
@@ -88,6 +89,8 @@ Verify the service and managed OUs:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-Service SGUAuthBroker
|
Get-Service SGUAuthBroker
|
||||||
|
Get-NetTCPConnection -LocalPort 8443 -State Listen
|
||||||
|
sc.exe qfailure SGUAuthBroker
|
||||||
Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
|
Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -116,6 +119,7 @@ On Windows 10:
|
|||||||
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
|
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
|
||||||
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
|
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
|
||||||
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
|
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
|
||||||
|
-TimeoutSeconds 20 `
|
||||||
-InstallDotNetRuntime `
|
-InstallDotNetRuntime `
|
||||||
-DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe
|
-DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe
|
||||||
```
|
```
|
||||||
@@ -124,6 +128,18 @@ Use Lithnet's `Invoke-CredUI` test utility when available, or lock the VM and
|
|||||||
select **Acceso institucional SGU** under sign-in options. Keep the built-in
|
select **Acceso institucional SGU** under sign-in options. Keep the built-in
|
||||||
Windows password tile visible.
|
Windows password tile visible.
|
||||||
|
|
||||||
|
Before testing through Hyper-V Enhanced Session/RDP, enable the dedicated lab
|
||||||
|
group and Windows PowerShell Remoting:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\Enable-LabRemoteAccess.ps1 `
|
||||||
|
-RemoteDesktopPrincipal 'LCI\SG-Laboratorio-Usuarios-RDP' `
|
||||||
|
-EnableAdministrativeFirewallGroups
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`windows-client-onboarding.md`](windows-client-onboarding.md) for domain
|
||||||
|
join, RDP, WinRM, firewall, and error `0xC000015B` diagnostics.
|
||||||
|
|
||||||
## 6. Required end-to-end cases
|
## 6. Required end-to-end cases
|
||||||
|
|
||||||
1. Online valid `DO`, `AL`, and `AD` logons; verify each OU.
|
1. Online valid `DO`, `AL`, and `AD` logons; verify each OU.
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ infrastructure:
|
|||||||
to Windows. Windows must still validate it against AD or the cached domain
|
to Windows. Windows must still validate it against AD or the cached domain
|
||||||
verifier, so this does not grant access without the last synchronized password.
|
verifier, so this does not grant access without the last synchronized password.
|
||||||
|
|
||||||
|
Redis is deliberately not used for password continuity. A second persistent
|
||||||
|
password verifier duplicates attackable credential material, while a reversible
|
||||||
|
encrypted password becomes a credential vault. AD and Windows cached domain
|
||||||
|
logon already implement the required last-known-password behavior. See
|
||||||
|
[`decisions/0001-no-password-cache.md`](decisions/0001-no-password-cache.md).
|
||||||
|
|
||||||
The installer never registers a Credential Provider filter and never disables
|
The installer never registers a Credential Provider filter and never disables
|
||||||
Microsoft's password, PIN, smart-card, or Windows Hello providers.
|
Microsoft's password, PIN, smart-card, or Windows Hello providers.
|
||||||
|
|
||||||
|
|||||||
@@ -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]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
|
||||||
[string]$DomainNetbios = 'LCI',
|
[string]$DomainNetbios = 'LCI',
|
||||||
[string]$UpnSuffix = 'lci.lasalle.mx',
|
[string]$UpnSuffix = 'lci.lasalle.mx',
|
||||||
|
[string]$RemoteDesktopGroupDn = '',
|
||||||
|
[ValidateRange(10, 60)]
|
||||||
|
[int]$NtlmTimeoutSeconds = 15,
|
||||||
[switch]$CreateMissingOus,
|
[switch]$CreateMissingOus,
|
||||||
[switch]$DisableCertificateRevocationCheckForLab
|
[switch]$DisableCertificateRevocationCheckForLab
|
||||||
)
|
)
|
||||||
@@ -96,6 +99,15 @@ if ($CreateMissingOus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($RemoteDesktopGroupDn) {
|
||||||
|
Import-Module ActiveDirectory -ErrorAction Stop
|
||||||
|
$remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop
|
||||||
|
if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or
|
||||||
|
-not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw 'RemoteDesktopGroupDn must identify a security group beneath BaseDn.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.json')) {
|
foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.json')) {
|
||||||
if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
|
if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
|
||||||
throw "PublishPath is missing $file."
|
throw "PublishPath is missing $file."
|
||||||
@@ -124,7 +136,7 @@ $productionSettings = @{
|
|||||||
Ntlm = @{
|
Ntlm = @{
|
||||||
Endpoint = $NtlmEndpoint
|
Endpoint = $NtlmEndpoint
|
||||||
Domain = ''
|
Domain = ''
|
||||||
TimeoutSeconds = 15
|
TimeoutSeconds = $NtlmTimeoutSeconds
|
||||||
MaxRedirects = 5
|
MaxRedirects = 5
|
||||||
AdministrativeProfilePath = $AdministrativeProfilePath
|
AdministrativeProfilePath = $AdministrativeProfilePath
|
||||||
MenuProfilePath = $MenuProfilePath
|
MenuProfilePath = $MenuProfilePath
|
||||||
@@ -139,6 +151,7 @@ $productionSettings = @{
|
|||||||
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
|
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
|
||||||
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
|
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
|
||||||
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
|
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
|
||||||
|
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
|
||||||
CreateMissingOus = [bool]$CreateMissingOus
|
CreateMissingOus = [bool]$CreateMissingOus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +160,13 @@ $productionSettings = @{
|
|||||||
if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker Windows service')) {
|
if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker Windows service')) {
|
||||||
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
|
||||||
Stop-Service -Name $serviceName -Force
|
Stop-Service -Name $serviceName -Force
|
||||||
|
(Get-Service -Name $serviceName).WaitForStatus(
|
||||||
|
[System.ServiceProcess.ServiceControllerStatus]::Stopped,
|
||||||
|
[TimeSpan]::FromSeconds(15))
|
||||||
|
|
||||||
|
# A self-contained .NET process can briefly retain mapped runtime files
|
||||||
|
# after SCM reports Stopped. Give Windows time to release those handles.
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
}
|
}
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
|
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
|
||||||
@@ -165,6 +185,18 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
|
|||||||
-BinaryPathName ('"{0}"' -f (Join-Path $installPath 'SGU.AuthBroker.exe')) `
|
-BinaryPathName ('"{0}"' -f (Join-Path $installPath 'SGU.AuthBroker.exe')) `
|
||||||
-StartupType Automatic
|
-StartupType Automatic
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
Set-Service -Name $serviceName -StartupType Automatic
|
||||||
|
}
|
||||||
|
|
||||||
|
& sc.exe failure $serviceName 'reset=' '86400' 'actions=' 'restart/5000/restart/15000/restart/60000' | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw 'Could not configure automatic recovery for SGUAuthBroker.'
|
||||||
|
}
|
||||||
|
& sc.exe failureflag $serviceName '1' | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw 'Could not enable recovery for non-crash SGUAuthBroker failures.'
|
||||||
|
}
|
||||||
|
|
||||||
if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) {
|
if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) {
|
||||||
New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
|
New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
|
||||||
|
|||||||
@@ -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',
|
[string]$DomainNetbios = 'LCI',
|
||||||
|
|
||||||
[ValidateRange(2, 30)]
|
[ValidateRange(2, 60)]
|
||||||
[int]$TimeoutSeconds = 6,
|
[int]$TimeoutSeconds = 20,
|
||||||
|
|
||||||
[switch]$InstallDotNetRuntime,
|
[switch]$InstallDotNetRuntime,
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
|
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
|
||||||
$installPath = Join-Path $env:ProgramFiles 'SGU\CredentialProvider'
|
$installRoot = Join-Path $env:ProgramFiles 'SGU\CredentialProvider'
|
||||||
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
||||||
$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
|
$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
|
||||||
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
|
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
|
||||||
@@ -100,6 +100,30 @@ foreach ($file in $requiredFiles) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$resolvedPublishPath = (Resolve-Path -LiteralPath $PublishPath).Path.TrimEnd('\')
|
||||||
|
$packageManifest = Get-ChildItem -LiteralPath $resolvedPublishPath -Recurse -File |
|
||||||
|
Sort-Object FullName |
|
||||||
|
ForEach-Object {
|
||||||
|
$relativePath = $_.FullName.Substring($resolvedPublishPath.Length).TrimStart('\')
|
||||||
|
'{0}={1}' -f $relativePath, (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
|
||||||
|
}
|
||||||
|
$manifestBytes = [Text.Encoding]::UTF8.GetBytes(($packageManifest -join "`n"))
|
||||||
|
$sha256 = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
$packageHash = -join ($sha256.ComputeHash($manifestBytes) | ForEach-Object { $_.ToString('x2') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$sha256.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionId = $packageHash.Substring(0, 16)
|
||||||
|
$installPath = Join-Path $installRoot "versions\$versionId"
|
||||||
|
$completeMarker = Join-Path $installPath '.complete'
|
||||||
|
if ((Test-Path -LiteralPath $installPath) -and -not (Test-Path -LiteralPath $completeMarker)) {
|
||||||
|
$installPath = '{0}-{1}' -f $installPath, ([Guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||||
|
$completeMarker = Join-Path $installPath '.complete'
|
||||||
|
}
|
||||||
|
|
||||||
$clientThumbprint = $ClientCertificateThumbprint -replace ' ', ''
|
$clientThumbprint = $ClientCertificateThumbprint -replace ' ', ''
|
||||||
$serverThumbprint = $ServerCertificateThumbprint -replace ' ', ''
|
$serverThumbprint = $ServerCertificateThumbprint -replace ' ', ''
|
||||||
if ($clientThumbprint.Length -ne 40 -or $serverThumbprint.Length -ne 40) {
|
if ($clientThumbprint.Length -ne 40 -or $serverThumbprint.Length -ne 40) {
|
||||||
@@ -121,8 +145,11 @@ if (-not $serverCertificate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credential Provider')) {
|
if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credential Provider')) {
|
||||||
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
|
if (-not (Test-Path -LiteralPath $completeMarker)) {
|
||||||
Copy-Item -Path (Join-Path $PublishPath '*') -Destination $installPath -Recurse -Force
|
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
|
New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
|
||||||
$settingsJson = @{
|
$settingsJson = @{
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace SGU.AuthBroker.Core.Profiles;
|
||||||
|
|
||||||
|
public static partial class SguHtmlDecoder
|
||||||
|
{
|
||||||
|
private const int MetaScanBytes = 8 * 1024;
|
||||||
|
|
||||||
|
static SguHtmlDecoder()
|
||||||
|
{
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Decode(ReadOnlySpan<byte> bytes, string? declaredCharset = null)
|
||||||
|
{
|
||||||
|
if (bytes.IsEmpty)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string charset in GetCandidateCharsets(bytes, declaredCharset))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Encoding baseEncoding = Encoding.GetEncoding(charset);
|
||||||
|
Encoding strictEncoding = Encoding.GetEncoding(
|
||||||
|
baseEncoding.CodePage,
|
||||||
|
EncoderFallback.ExceptionFallback,
|
||||||
|
DecoderFallback.ExceptionFallback);
|
||||||
|
return strictEncoding.GetString(bytes);
|
||||||
|
}
|
||||||
|
catch (DecoderFallbackException)
|
||||||
|
{
|
||||||
|
// Some SGU responses declare UTF-8 but contain Windows-1252 bytes.
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// Ignore unknown declarations and continue with content detection.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Encoding.GetEncoding(1252).GetString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetCandidateCharsets(
|
||||||
|
ReadOnlySpan<byte> bytes,
|
||||||
|
string? declaredCharset)
|
||||||
|
{
|
||||||
|
List<string> candidates = [];
|
||||||
|
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
AddCandidate(candidates, seen, DetectBom(bytes));
|
||||||
|
AddCandidate(candidates, seen, declaredCharset);
|
||||||
|
|
||||||
|
int scanLength = Math.Min(bytes.Length, MetaScanBytes);
|
||||||
|
string header = Encoding.Latin1.GetString(bytes[..scanLength]);
|
||||||
|
Match metaCharset = CharsetPattern().Match(header);
|
||||||
|
if (metaCharset.Success)
|
||||||
|
{
|
||||||
|
AddCandidate(candidates, seen, metaCharset.Groups["charset"].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddCandidate(candidates, seen, "utf-8");
|
||||||
|
AddCandidate(candidates, seen, "windows-1252");
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCandidate(
|
||||||
|
List<string> candidates,
|
||||||
|
HashSet<string> seen,
|
||||||
|
string? charset)
|
||||||
|
{
|
||||||
|
string? normalized = charset?.Trim().Trim('"', '\'');
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
|
||||||
|
{
|
||||||
|
candidates.Add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? DetectBom(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }))
|
||||||
|
{
|
||||||
|
return "utf-8";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.StartsWith(new byte[] { 0xFF, 0xFE }))
|
||||||
|
{
|
||||||
|
return "utf-16";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.StartsWith(new byte[] { 0xFE, 0xFF }))
|
||||||
|
{
|
||||||
|
return "unicodeFFFE";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(
|
||||||
|
"charset\\s*=\\s*[\\\"']?\\s*(?<charset>[A-Za-z0-9._-]+)",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex CharsetPattern();
|
||||||
|
}
|
||||||
@@ -27,11 +27,11 @@ public static class SguProfileParser
|
|||||||
|
|
||||||
InstitutionalProfile profile = new(
|
InstitutionalProfile profile = new(
|
||||||
EmployeeNumber: employeeNumber,
|
EmployeeNumber: employeeNumber,
|
||||||
DisplayName: Limit(displayName, 256),
|
DisplayName: NormalizeTitle(displayName, 256),
|
||||||
Email: NormalizeEmail(ExtractSpanText(html, EmailId)),
|
Email: NormalizeEmail(ExtractSpanText(html, EmailId)),
|
||||||
EmployeeType: Limit(ExtractSpanText(html, EmployeeTypeId), 256),
|
EmployeeType: NormalizeSentence(ExtractSpanText(html, EmployeeTypeId), 256),
|
||||||
JobTitle: Limit(ExtractSpanText(html, JobTitleId), 64),
|
JobTitle: NormalizeTitle(ExtractSpanText(html, JobTitleId), 64),
|
||||||
Department: Limit(ExtractSpanText(html, DepartmentId), 64));
|
Department: NormalizeTitle(ExtractSpanText(html, DepartmentId), 64));
|
||||||
return profile.HasValues ? profile : null;
|
return profile.HasValues ? profile : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ public static class SguProfileParser
|
|||||||
ArgumentNullException.ThrowIfNull(html);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
|
|
||||||
InstitutionalProfile profile = new(
|
InstitutionalProfile profile = new(
|
||||||
DisplayName: Limit(ExtractSpanText(html, MenuNameId), 256));
|
DisplayName: NormalizeTitle(ExtractSpanText(html, MenuNameId), 256));
|
||||||
return profile.HasValues ? profile : null;
|
return profile.HasValues ? profile : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,13 +167,27 @@ public static class SguProfileParser
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return address.Address;
|
return address.Address.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeTitle(string? value, int maximumLength)
|
||||||
|
{
|
||||||
|
string? candidate = Limit(value, maximumLength);
|
||||||
|
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeSentence(string? value, int maximumLength)
|
||||||
|
{
|
||||||
|
string? candidate = Limit(value, maximumLength);
|
||||||
|
return candidate is null ? null : SpanishTextNormalizer.ToSentenceCase(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? Limit(string? value, int maximumLength)
|
private static string? Limit(string? value, int maximumLength)
|
||||||
{
|
{
|
||||||
string? candidate = value?.Trim();
|
string? candidate = value?.Trim();
|
||||||
return string.IsNullOrEmpty(candidate) || candidate.Length > maximumLength
|
return string.IsNullOrEmpty(candidate) ||
|
||||||
|
candidate.Length > maximumLength ||
|
||||||
|
candidate.Contains('\uFFFD')
|
||||||
? null
|
? null
|
||||||
: candidate;
|
: candidate;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SGU.AuthBroker.Core.Profiles;
|
||||||
|
|
||||||
|
public static class SpanishTextNormalizer
|
||||||
|
{
|
||||||
|
private static readonly CultureInfo SpanishCulture = CultureInfo.GetCultureInfo("es-MX");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> LowercaseParticles = new(
|
||||||
|
[
|
||||||
|
"a", "al", "da", "das", "de", "del", "do", "dos", "e", "el",
|
||||||
|
"la", "las", "los", "o", "u", "van", "von", "y"
|
||||||
|
],
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static string ToTitleCase(string value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
string[] words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
for (int index = 0; index < words.Length; index++)
|
||||||
|
{
|
||||||
|
string lowercase = words[index].ToLower(SpanishCulture);
|
||||||
|
string comparisonToken = lowercase.Trim('(', ')', '[', ']', '{', '}', ',', '.', ';', ':');
|
||||||
|
words[index] = index > 0 && LowercaseParticles.Contains(comparisonToken)
|
||||||
|
? lowercase
|
||||||
|
: CapitalizeCompound(lowercase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(' ', words);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToSentenceCase(string value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
string lowercase = value.ToLower(SpanishCulture);
|
||||||
|
StringBuilder result = new(lowercase);
|
||||||
|
for (int index = 0; index < result.Length; index++)
|
||||||
|
{
|
||||||
|
if (!char.IsLetter(result[index]))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[index] = char.ToUpper(result[index], SpanishCulture);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CapitalizeCompound(string value)
|
||||||
|
{
|
||||||
|
StringBuilder result = new(value.Length);
|
||||||
|
bool capitalizeNextLetter = true;
|
||||||
|
foreach (char character in value)
|
||||||
|
{
|
||||||
|
if (capitalizeNextLetter && char.IsLetter(character))
|
||||||
|
{
|
||||||
|
result.Append(char.ToUpper(character, SpanishCulture));
|
||||||
|
capitalizeNextLetter = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Append(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character is '-' or '\'' or '\u2019')
|
||||||
|
{
|
||||||
|
capitalizeNextLetter = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,13 @@ public sealed class BrokerOptions
|
|||||||
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
|
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Directory.RemoteDesktopGroupDn) &&
|
||||||
|
(!Directory.RemoteDesktopGroupDn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!Directory.RemoteDesktopGroupDn.EndsWith($",{Directory.BaseDn}", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("RemoteDesktopGroupDn must identify a group beneath BaseDn.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCertificateThumbprint(string value)
|
private static bool IsCertificateThumbprint(string value)
|
||||||
@@ -130,6 +137,8 @@ public sealed class ActiveDirectoryOptions
|
|||||||
|
|
||||||
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||||
|
|
||||||
|
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
|
||||||
|
|
||||||
public bool CreateMissingOus { get; init; }
|
public bool CreateMissingOus { get; init; }
|
||||||
|
|
||||||
public string GetOuDn(InstitutionalRole role) => role switch
|
public string GetOuDn(InstitutionalRole role) => role switch
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
user.CommitChanges();
|
user.CommitChanges();
|
||||||
|
|
||||||
TryApplyProfile(user, identity, profile);
|
TryApplyProfile(user, identity, profile);
|
||||||
|
TryEnsureRemoteDesktopGroupMembership(user);
|
||||||
|
|
||||||
return new DirectorySyncResult(
|
return new DirectorySyncResult(
|
||||||
options.DomainNetbios,
|
options.DomainNetbios,
|
||||||
@@ -163,6 +164,37 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
user.RefreshCache(["distinguishedName"]);
|
||||||
|
string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value);
|
||||||
|
if (string.IsNullOrWhiteSpace(userDn))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using DirectoryEntry group = Bind(options.RemoteDesktopGroupDn);
|
||||||
|
_ = group.NativeObject;
|
||||||
|
if (!group.Properties["member"].Contains(userDn))
|
||||||
|
{
|
||||||
|
group.Properties["member"].Add(userDn);
|
||||||
|
group.CommitChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Remote access is lab policy and must not invalidate a completed
|
||||||
|
// password synchronization if the optional group is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
|
private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
|
||||||
using SGU.AuthBroker.Core.Authentication;
|
using SGU.AuthBroker.Core.Authentication;
|
||||||
using SGU.AuthBroker.Core.Identity;
|
using SGU.AuthBroker.Core.Identity;
|
||||||
using SGU.AuthBroker.Core.Profiles;
|
using SGU.AuthBroker.Core.Profiles;
|
||||||
@@ -202,20 +201,9 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
|||||||
buffer.Write(chunk, 0, read);
|
buffer.Write(chunk, 0, read);
|
||||||
}
|
}
|
||||||
|
|
||||||
string? charset = content.Headers.ContentType?.CharSet?.Trim('"', '\'');
|
return SguHtmlDecoder.Decode(
|
||||||
Encoding encoding;
|
buffer.GetBuffer().AsSpan(0, checked((int)buffer.Length)),
|
||||||
try
|
content.Headers.ContentType?.CharSet);
|
||||||
{
|
|
||||||
encoding = string.IsNullOrWhiteSpace(charset)
|
|
||||||
? Encoding.UTF8
|
|
||||||
: Encoding.GetEncoding(charset);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
encoding = Encoding.UTF8;
|
|
||||||
}
|
|
||||||
|
|
||||||
return encoding.GetString(buffer.GetBuffer(), 0, checked((int)buffer.Length));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
|
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
|
"RemoteDesktopGroupDn": "",
|
||||||
"CreateMissingOus": false
|
"CreateMissingOus": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace SGU.CredentialProvider;
|
|||||||
internal static class ControlKeys
|
internal static class ControlKeys
|
||||||
{
|
{
|
||||||
public const string ProviderLabel = "ProviderLabel";
|
public const string ProviderLabel = "ProviderLabel";
|
||||||
|
public const string ProviderLogo = "ProviderLogo";
|
||||||
public const string InformationLabel = "InformationLabel";
|
public const string InformationLabel = "InformationLabel";
|
||||||
public const string UserName = "UserName";
|
public const string UserName = "UserName";
|
||||||
public const string Password = "Password";
|
public const string Password = "Password";
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
|
|||||||
|
|
||||||
public string DomainNetbios { get; init; } = "LCI";
|
public string DomainNetbios { get; init; } = "LCI";
|
||||||
|
|
||||||
public int TimeoutSeconds { get; init; } = 6;
|
public int TimeoutSeconds { get; init; } = 20;
|
||||||
|
|
||||||
public string ClientCertificateThumbprint { get; init; } = string.Empty;
|
public string ClientCertificateThumbprint { get; init; } = string.Empty;
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ internal sealed class ProviderSettings
|
|||||||
throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
|
throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 30)
|
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 60)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
|
throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
|
||||||
|
namespace SGU.CredentialProvider;
|
||||||
|
|
||||||
|
internal static class ProviderTileIcon
|
||||||
|
{
|
||||||
|
public const int Size = 72;
|
||||||
|
|
||||||
|
public static Bitmap Create()
|
||||||
|
{
|
||||||
|
Bitmap bitmap = new(Size, Size, PixelFormat.Format32bppArgb);
|
||||||
|
using Graphics graphics = Graphics.FromImage(bitmap);
|
||||||
|
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||||
|
graphics.Clear(Color.FromArgb(0, 83, 155));
|
||||||
|
|
||||||
|
using Pen key = new(Color.White, 5.5f)
|
||||||
|
{
|
||||||
|
StartCap = LineCap.Round,
|
||||||
|
EndCap = LineCap.Round,
|
||||||
|
LineJoin = LineJoin.Round
|
||||||
|
};
|
||||||
|
|
||||||
|
graphics.DrawEllipse(key, 14, 14, 25, 25);
|
||||||
|
graphics.DrawLine(key, 35, 35, 57, 57);
|
||||||
|
graphics.DrawLine(key, 47, 47, 55, 39);
|
||||||
|
graphics.DrawLine(key, 53, 53, 61, 45);
|
||||||
|
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ public sealed class SguCredentialProvider : CredentialProviderBase
|
|||||||
public override IEnumerable<ControlBase> GetControls(UsageScenario cpus)
|
public override IEnumerable<ControlBase> GetControls(UsageScenario cpus)
|
||||||
{
|
{
|
||||||
yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU");
|
yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU");
|
||||||
|
yield return new CredentialProviderLogoControl(
|
||||||
|
ControlKeys.ProviderLogo,
|
||||||
|
"Acceso institucional SGU",
|
||||||
|
ProviderTileIcon.Create());
|
||||||
yield return new SmallLabelControl(
|
yield return new SmallLabelControl(
|
||||||
ControlKeys.InformationLabel,
|
ControlKeys.InformationLabel,
|
||||||
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.");
|
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
|
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
|
||||||
"DomainNetbios": "LCI",
|
"DomainNetbios": "LCI",
|
||||||
"TimeoutSeconds": 6,
|
"TimeoutSeconds": 20,
|
||||||
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
|
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
|
||||||
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
|
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Text;
|
||||||
|
using SGU.AuthBroker.Core.Profiles;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SGU.AuthBroker.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class SguHtmlDecoderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DecodesWindows1252WhenSguOmitsACharset()
|
||||||
|
{
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
byte[] bytes = Encoding.GetEncoding(1252).GetBytes(
|
||||||
|
"<span>ANALISTA DE INMERSIÓN — FACULTAD DE INGENIERÍA</span>");
|
||||||
|
|
||||||
|
string decoded = SguHtmlDecoder.Decode(bytes);
|
||||||
|
|
||||||
|
Assert.Contains("INMERSIÓN", decoded, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("INGENIERÍA", decoded, StringComparison.Ordinal);
|
||||||
|
Assert.Contains('—', decoded);
|
||||||
|
Assert.DoesNotContain('\uFFFD', decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FallsBackWhenTheDeclaredUtf8CharsetIsIncorrect()
|
||||||
|
{
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
byte[] bytes = Encoding.GetEncoding(1252).GetBytes("JESÚS GONZÁLEZ");
|
||||||
|
|
||||||
|
string decoded = SguHtmlDecoder.Decode(bytes, "utf-8");
|
||||||
|
|
||||||
|
Assert.Equal("JESÚS GONZÁLEZ", decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HonorsValidUtf8WithoutADeclaration()
|
||||||
|
{
|
||||||
|
byte[] bytes = Encoding.UTF8.GetBytes("María del Carmen 🔐");
|
||||||
|
|
||||||
|
string decoded = SguHtmlDecoder.Decode(bytes);
|
||||||
|
|
||||||
|
Assert.Equal("María del Carmen 🔐", decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,11 +38,11 @@ public sealed class SguProfileParserTests
|
|||||||
|
|
||||||
Assert.NotNull(profile);
|
Assert.NotNull(profile);
|
||||||
Assert.Equal("017045", profile.EmployeeNumber);
|
Assert.Equal("017045", profile.EmployeeNumber);
|
||||||
Assert.Equal("JESÚS ALEJANDRO ROSALES GONZÁLEZ", profile.DisplayName);
|
Assert.Equal("Jesús Alejandro Rosales González", profile.DisplayName);
|
||||||
Assert.Equal("persona@lasalle.mx", profile.Email);
|
Assert.Equal("persona@lasalle.mx", profile.Email);
|
||||||
Assert.Equal("SINDICALIZADO QUINCENAL (ACTIVO)", profile.EmployeeType);
|
Assert.Equal("Sindicalizado quincenal (activo)", profile.EmployeeType);
|
||||||
Assert.Equal("ANALISTA DE PROYECTOS", profile.JobTitle);
|
Assert.Equal("Analista de Proyectos", profile.JobTitle);
|
||||||
Assert.Equal("FACULTAD DE INGENIERÍA", profile.Department);
|
Assert.Equal("Facultad de Ingeniería", profile.Department);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -68,12 +68,44 @@ public sealed class SguProfileParserTests
|
|||||||
InstitutionalProfile? profile = SguProfileParser.ParseMenu(html);
|
InstitutionalProfile? profile = SguProfileParser.ParseMenu(html);
|
||||||
|
|
||||||
Assert.NotNull(profile);
|
Assert.NotNull(profile);
|
||||||
Assert.Equal("MARÍA & JOSÉ", profile.DisplayName);
|
Assert.Equal("María & José", profile.DisplayName);
|
||||||
Assert.Null(profile.Email);
|
Assert.Null(profile.Email);
|
||||||
Assert.Null(profile.JobTitle);
|
Assert.Null(profile.JobTitle);
|
||||||
Assert.Null(profile.Department);
|
Assert.Null(profile.Department);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("MARÍA DEL CARMEN", "María del Carmen")]
|
||||||
|
[InlineData("MIGUEL DE CERVANTES", "Miguel de Cervantes")]
|
||||||
|
[InlineData("CARLOS DE LA FUENTE", "Carlos de la Fuente")]
|
||||||
|
[InlineData("MARÍA-JOSÉ O'CONNOR", "María-José O'Connor")]
|
||||||
|
public void PreservesSpanishNameParticlesAndAccents(string source, string expected)
|
||||||
|
{
|
||||||
|
const string marker = "ctl00_lblNombreUsuario";
|
||||||
|
|
||||||
|
InstitutionalProfile? profile = SguProfileParser.ParseMenu(
|
||||||
|
$"<span id=\"{marker}\">{source}</span>");
|
||||||
|
|
||||||
|
Assert.NotNull(profile);
|
||||||
|
Assert.Equal(expected, profile.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsReplacementCharactersInsteadOfWritingCorruptMetadata()
|
||||||
|
{
|
||||||
|
const string html = """
|
||||||
|
<span id="ctl00_contenedor_decEncabezado_lblNombre">017045 - 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]
|
[Fact]
|
||||||
public void MissingKnownFieldsProducesNoProfile()
|
public void MissingKnownFieldsProducesNoProfile()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ namespace SGU.CredentialProvider.SmokeProbe;
|
|||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
private static readonly Guid ProviderClassId = new("D789CFD8-5AD4-489F-9B83-7EB5D9D09335");
|
private static readonly Guid ProviderClassId = new("D789CFD8-5AD4-489F-9B83-7EB5D9D09335");
|
||||||
|
private static readonly Guid ProviderLogoFieldType = new("2D837775-F6CD-464E-A745-482FD0B47493");
|
||||||
|
|
||||||
private static readonly string[] ExpectedLabels =
|
private static readonly string[] ExpectedLabels =
|
||||||
[
|
[
|
||||||
|
"Acceso institucional SGU",
|
||||||
"Acceso institucional SGU",
|
"Acceso institucional SGU",
|
||||||
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.",
|
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.",
|
||||||
"Clave institucional",
|
"Clave institucional",
|
||||||
@@ -52,6 +54,7 @@ internal static class Program
|
|||||||
ThrowIfFailed(provider.GetFieldDescriptorCount(out uint fieldCount), "GetFieldDescriptorCount");
|
ThrowIfFailed(provider.GetFieldDescriptorCount(out uint fieldCount), "GetFieldDescriptorCount");
|
||||||
|
|
||||||
List<string> labels = [];
|
List<string> labels = [];
|
||||||
|
bool providerLogoPresent = false;
|
||||||
for (uint index = 0; index < fieldCount; index++)
|
for (uint index = 0; index < fieldCount; index++)
|
||||||
{
|
{
|
||||||
ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt");
|
ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt");
|
||||||
@@ -64,6 +67,8 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
FieldDescriptor descriptor = Marshal.PtrToStructure<FieldDescriptor>(descriptorPointer);
|
FieldDescriptor descriptor = Marshal.PtrToStructure<FieldDescriptor>(descriptorPointer);
|
||||||
labels.Add(descriptor.Label ?? string.Empty);
|
labels.Add(descriptor.Label ?? string.Empty);
|
||||||
|
providerLogoPresent |= descriptor.FieldType == FieldType.TileImage &&
|
||||||
|
descriptor.FieldTypeGuid == ProviderLogoFieldType;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -81,6 +86,7 @@ internal static class Program
|
|||||||
bool passed = fieldCount == ExpectedLabels.Length &&
|
bool passed = fieldCount == ExpectedLabels.Length &&
|
||||||
credentialCount == 1 &&
|
credentialCount == 1 &&
|
||||||
credential != IntPtr.Zero &&
|
credential != IntPtr.Zero &&
|
||||||
|
providerLogoPresent &&
|
||||||
labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal);
|
labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal);
|
||||||
|
|
||||||
if (mode != "enumeration" && passed)
|
if (mode != "enumeration" && passed)
|
||||||
@@ -96,6 +102,7 @@ internal static class Program
|
|||||||
usageScenario = "Logon",
|
usageScenario = "Logon",
|
||||||
fieldCount,
|
fieldCount,
|
||||||
labels,
|
labels,
|
||||||
|
providerLogoPresent,
|
||||||
credentialCount,
|
credentialCount,
|
||||||
defaultIndex,
|
defaultIndex,
|
||||||
autoLogon = autoLogon != 0
|
autoLogon = autoLogon != 0
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ namespace SGU.CredentialProvider.Tests;
|
|||||||
|
|
||||||
public sealed class BrokerClientTests
|
public sealed class BrokerClientTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker()
|
||||||
|
{
|
||||||
|
Assert.Equal(20, new ProviderSettings().TimeoutSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendsTheOriginalPasswordWithoutDerivation()
|
public async Task SendsTheOriginalPasswordWithoutDerivation()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using Lithnet.CredentialProvider;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SGU.CredentialProvider.Tests;
|
||||||
|
|
||||||
|
public sealed class ProviderTileIconTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ProviderPublishesASeventyTwoPixelLogoForSignInOptions()
|
||||||
|
{
|
||||||
|
SguCredentialProvider provider = new();
|
||||||
|
|
||||||
|
CredentialProviderLogoControl logo = Assert.Single(
|
||||||
|
provider.GetControls(UsageScenario.Logon).OfType<CredentialProviderLogoControl>());
|
||||||
|
|
||||||
|
Assert.Equal(ProviderTileIcon.Size, logo.Bitmap.Width);
|
||||||
|
Assert.Equal(ProviderTileIcon.Size, logo.Bitmap.Height);
|
||||||
|
Assert.Equal(Color.FromArgb(0, 83, 155).ToArgb(), logo.Bitmap.GetPixel(0, 0).ToArgb());
|
||||||
|
int lightPixels = 0;
|
||||||
|
for (int x = 0; x < logo.Bitmap.Width; x++)
|
||||||
|
{
|
||||||
|
for (int y = 0; y < logo.Bitmap.Height; y++)
|
||||||
|
{
|
||||||
|
if (logo.Bitmap.GetPixel(x, y).GetBrightness() > 0.7f)
|
||||||
|
{
|
||||||
|
lightPixels++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.InRange(lightPixels, 200, 2_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user