From a6bd625e4e52efa26c3899771d6c5d0784d4f90e Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Wed, 9 Sep 2026 09:48:06 -0600 Subject: [PATCH] Resolve SGU staff addresses and provision local student user --- README.md | 9 +- docs/architecture.md | 6 +- docs/client-enrollment.md | 17 +- scripts/Enroll-SguDomainClient.ps1 | 4 + scripts/Install-SguEnrollmentGuard.ps1 | 1 + scripts/New-SguBootstrapPackages.ps1 | 1 + scripts/Publish-GiteaRelease.ps1 | 2 + scripts/Repair-SguClientEnrollment.ps1 | 2 + scripts/Set-SguStandardLocalUser.ps1 | 84 ++++++ scripts/Test-SguClientEnrollment.ps1 | 41 +++ .../SguAdministrativeLocationSelection.cs | 20 ++ .../Profiles/SguProfileParser.cs | 263 +++++++++++++++++- .../Services/NtlmCredentialValidator.cs | 189 ++++++++++++- .../SguProfileParserTests.cs | 59 ++++ .../NtlmCredentialValidatorTests.cs | 58 +++- 15 files changed, 728 insertions(+), 28 deletions(-) create mode 100644 scripts/Set-SguStandardLocalUser.ps1 create mode 100644 src/SGU.AuthBroker.Core/Profiles/SguAdministrativeLocationSelection.cs diff --git a/README.md b/README.md index ee96ee4..945b750 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ source and adds an SGU-specific provider, an mTLS-protected broker, Active Directory synchronization, deployment scripts, and tests. 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 @@ -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 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: - [One-command server recovery and client enrollment](docs/bootstrap-recovery.md) diff --git a/docs/architecture.md b/docs/architecture.md index 753b840..cf4ea86 100644 --- a/docs/architecture.md +++ b/docs/architecture.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 incident page confirms an `AD` employee number, two additional GETs in the same 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 type and job title, then attempt the same two shared staff modules without requiring any optional route to exist. A supplemental diff --git a/docs/client-enrollment.md b/docs/client-enrollment.md index 05a707b..077b36b 100644 --- a/docs/client-enrollment.md +++ b/docs/client-enrollment.md @@ -72,11 +72,13 @@ Orden de la transacción: 1. instala .NET y los binarios versionados; 2. registra COM, configura SGU como proveedor predeterminado y oculta el último usuario que cerró sesión; -3. instala el guard de autorreparación; -4. exige health mTLS del broker y ejecuta las comprobaciones locales; -5. configura DNS del dominio; -6. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia; -7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final. +3. crea o actualiza la cuenta local estándar `alumno` con la contraseña + `ingenieria`, la habilita y garantiza que no pertenezca a Administradores; +4. instala el guard de autorreparación; +5. exige health mTLS del broker y ejecuta las comprobaciones locales; +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 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, certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto, -enumeración local deshabilitada y proveedor de contraseña de Microsoft -preservado. El script de reparación se encuentra en +enumeración local deshabilitada, cuenta local estándar `alumno` habilitada y +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 administradores. diff --git a/scripts/Enroll-SguDomainClient.ps1 b/scripts/Enroll-SguDomainClient.ps1 index 4a2dfbc..1d7ebfb 100644 --- a/scripts/Enroll-SguDomainClient.ps1 +++ b/scripts/Enroll-SguDomainClient.ps1 @@ -41,6 +41,7 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra foreach ($scriptName in @( 'Install-CredentialProvider.ps1', 'Install-SguEnrollmentGuard.ps1', + 'Set-SguStandardLocalUser.ps1', 'Test-SguClientEnrollment.ps1', 'Repair-SguClientEnrollment.ps1', 'Enable-LabRemoteAccess.ps1', @@ -115,6 +116,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo -ServerAddress $RustDeskServerAddress ` -ServerPublicKey $RustDeskServerPublicKey } + $localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1') & (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null $testParameters = @{ RequireBrokerHealth = $true } @@ -142,6 +144,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo $postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress } $postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters + $postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser $postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult return $postJoin } @@ -175,6 +178,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo DomainName = $DomainName ConnectivityMode = $ConnectivityMode ProviderValidatedBeforeJoin = $true + StandardLocalUser = $localStudentUser RustDesk = $rustDeskResult RestartRequired = [bool]$SkipRestart } diff --git a/scripts/Install-SguEnrollmentGuard.ps1 b/scripts/Install-SguEnrollmentGuard.ps1 index 252bcc1..2781a02 100644 --- a/scripts/Install-SguEnrollmentGuard.ps1 +++ b/scripts/Install-SguEnrollmentGuard.ps1 @@ -32,6 +32,7 @@ $sourceScripts = @( 'Enable-LabRemoteAccess.ps1', 'Enable-SguClientMonitoring.ps1', 'Install-SguRustDeskClient.ps1', + 'Set-SguStandardLocalUser.ps1', 'Test-SguClientEnrollment.ps1', 'Repair-SguClientEnrollment.ps1' ) diff --git a/scripts/New-SguBootstrapPackages.ps1 b/scripts/New-SguBootstrapPackages.ps1 index 38c6eb9..02160c5 100644 --- a/scripts/New-SguBootstrapPackages.ps1 +++ b/scripts/New-SguBootstrapPackages.ps1 @@ -118,6 +118,7 @@ $clientScripts = @( 'Install-SguRustDeskClient.ps1', 'Register-SguClientCertificate.ps1', 'Repair-SguClientEnrollment.ps1', + 'Set-SguStandardLocalUser.ps1', 'Test-SguClientEnrollment.ps1' ) foreach ($scriptName in $clientScripts) { diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index 36b0224..4803f08 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -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. - 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 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 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. diff --git a/scripts/Repair-SguClientEnrollment.ps1 b/scripts/Repair-SguClientEnrollment.ps1 index c163c99..d23d9bf 100644 --- a/scripts/Repair-SguClientEnrollment.ps1 +++ b/scripts/Repair-SguClientEnrollment.ps1 @@ -11,9 +11,11 @@ $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1' $remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1' $monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1' $rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1' +$localUserScript = Join-Path $enrollmentRoot 'Set-SguStandardLocalUser.ps1' $before = & $testScript if (-not $before.IsValid) { + & $localUserScript | Out-Null $installParams = @{ PublishPath = [string]$configuration.PublishPath BrokerEndpoint = [string]$configuration.BrokerEndpoint diff --git a/scripts/Set-SguStandardLocalUser.ps1 b/scripts/Set-SguStandardLocalUser.ps1 new file mode 100644 index 0000000..f4c02f0 --- /dev/null +++ b/scripts/Set-SguStandardLocalUser.ps1 @@ -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 +} diff --git a/scripts/Test-SguClientEnrollment.ps1 b/scripts/Test-SguClientEnrollment.ps1 index 58d03ac..dfc8183 100644 --- a/scripts/Test-SguClientEnrollment.ps1 +++ b/scripts/Test-SguClientEnrollment.ps1 @@ -19,6 +19,7 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' $interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' $settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json' $issues = [Collections.Generic.List[string]]::new() +$standardLocalUserName = 'alumno' $computer = Get-CimInstance Win32_ComputerSystem if ($RequireDomainJoined -and -not $computer.PartOfDomain) { @@ -87,6 +88,41 @@ if (-not $passwordProviderPreserved) { $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 try { $settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json @@ -207,6 +243,11 @@ $result = [pscustomobject]@{ LastSignedInUserHidden = $lastSignedInUserHidden LocalUserEnumerationDisabled = $localUserEnumerationDisabled PasswordProviderPreserved = $passwordProviderPreserved + StandardLocalUserPresent = $standardLocalUserPresent + StandardLocalUserEnabled = $standardLocalUserEnabled + StandardLocalUserIsAdministrator = $standardLocalUserIsAdministrator + StandardLocalUserInUsersGroup = $standardLocalUserInUsersGroup + StandardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires SettingsPresent = [bool]$settings ClientCertificatePresent = [bool]$clientCertificatePresent ServerCertificateTrusted = $serverCertificateTrusted diff --git a/src/SGU.AuthBroker.Core/Profiles/SguAdministrativeLocationSelection.cs b/src/SGU.AuthBroker.Core/Profiles/SguAdministrativeLocationSelection.cs new file mode 100644 index 0000000..52e1a6d --- /dev/null +++ b/src/SGU.AuthBroker.Core/Profiles/SguAdministrativeLocationSelection.cs @@ -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; +} diff --git a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs index 43dc2ea..02bfe20 100644 --- a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs +++ b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Mail; using System.Text; +using System.Text.Json; namespace SGU.AuthBroker.Core.Profiles; @@ -102,7 +103,14 @@ public static class SguProfileParser 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); @@ -112,18 +120,79 @@ public static class SguProfileParser string? interiorNumber = NormalizeAddressUnit( ExtractInputValue(html, AdministrativeInteriorNumberId)); string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber); - string? neighborhood = NormalizeTitle( - ExtractSelectedOptionText(html, AdministrativeNeighborhoodId), - 256); + string? neighborhood = NormalizeTitle(FirstNonEmpty( + selection?.NeighborhoodName, + 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( StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null), - City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128), - State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128), - PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId))); + City: city, + State: state, + PostalCode: postalCode); 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) { ArgumentNullException.ThrowIfNull(html); @@ -350,6 +419,186 @@ public static class SguProfileParser 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("', optionStart); + int optionEnd = optionTagEnd < 0 + ? -1 + : optionsHtml.IndexOf(" 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) => FindOpeningTagByAttribute(html, tagName, "id", id); diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs index a8d79bb..e6297d2 100644 --- a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs +++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Net; +using System.Text; +using System.Text.Json; using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Profiles; @@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator pages.Add(( options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal)); - pages.Add(( - options.AdministrativeLocationProfilePath, - SguProfileParser.ParseAdministrativeLocation)); foreach ((string path, Func 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.", identity.Role, elapsed.ElapsedMilliseconds); - break; + return profile; } 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 TryEnrichStaffLocationAsync( + HttpClient client, + InstitutionalProfile profile, + UserIdentity identity, + HashSet 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 + { + ["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 + { + ["pIdEstado"] = selection.StateId + }, + allowedHosts, + timeoutToken).ConfigureAwait(false); + } + + string? neighborhoodsJson = await TryPostProfilePageMethodAsync( + client, + GetAdministrativeLocationMethodUri("GetColoniasListado"), + locationPageUri, + new Dictionary + { + ["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; } @@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator return null; } + private async Task TryPostProfilePageMethodAsync( + HttpClient client, + Uri requestedUri, + Uri referrerUri, + IReadOnlyDictionary payload, + HashSet 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( Uri uri, CredentialCache credentialCache, @@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator private Uri GetProfileUri(string 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 TryReadProfileAsync( HttpResponseMessage response, UserIdentity identity, diff --git a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs index 2eb8144..b616498 100644 --- a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs +++ b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs @@ -228,6 +228,65 @@ public sealed class SguProfileParserTests Assert.Null(profile.Email); } + [Fact] + public void ResolvesAdministrativeAddressFromPageMethodIdentifiers() + { + const string 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] public void AdministrativePagesOverlayTheVerifiedIncidentsProfile() { diff --git a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs index 4a546de..bebcffd 100644 --- a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs +++ b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs @@ -145,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests + """), + 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); @@ -180,9 +190,17 @@ public sealed class NtlmCredentialValidatorTests "/psulsa/", "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.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); + Assert.Equal("{\"CodigoPostal\":\"01000\"}", handler.RequestBodies[5]); + Assert.Equal("{\"pIdEstado\":\"09\"}", handler.RequestBodies[6]); + Assert.Equal( + "{\"pIdEstado\":\"\",\"pLocalidad\":\"\",\"CodigoPostal\":\"01000\"}", + handler.RequestBodies[7]); } [Fact] @@ -242,14 +260,24 @@ public sealed class NtlmCredentialValidatorTests + """), + 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); @@ -280,7 +308,10 @@ public sealed class NtlmCredentialValidatorTests "/psulsa/menu.aspx", "/psulsa/gadmon/nomina/consultanomina.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); } @@ -363,18 +394,29 @@ public sealed class NtlmCredentialValidatorTests 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 readonly Queue responses = new(responses); public List RequestPaths { get; } = []; + public List RequestBodies { get; } = []; + protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); RequestPaths.Add(request.RequestUri!.AbsolutePath); + RequestBodies.Add(request.Content is null + ? null + : request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult()); if (responses.Count == 0) { throw new InvalidOperationException("The validator sent more requests than expected.");