Resolve SGU staff addresses and provision local student user

This commit is contained in:
2026-09-09 09:48:06 -06:00
parent 8baa47fe1e
commit a6bd625e4e
15 changed files with 728 additions and 28 deletions
+8 -1
View File
@@ -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)
+5 -1
View File
@@ -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
+10 -7
View File
@@ -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.
+4
View File
@@ -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
}
+1
View File
@@ -32,6 +32,7 @@ $sourceScripts = @(
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Install-SguRustDeskClient.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1'
)
+1
View File
@@ -118,6 +118,7 @@ $clientScripts = @(
'Install-SguRustDeskClient.ps1',
'Register-SguClientCertificate.ps1',
'Repair-SguClientEnrollment.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1'
)
foreach ($scriptName in $clientScripts) {
+2
View File
@@ -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.
+2
View File
@@ -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
+84
View File
@@ -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
}
+41
View File
@@ -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
@@ -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.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("</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)
=> FindOpeningTagByAttribute(html, tagName, "id", id);
@@ -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<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.",
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<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;
}
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
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(
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<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,
@@ -228,6 +228,65 @@ public sealed class SguProfileParserTests
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]
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
{
@@ -145,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" />
<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 id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option>
<option selected="selected" value="0">Seleccione alguna localidad...</option>
</select>
<select id="ctl00_contenedor_ddlColonia">
<option>FLORIDA</option>
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
</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);
@@ -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
<input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" />
<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 id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option>
<option selected="selected" value="0">Seleccione alguna localidad...</option>
</select>
<select id="ctl00_contenedor_ddlColonia">
<option selected="selected">FLORIDA</option>
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
</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);
@@ -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<HttpResponseMessage> responses = new(responses);
public List<string> RequestPaths { get; } = [];
public List<string?> RequestBodies { get; } = [];
protected override Task<HttpResponseMessage> 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.");