Resolve SGU staff addresses and provision local student user
This commit is contained in:
@@ -9,7 +9,7 @@ source and adds an SGU-specific provider, an mTLS-protected broker, Active
|
|||||||
Directory synchronization, deployment scripts, and tests.
|
Directory synchronization, deployment scripts, and tests.
|
||||||
|
|
||||||
Ready-to-run bootstrap packages are published on the
|
Ready-to-run bootstrap packages are published on the
|
||||||
[releases page](https://github.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
|
[releases page](https://gitea.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
|
||||||
|
|
||||||
## Authentication contract
|
## Authentication contract
|
||||||
|
|
||||||
@@ -44,6 +44,13 @@ skips that optional field. Missing or changed presentation HTML never blocks
|
|||||||
authentication or password synchronization after the lightweight NTLM root has
|
authentication or password synchronization after the lightweight NTLM root has
|
||||||
accepted the credential.
|
accepted the credential.
|
||||||
|
|
||||||
|
For administrative staff and professors, the location page is enriched with its
|
||||||
|
ASP.NET PageMethods responses. `GetDireccion` supplies the saved state,
|
||||||
|
municipality and neighborhood identifiers; `GetLocalidadListado` resolves the
|
||||||
|
municipality name, and `GetColoniasListado` validates or supplies the
|
||||||
|
neighborhood name. This avoids reading the temporary `Seleccione...` values
|
||||||
|
visible while the browser populates those controls asynchronously.
|
||||||
|
|
||||||
Operational documentation:
|
Operational documentation:
|
||||||
|
|
||||||
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
|
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ It uses the administrative incident overview for `AD`, the student information
|
|||||||
page for `AL`, and the portal menu as a conservative base for `DO`. After the
|
page for `AL`, and the portal menu as a conservative base for `DO`. After the
|
||||||
incident page confirms an `AD` employee number, two additional GETs in the same
|
incident page confirms an `AD` employee number, two additional GETs in the same
|
||||||
in-memory session read the structured name and selected sex from
|
in-memory session read the structured name and selected sex from
|
||||||
`datos/personales.aspx` and the address from `datos/ubicacion.aspx`. Docentes request
|
`datos/personales.aspx` and the address inputs from `datos/ubicacion.aspx`. The
|
||||||
|
broker then calls the location page's `GetDireccion`, `GetLocalidadListado`, and
|
||||||
|
`GetColoniasListado` methods to correlate the saved state, municipality, and
|
||||||
|
neighborhood identifiers instead of reading transient `Seleccione...` options.
|
||||||
|
Docentes request
|
||||||
`nomina/consultanomina.aspx` for a matching employee number, email, employee
|
`nomina/consultanomina.aspx` for a matching employee number, email, employee
|
||||||
type and job title, then attempt the same two shared staff modules without
|
type and job title, then attempt the same two shared staff modules without
|
||||||
requiring any optional route to exist. A supplemental
|
requiring any optional route to exist. A supplemental
|
||||||
|
|||||||
@@ -72,11 +72,13 @@ Orden de la transacción:
|
|||||||
1. instala .NET y los binarios versionados;
|
1. instala .NET y los binarios versionados;
|
||||||
2. registra COM, configura SGU como proveedor predeterminado y oculta el
|
2. registra COM, configura SGU como proveedor predeterminado y oculta el
|
||||||
último usuario que cerró sesión;
|
último usuario que cerró sesión;
|
||||||
3. instala el guard de autorreparación;
|
3. crea o actualiza la cuenta local estándar `alumno` con la contraseña
|
||||||
4. exige health mTLS del broker y ejecuta las comprobaciones locales;
|
`ingenieria`, la habilita y garantiza que no pertenezca a Administradores;
|
||||||
5. configura DNS del dominio;
|
4. instala el guard de autorreparación;
|
||||||
6. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
|
5. exige health mTLS del broker y ejecuta las comprobaciones locales;
|
||||||
7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
|
6. configura DNS del dominio;
|
||||||
|
7. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
|
||||||
|
8. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
|
||||||
|
|
||||||
La directiva de Windows **Assign a default credential provider** selecciona SGU
|
La directiva de Windows **Assign a default credential provider** selecciona SGU
|
||||||
por defecto. El instalador también habilita **Interactive logon: Don't display
|
por defecto. El instalador también habilita **Interactive logon: Don't display
|
||||||
@@ -138,8 +140,9 @@ Start-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard
|
|||||||
|
|
||||||
Un resultado válido exige simultáneamente binario y registro COM, configuración,
|
Un resultado válido exige simultáneamente binario y registro COM, configuración,
|
||||||
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
|
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
|
||||||
enumeración local deshabilitada y proveedor de contraseña de Microsoft
|
enumeración local deshabilitada, cuenta local estándar `alumno` habilitada y
|
||||||
preservado. El script de reparación se encuentra en
|
fuera del grupo Administradores, y proveedor de contraseña de Microsoft
|
||||||
|
preservado. El guard recrea o corrige esa cuenta de forma idempotente. El script de reparación se encuentra en
|
||||||
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
|
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
|
||||||
administradores.
|
administradores.
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
|
|||||||
foreach ($scriptName in @(
|
foreach ($scriptName in @(
|
||||||
'Install-CredentialProvider.ps1',
|
'Install-CredentialProvider.ps1',
|
||||||
'Install-SguEnrollmentGuard.ps1',
|
'Install-SguEnrollmentGuard.ps1',
|
||||||
|
'Set-SguStandardLocalUser.ps1',
|
||||||
'Test-SguClientEnrollment.ps1',
|
'Test-SguClientEnrollment.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1',
|
'Repair-SguClientEnrollment.ps1',
|
||||||
'Enable-LabRemoteAccess.ps1',
|
'Enable-LabRemoteAccess.ps1',
|
||||||
@@ -115,6 +116,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
-ServerAddress $RustDeskServerAddress `
|
-ServerAddress $RustDeskServerAddress `
|
||||||
-ServerPublicKey $RustDeskServerPublicKey
|
-ServerPublicKey $RustDeskServerPublicKey
|
||||||
}
|
}
|
||||||
|
$localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1')
|
||||||
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
|
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
|
||||||
|
|
||||||
$testParameters = @{ RequireBrokerHealth = $true }
|
$testParameters = @{ RequireBrokerHealth = $true }
|
||||||
@@ -142,6 +144,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
|
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
|
||||||
}
|
}
|
||||||
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
|
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
|
||||||
|
$postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser
|
||||||
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
|
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
|
||||||
return $postJoin
|
return $postJoin
|
||||||
}
|
}
|
||||||
@@ -175,6 +178,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
DomainName = $DomainName
|
DomainName = $DomainName
|
||||||
ConnectivityMode = $ConnectivityMode
|
ConnectivityMode = $ConnectivityMode
|
||||||
ProviderValidatedBeforeJoin = $true
|
ProviderValidatedBeforeJoin = $true
|
||||||
|
StandardLocalUser = $localStudentUser
|
||||||
RustDesk = $rustDeskResult
|
RustDesk = $rustDeskResult
|
||||||
RestartRequired = [bool]$SkipRestart
|
RestartRequired = [bool]$SkipRestart
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ $sourceScripts = @(
|
|||||||
'Enable-LabRemoteAccess.ps1',
|
'Enable-LabRemoteAccess.ps1',
|
||||||
'Enable-SguClientMonitoring.ps1',
|
'Enable-SguClientMonitoring.ps1',
|
||||||
'Install-SguRustDeskClient.ps1',
|
'Install-SguRustDeskClient.ps1',
|
||||||
|
'Set-SguStandardLocalUser.ps1',
|
||||||
'Test-SguClientEnrollment.ps1',
|
'Test-SguClientEnrollment.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1'
|
'Repair-SguClientEnrollment.ps1'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ $clientScripts = @(
|
|||||||
'Install-SguRustDeskClient.ps1',
|
'Install-SguRustDeskClient.ps1',
|
||||||
'Register-SguClientCertificate.ps1',
|
'Register-SguClientCertificate.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1',
|
'Repair-SguClientEnrollment.ps1',
|
||||||
|
'Set-SguStandardLocalUser.ps1',
|
||||||
'Test-SguClientEnrollment.ps1'
|
'Test-SguClientEnrollment.ps1'
|
||||||
)
|
)
|
||||||
foreach ($scriptName in $clientScripts) {
|
foreach ($scriptName in $clientScripts) {
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ Bootstrap reproducible para el laboratorio SGU.
|
|||||||
- El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet.
|
- El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet.
|
||||||
- Los Windows 11 Pro pueden instalar un perfil IKEv2 de todos los usuarios con certificado de máquina, DNS dividido para `lci.lasalle.mx` y ejecutarlo desde la pantalla de inicio de sesión antes de autenticar una cuenta de dominio nueva.
|
- Los Windows 11 Pro pueden instalar un perfil IKEv2 de todos los usuarios con certificado de máquina, DNS dividido para `lci.lasalle.mx` y ejecutarlo desde la pantalla de inicio de sesión antes de autenticar una cuenta de dominio nueva.
|
||||||
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID.
|
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID.
|
||||||
|
- El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU.
|
||||||
|
- El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores.
|
||||||
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
|
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
|
||||||
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
|
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
|
||||||
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
|
|||||||
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
||||||
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
|
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
|
||||||
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
|
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
|
||||||
|
$localUserScript = Join-Path $enrollmentRoot 'Set-SguStandardLocalUser.ps1'
|
||||||
|
|
||||||
$before = & $testScript
|
$before = & $testScript
|
||||||
if (-not $before.IsValid) {
|
if (-not $before.IsValid) {
|
||||||
|
& $localUserScript | Out-Null
|
||||||
$installParams = @{
|
$installParams = @{
|
||||||
PublishPath = [string]$configuration.PublishPath
|
PublishPath = [string]$configuration.PublishPath
|
||||||
BrokerEndpoint = [string]$configuration.BrokerEndpoint
|
BrokerEndpoint = [string]$configuration.BrokerEndpoint
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$userName = 'alumno'
|
||||||
|
$plainTextPassword = 'ingenieria'
|
||||||
|
$description = 'Cuenta local estandar de recuperacion para equipos SGU'
|
||||||
|
|
||||||
|
$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.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Create or update standard local user $userName")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$securePassword = ConvertTo-SecureString $plainTextPassword -AsPlainText -Force
|
||||||
|
try {
|
||||||
|
$user = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue
|
||||||
|
if ($user -and $user.SID.Value.EndsWith('-500', [StringComparison]::Ordinal)) {
|
||||||
|
throw "The local account '$userName' is the built-in Administrator account and cannot be converted to a standard user."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
Set-LocalUser -Name $userName `
|
||||||
|
-Password $securePassword `
|
||||||
|
-PasswordNeverExpires $true `
|
||||||
|
-Description $description
|
||||||
|
if (-not $user.Enabled) {
|
||||||
|
Enable-LocalUser -Name $userName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
New-LocalUser -Name $userName `
|
||||||
|
-Password $securePassword `
|
||||||
|
-PasswordNeverExpires `
|
||||||
|
-Description $description | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Get-LocalUser -Name $userName -ErrorAction Stop
|
||||||
|
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||||
|
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||||
|
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
|
||||||
|
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
|
||||||
|
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
|
||||||
|
if ($administratorMembers.SID.Value -contains $user.SID.Value) {
|
||||||
|
Remove-LocalGroupMember -Group $administratorsGroup -Member $user -Confirm:$false
|
||||||
|
}
|
||||||
|
|
||||||
|
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
|
||||||
|
if ($standardMembers.SID.Value -notcontains $user.SID.Value) {
|
||||||
|
Add-LocalGroupMember -Group $usersGroup -Member $user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$securePassword = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$verifiedUser = Get-LocalUser -Name $userName -ErrorAction Stop
|
||||||
|
$verifiedAdministratorsGroup = Get-LocalGroup `
|
||||||
|
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) `
|
||||||
|
-ErrorAction Stop
|
||||||
|
$verifiedUsersGroup = Get-LocalGroup `
|
||||||
|
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')) `
|
||||||
|
-ErrorAction Stop
|
||||||
|
$verifiedAdministrators = @(Get-LocalGroupMember -Group $verifiedAdministratorsGroup -ErrorAction Stop)
|
||||||
|
$verifiedUsers = @(Get-LocalGroupMember -Group $verifiedUsersGroup -ErrorAction Stop)
|
||||||
|
if (@($verifiedAdministrators).SID.Value -contains $verifiedUser.SID.Value) {
|
||||||
|
throw "The local account '$userName' still belongs to the local Administrators group."
|
||||||
|
}
|
||||||
|
if ($verifiedUsers.SID.Value -notcontains $verifiedUser.SID.Value) {
|
||||||
|
throw "The local account '$userName' does not belong to the local Users group."
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
UserName = $verifiedUser.Name
|
||||||
|
Enabled = $verifiedUser.Enabled
|
||||||
|
IsAdministrator = $false
|
||||||
|
IsStandardUser = $true
|
||||||
|
PasswordNeverExpires = $verifiedUser.PasswordNeverExpires
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
|
|||||||
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||||
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
||||||
$issues = [Collections.Generic.List[string]]::new()
|
$issues = [Collections.Generic.List[string]]::new()
|
||||||
|
$standardLocalUserName = 'alumno'
|
||||||
|
|
||||||
$computer = Get-CimInstance Win32_ComputerSystem
|
$computer = Get-CimInstance Win32_ComputerSystem
|
||||||
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
||||||
@@ -87,6 +88,41 @@ if (-not $passwordProviderPreserved) {
|
|||||||
$issues.Add('The built-in Microsoft password provider registration is missing.')
|
$issues.Add('The built-in Microsoft password provider registration is missing.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$standardLocalUser = Get-LocalUser -Name $standardLocalUserName -ErrorAction SilentlyContinue
|
||||||
|
$standardLocalUserPresent = [bool]$standardLocalUser
|
||||||
|
$standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled
|
||||||
|
$standardLocalUserIsAdministrator = $false
|
||||||
|
$standardLocalUserInUsersGroup = $false
|
||||||
|
$standardLocalUserPasswordNeverExpires =
|
||||||
|
$standardLocalUserPresent -and $standardLocalUser.PasswordNeverExpires
|
||||||
|
if ($standardLocalUserPresent) {
|
||||||
|
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||||
|
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||||
|
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
|
||||||
|
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
|
||||||
|
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
|
||||||
|
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
|
||||||
|
$standardLocalUserIsAdministrator =
|
||||||
|
$administratorMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||||
|
$standardLocalUserInUsersGroup =
|
||||||
|
$standardMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||||
|
}
|
||||||
|
if (-not $standardLocalUserPresent) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' is missing.")
|
||||||
|
}
|
||||||
|
elseif (-not $standardLocalUserEnabled) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' is disabled.")
|
||||||
|
}
|
||||||
|
elseif ($standardLocalUserIsAdministrator) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' belongs to the local Administrators group.")
|
||||||
|
}
|
||||||
|
elseif (-not $standardLocalUserInUsersGroup) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.")
|
||||||
|
}
|
||||||
|
elseif (-not $standardLocalUserPasswordNeverExpires) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' does not retain its enrollment password.")
|
||||||
|
}
|
||||||
|
|
||||||
$settings = $null
|
$settings = $null
|
||||||
try {
|
try {
|
||||||
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
|
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
|
||||||
@@ -207,6 +243,11 @@ $result = [pscustomobject]@{
|
|||||||
LastSignedInUserHidden = $lastSignedInUserHidden
|
LastSignedInUserHidden = $lastSignedInUserHidden
|
||||||
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
|
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
|
||||||
PasswordProviderPreserved = $passwordProviderPreserved
|
PasswordProviderPreserved = $passwordProviderPreserved
|
||||||
|
StandardLocalUserPresent = $standardLocalUserPresent
|
||||||
|
StandardLocalUserEnabled = $standardLocalUserEnabled
|
||||||
|
StandardLocalUserIsAdministrator = $standardLocalUserIsAdministrator
|
||||||
|
StandardLocalUserInUsersGroup = $standardLocalUserInUsersGroup
|
||||||
|
StandardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires
|
||||||
SettingsPresent = [bool]$settings
|
SettingsPresent = [bool]$settings
|
||||||
ClientCertificatePresent = [bool]$clientCertificatePresent
|
ClientCertificatePresent = [bool]$clientCertificatePresent
|
||||||
ServerCertificateTrusted = $serverCertificateTrusted
|
ServerCertificateTrusted = $serverCertificateTrusted
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace SGU.AuthBroker.Core.Profiles;
|
||||||
|
|
||||||
|
public sealed record SguAdministrativeLocationSelection(
|
||||||
|
string? StateId = null,
|
||||||
|
string? MunicipalityId = null,
|
||||||
|
string? NeighborhoodId = null,
|
||||||
|
string? StateName = null,
|
||||||
|
string? MunicipalityName = null,
|
||||||
|
string? NeighborhoodName = null,
|
||||||
|
string? PostalCode = null)
|
||||||
|
{
|
||||||
|
public bool HasValues =>
|
||||||
|
StateId is not null ||
|
||||||
|
MunicipalityId is not null ||
|
||||||
|
NeighborhoodId is not null ||
|
||||||
|
StateName is not null ||
|
||||||
|
MunicipalityName is not null ||
|
||||||
|
NeighborhoodName is not null ||
|
||||||
|
PostalCode is not null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Mail;
|
using System.Net.Mail;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace SGU.AuthBroker.Core.Profiles;
|
namespace SGU.AuthBroker.Core.Profiles;
|
||||||
|
|
||||||
@@ -102,7 +103,14 @@ public static class SguProfileParser
|
|||||||
return profile.HasValues ? profile : null;
|
return profile.HasValues ? profile : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static InstitutionalProfile? ParseAdministrativeLocation(string html)
|
public static InstitutionalProfile? ParseAdministrativeLocation(string html) =>
|
||||||
|
ParseAdministrativeLocation(html, null, null, null);
|
||||||
|
|
||||||
|
public static InstitutionalProfile? ParseAdministrativeLocation(
|
||||||
|
string html,
|
||||||
|
SguAdministrativeLocationSelection? selection,
|
||||||
|
string? localitiesJson,
|
||||||
|
string? neighborhoodsJson)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(html);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
|
|
||||||
@@ -112,18 +120,79 @@ public static class SguProfileParser
|
|||||||
string? interiorNumber = NormalizeAddressUnit(
|
string? interiorNumber = NormalizeAddressUnit(
|
||||||
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
||||||
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
||||||
string? neighborhood = NormalizeTitle(
|
string? neighborhood = NormalizeTitle(FirstNonEmpty(
|
||||||
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
|
selection?.NeighborhoodName,
|
||||||
256);
|
ResolveNeighborhoodName(neighborhoodsJson, selection),
|
||||||
|
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId)), 256);
|
||||||
|
string? city = NormalizeTitle(FirstNonEmpty(
|
||||||
|
selection?.MunicipalityName,
|
||||||
|
ResolveLocalityName(localitiesJson, selection),
|
||||||
|
ExtractSelectedOptionText(html, AdministrativeCityId)), 128);
|
||||||
|
string? state = NormalizeTitle(FirstNonEmpty(
|
||||||
|
selection?.StateName,
|
||||||
|
ExtractOptionTextByValue(html, AdministrativeStateId, selection?.StateId),
|
||||||
|
ExtractSelectedOptionText(html, AdministrativeStateId)), 128);
|
||||||
|
string? postalCode = NormalizePostalCode(FirstNonEmpty(
|
||||||
|
selection?.PostalCode,
|
||||||
|
ExtractInputValue(html, AdministrativePostalCodeId)));
|
||||||
|
|
||||||
InstitutionalProfile profile = new(
|
InstitutionalProfile profile = new(
|
||||||
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
||||||
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
|
City: city,
|
||||||
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
|
State: state,
|
||||||
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
|
PostalCode: postalCode);
|
||||||
return profile.HasValues ? profile : null;
|
return profile.HasValues ? profile : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static SguAdministrativeLocationSelection? ParseAdministrativeLocationSelection(
|
||||||
|
string json,
|
||||||
|
string? expectedPostalCode)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(json);
|
||||||
|
|
||||||
|
string? expected = NormalizePostalCode(expectedPostalCode);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(json);
|
||||||
|
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JsonElement value in values.EnumerateArray())
|
||||||
|
{
|
||||||
|
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
|
||||||
|
if (expected is not null &&
|
||||||
|
!string.Equals(postalCode, expected, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
SguAdministrativeLocationSelection selection = new(
|
||||||
|
StateId: NormalizeCatalogId(GetJsonString(value, "p_IdEstado")),
|
||||||
|
MunicipalityId: NormalizeCatalogId(GetJsonString(value, "p_IdMunicipio")),
|
||||||
|
NeighborhoodId: NormalizeCatalogId(GetJsonString(value, "p_IdCP")),
|
||||||
|
StateName: Limit(GetJsonString(value, "p_NombreEstado"), 128),
|
||||||
|
MunicipalityName: Limit(GetJsonString(value, "p_NombreMunicipio"), 128),
|
||||||
|
NeighborhoodName: Limit(FirstNonEmpty(
|
||||||
|
GetJsonString(value, "p_NombreColonia"),
|
||||||
|
GetJsonString(value, "p_NombreAsentamiento"),
|
||||||
|
GetJsonString(value, "p_Nombre")), 256),
|
||||||
|
PostalCode: postalCode);
|
||||||
|
if (selection.HasValues)
|
||||||
|
{
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(html);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
@@ -350,6 +419,186 @@ public static class SguProfileParser
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ExtractOptionTextByValue(string html, string id, string? expectedValue)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(expectedValue))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? openingTag = FindOpeningTag(html, "select", id);
|
||||||
|
if (openingTag is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
int contentStart = openingTagIndex + openingTag.Length;
|
||||||
|
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (openingTagIndex < 0 || contentEnd < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string optionsHtml = html[contentStart..contentEnd];
|
||||||
|
int searchFrom = 0;
|
||||||
|
while (searchFrom < optionsHtml.Length)
|
||||||
|
{
|
||||||
|
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (optionStart < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
|
||||||
|
int optionEnd = optionTagEnd < 0
|
||||||
|
? -1
|
||||||
|
: optionsHtml.IndexOf("</option", optionTagEnd + 1, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (optionTagEnd < 0 || optionEnd < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
|
||||||
|
string? optionValue = ExtractAttributeValue(optionTag, "value");
|
||||||
|
if (string.Equals(optionValue, expectedValue, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return NormalizeText(optionsHtml[(optionTagEnd + 1)..optionEnd]);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchFrom = optionEnd + "</option".Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveLocalityName(
|
||||||
|
string? json,
|
||||||
|
SguAdministrativeLocationSelection? selection)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json) ||
|
||||||
|
string.IsNullOrWhiteSpace(selection?.MunicipalityId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(json);
|
||||||
|
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JsonElement value in values.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (string.Equals(
|
||||||
|
NormalizeCatalogId(GetJsonString(value, "Id_Municipio")),
|
||||||
|
selection.MunicipalityId,
|
||||||
|
StringComparison.Ordinal) &&
|
||||||
|
(selection.StateId is null || string.Equals(
|
||||||
|
NormalizeCatalogId(GetJsonString(value, "ID_Estado")),
|
||||||
|
selection.StateId,
|
||||||
|
StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
return GetJsonString(value, "Nombre");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveNeighborhoodName(
|
||||||
|
string? json,
|
||||||
|
SguAdministrativeLocationSelection? selection)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json) || selection is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(json);
|
||||||
|
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> postalCodeMatches = [];
|
||||||
|
foreach (JsonElement value in values.EnumerateArray())
|
||||||
|
{
|
||||||
|
string? neighborhoodId = NormalizeCatalogId(GetJsonString(value, "p_IdCP"));
|
||||||
|
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
|
||||||
|
string? name = GetJsonString(value, "p_Nombre");
|
||||||
|
if (name is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.NeighborhoodId is not null &&
|
||||||
|
string.Equals(neighborhoodId, selection.NeighborhoodId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.PostalCode is not null &&
|
||||||
|
string.Equals(postalCode, selection.PostalCode, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
postalCodeMatches.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return postalCodeMatches.Count == 1 ? postalCodeMatches[0] : null;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetPageMethodArray(JsonElement root, out JsonElement values)
|
||||||
|
{
|
||||||
|
values = default;
|
||||||
|
return root.ValueKind == JsonValueKind.Object &&
|
||||||
|
root.TryGetProperty("d", out values) &&
|
||||||
|
values.ValueKind == JsonValueKind.Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetJsonString(JsonElement value, string propertyName)
|
||||||
|
{
|
||||||
|
if (value.ValueKind != JsonValueKind.Object ||
|
||||||
|
!value.TryGetProperty(propertyName, out JsonElement property))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return property.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => property.GetString(),
|
||||||
|
JsonValueKind.Number => property.GetRawText(),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeCatalogId(string? value)
|
||||||
|
{
|
||||||
|
string? candidate = value?.Trim();
|
||||||
|
return string.IsNullOrEmpty(candidate) ||
|
||||||
|
candidate.Length > 32 ||
|
||||||
|
!candidate.All(char.IsAsciiLetterOrDigit)
|
||||||
|
? null
|
||||||
|
: candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FirstNonEmpty(params string?[] values) =>
|
||||||
|
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||||
|
|
||||||
private static string? FindOpeningTag(string html, string tagName, string id)
|
private static string? FindOpeningTag(string html, string tagName, string id)
|
||||||
=> FindOpeningTagByAttribute(html, tagName, "id", id);
|
=> FindOpeningTagByAttribute(html, tagName, "id", id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
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;
|
||||||
@@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
pages.Add((
|
pages.Add((
|
||||||
options.AdministrativePersonalProfilePath,
|
options.AdministrativePersonalProfilePath,
|
||||||
SguProfileParser.ParseAdministrativePersonal));
|
SguProfileParser.ParseAdministrativePersonal));
|
||||||
pages.Add((
|
|
||||||
options.AdministrativeLocationProfilePath,
|
|
||||||
SguProfileParser.ParseAdministrativeLocation));
|
|
||||||
|
|
||||||
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
|
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
|
||||||
{
|
{
|
||||||
@@ -474,7 +473,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
break;
|
return profile;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
@@ -487,6 +486,142 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await TryEnrichStaffLocationAsync(
|
||||||
|
client,
|
||||||
|
profile,
|
||||||
|
identity,
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken,
|
||||||
|
requestCancellationToken,
|
||||||
|
elapsed).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<InstitutionalProfile> TryEnrichStaffLocationAsync(
|
||||||
|
HttpClient client,
|
||||||
|
InstitutionalProfile profile,
|
||||||
|
UserIdentity identity,
|
||||||
|
HashSet<string> allowedHosts,
|
||||||
|
CancellationToken timeoutToken,
|
||||||
|
CancellationToken requestCancellationToken,
|
||||||
|
Stopwatch elapsed)
|
||||||
|
{
|
||||||
|
string path = options.AdministrativeLocationProfilePath;
|
||||||
|
Uri locationPageUri = GetProfileUri(path);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? html = await TryFetchAdditionalProfilePageAsync(
|
||||||
|
client,
|
||||||
|
locationPageUri,
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken).ConfigureAwait(false);
|
||||||
|
if (html is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
|
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
|
||||||
|
path,
|
||||||
|
identity.Role);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html);
|
||||||
|
if (staticLocation is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
|
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
|
||||||
|
path,
|
||||||
|
identity.Role);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = profile.Overlay(staticLocation);
|
||||||
|
if (string.IsNullOrWhiteSpace(staticLocation.PostalCode))
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? directionJson = await TryPostProfilePageMethodAsync(
|
||||||
|
client,
|
||||||
|
GetAdministrativeLocationMethodUri("GetDireccion"),
|
||||||
|
locationPageUri,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["CodigoPostal"] = staticLocation.PostalCode
|
||||||
|
},
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken).ConfigureAwait(false);
|
||||||
|
if (directionJson is null)
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
SguAdministrativeLocationSelection? selection =
|
||||||
|
SguProfileParser.ParseAdministrativeLocationSelection(
|
||||||
|
directionJson,
|
||||||
|
staticLocation.PostalCode);
|
||||||
|
if (selection is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
|
"SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.",
|
||||||
|
identity.Role);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? localitiesJson = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(selection.StateId))
|
||||||
|
{
|
||||||
|
localitiesJson = await TryPostProfilePageMethodAsync(
|
||||||
|
client,
|
||||||
|
GetAdministrativeLocationMethodUri("GetLocalidadListado"),
|
||||||
|
locationPageUri,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["pIdEstado"] = selection.StateId
|
||||||
|
},
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
string? neighborhoodsJson = await TryPostProfilePageMethodAsync(
|
||||||
|
client,
|
||||||
|
GetAdministrativeLocationMethodUri("GetColoniasListado"),
|
||||||
|
locationPageUri,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["pIdEstado"] = string.Empty,
|
||||||
|
["pLocalidad"] = string.Empty,
|
||||||
|
["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode
|
||||||
|
},
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation(
|
||||||
|
html,
|
||||||
|
selection,
|
||||||
|
localitiesJson,
|
||||||
|
neighborhoodsJson);
|
||||||
|
return profile.Overlay(resolvedLocation);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentTimeout,
|
||||||
|
"SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
|
identity.Role,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentFailure,
|
||||||
|
exception,
|
||||||
|
"SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
|
identity.Role,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryPostProfilePageMethodAsync(
|
||||||
|
HttpClient client,
|
||||||
|
Uri requestedUri,
|
||||||
|
Uri referrerUri,
|
||||||
|
IReadOnlyDictionary<string, string> payload,
|
||||||
|
HashSet<string> allowedHosts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsAllowedHttpsUri(requestedUri, allowedHosts) ||
|
||||||
|
!IsAllowedHttpsUri(referrerUri, allowedHosts))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using HttpRequestMessage request = new(HttpMethod.Post, requestedUri);
|
||||||
|
request.Headers.Referrer = referrerUri;
|
||||||
|
request.Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(payload),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json");
|
||||||
|
using HttpResponseMessage response = await client
|
||||||
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
int statusCode = (int)response.StatusCode;
|
||||||
|
if (statusCode is >= 200 and < 300)
|
||||||
|
{
|
||||||
|
return await ReadLimitedStringAsync(
|
||||||
|
response.Content,
|
||||||
|
options.MaxProfileBytes,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
|
"Optional SGU profile method {Path} returned HTTP {StatusCode}.",
|
||||||
|
requestedUri.AbsolutePath,
|
||||||
|
statusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddCredential(
|
private static void AddCredential(
|
||||||
Uri uri,
|
Uri uri,
|
||||||
CredentialCache credentialCache,
|
CredentialCache credentialCache,
|
||||||
@@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
private Uri GetProfileUri(string path) =>
|
private Uri GetProfileUri(string path) =>
|
||||||
new(new Uri(options.Endpoint, UriKind.Absolute), path);
|
new(new Uri(options.Endpoint, UriKind.Absolute), path);
|
||||||
|
|
||||||
|
private Uri GetAdministrativeLocationMethodUri(string methodName)
|
||||||
|
{
|
||||||
|
Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath);
|
||||||
|
return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
UserIdentity identity,
|
UserIdentity identity,
|
||||||
|
|||||||
@@ -228,6 +228,65 @@ public sealed class SguProfileParserTests
|
|||||||
Assert.Null(profile.Email);
|
Assert.Null(profile.Email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesAdministrativeAddressFromPageMethodIdentifiers()
|
||||||
|
{
|
||||||
|
const string html = """
|
||||||
|
<html><body>
|
||||||
|
<input id='ctl00_contenedor_txtCalle' value='RETORNO 1, SUR 16' />
|
||||||
|
<input id='ctl00_contenedor_txtNoExt' value='74' />
|
||||||
|
<input id='ctl00_contenedor_txtNoInt' value='' />
|
||||||
|
<input id='ctl00_contenedor_txtCP' value='08500' />
|
||||||
|
<select id='ctl00_contenedor_ddlEstado'>
|
||||||
|
<option selected='selected' value='0'>Seleccione...</option>
|
||||||
|
<option value='09'>CIUDAD DE MÉXICO</option>
|
||||||
|
</select>
|
||||||
|
<select id='ctl00_contenedor_ddlLocalidad'>
|
||||||
|
<option selected='selected' value='0'>Seleccione alguna localidad...</option>
|
||||||
|
</select>
|
||||||
|
<select id='ctl00_contenedor_ddlColonia'>
|
||||||
|
<option selected='selected' value='0,0'>Seleccione alguna colonia...</option>
|
||||||
|
</select>
|
||||||
|
</body></html>
|
||||||
|
""";
|
||||||
|
const string directionJson = """
|
||||||
|
{"d":[{"p_IdCP":"091263","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"006","p_NombreMunicipio":"","p_NombreColonia":"","p_Cp":"08500"}]}
|
||||||
|
""";
|
||||||
|
const string localitiesJson = """
|
||||||
|
{"d":[{"ID_Estado":"09","Id_Municipio":"002","Nombre":"AZCAPOTZALCO"},{"ID_Estado":"09","Id_Municipio":"006","Nombre":"IZTACALCO"}]}
|
||||||
|
""";
|
||||||
|
const string neighborhoodsJson = """
|
||||||
|
{"d":[{"p_IdCP":"091263","p_Nombre":"AGRÍCOLA ORIENTAL","p_Cp":"08500"}]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
SguAdministrativeLocationSelection? selection =
|
||||||
|
SguProfileParser.ParseAdministrativeLocationSelection(directionJson, "08500");
|
||||||
|
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativeLocation(
|
||||||
|
html,
|
||||||
|
selection,
|
||||||
|
localitiesJson,
|
||||||
|
neighborhoodsJson);
|
||||||
|
|
||||||
|
Assert.NotNull(selection);
|
||||||
|
Assert.Equal("09", selection.StateId);
|
||||||
|
Assert.Equal("006", selection.MunicipalityId);
|
||||||
|
Assert.Equal("091263", selection.NeighborhoodId);
|
||||||
|
Assert.NotNull(profile);
|
||||||
|
Assert.Equal("Retorno 1, Sur 16 74\r\nAgrícola Oriental", profile.StreetAddress);
|
||||||
|
Assert.Equal("Iztacalco", profile.City);
|
||||||
|
Assert.Equal("Ciudad de México", profile.State);
|
||||||
|
Assert.Equal("08500", profile.PostalCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("not-json")]
|
||||||
|
[InlineData("{\"d\":{}}")]
|
||||||
|
[InlineData("{\"d\":[]}")]
|
||||||
|
public void RejectsUnexpectedAdministrativeLocationPayloads(string json)
|
||||||
|
{
|
||||||
|
Assert.Null(SguProfileParser.ParseAdministrativeLocationSelection(json, "08500"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
|
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -145,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||||
<select id="ctl00_contenedor_ddlEstado">
|
<select id="ctl00_contenedor_ddlEstado">
|
||||||
<option selected="selected">CIUDAD DE MÉXICO</option>
|
<option selected="selected" value="0">Seleccione...</option>
|
||||||
|
<option value="09">CIUDAD DE MÉXICO</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlLocalidad">
|
<select id="ctl00_contenedor_ddlLocalidad">
|
||||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlColonia">
|
<select id="ctl00_contenedor_ddlColonia">
|
||||||
<option>FLORIDA</option>
|
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||||
</select>
|
</select>
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
|
||||||
"""));
|
"""));
|
||||||
NtlmCredentialValidator validator = CreateValidator(handler);
|
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||||
|
|
||||||
@@ -180,9 +190,17 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
"/psulsa/",
|
"/psulsa/",
|
||||||
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
|
||||||
],
|
],
|
||||||
handler.RequestPaths);
|
handler.RequestPaths);
|
||||||
|
Assert.Equal("{\"CodigoPostal\":\"01000\"}", handler.RequestBodies[5]);
|
||||||
|
Assert.Equal("{\"pIdEstado\":\"09\"}", handler.RequestBodies[6]);
|
||||||
|
Assert.Equal(
|
||||||
|
"{\"pIdEstado\":\"\",\"pLocalidad\":\"\",\"CodigoPostal\":\"01000\"}",
|
||||||
|
handler.RequestBodies[7]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -242,14 +260,24 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||||
<select id="ctl00_contenedor_ddlEstado">
|
<select id="ctl00_contenedor_ddlEstado">
|
||||||
<option selected="selected">CIUDAD DE MÉXICO</option>
|
<option selected="selected" value="0">Seleccione...</option>
|
||||||
|
<option value="09">CIUDAD DE MÉXICO</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlLocalidad">
|
<select id="ctl00_contenedor_ddlLocalidad">
|
||||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlColonia">
|
<select id="ctl00_contenedor_ddlColonia">
|
||||||
<option selected="selected">FLORIDA</option>
|
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||||
</select>
|
</select>
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
|
||||||
"""));
|
"""));
|
||||||
NtlmCredentialValidator validator = CreateValidator(handler);
|
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||||
|
|
||||||
@@ -280,7 +308,10 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
"/psulsa/menu.aspx",
|
"/psulsa/menu.aspx",
|
||||||
"/psulsa/gadmon/nomina/consultanomina.aspx",
|
"/psulsa/gadmon/nomina/consultanomina.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
|
||||||
],
|
],
|
||||||
handler.RequestPaths);
|
handler.RequestPaths);
|
||||||
}
|
}
|
||||||
@@ -363,18 +394,29 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
Content = new StringContent(content)
|
Content = new StringContent(content)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static HttpResponseMessage JsonResponse(string content) =>
|
||||||
|
new(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(content, null, "application/json")
|
||||||
|
};
|
||||||
|
|
||||||
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||||
{
|
{
|
||||||
private readonly Queue<HttpResponseMessage> responses = new(responses);
|
private readonly Queue<HttpResponseMessage> responses = new(responses);
|
||||||
|
|
||||||
public List<string> RequestPaths { get; } = [];
|
public List<string> RequestPaths { get; } = [];
|
||||||
|
|
||||||
|
public List<string?> RequestBodies { get; } = [];
|
||||||
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request,
|
HttpRequestMessage request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
RequestPaths.Add(request.RequestUri!.AbsolutePath);
|
RequestPaths.Add(request.RequestUri!.AbsolutePath);
|
||||||
|
RequestBodies.Add(request.Content is null
|
||||||
|
? null
|
||||||
|
: request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult());
|
||||||
if (responses.Count == 0)
|
if (responses.Count == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The validator sent more requests than expected.");
|
throw new InvalidOperationException("The validator sent more requests than expected.");
|
||||||
|
|||||||
Reference in New Issue
Block a user