Enrich administrative profiles from SGU

This commit is contained in:
2026-09-03 12:21:43 -06:00
parent d50a4d9895
commit 403132f869
12 changed files with 609 additions and 22 deletions
+6 -3
View File
@@ -24,9 +24,12 @@ Directory synchronization, deployment scripts, and tests.
No derived password is created. Passwords are not written to a database, file,
event log, application log, command line, or response.
For administrative accounts, profile enrichment targets the read-only incident
overview and reads only the employee number, name, account type/status, email,
job title, and department from their stable element IDs. Incident, calendar,
For administrative accounts, profile enrichment first verifies the employee
number against the incident overview, then reads the structured given names and
surnames from `datos/personales.aspx` and the postal address from
`datos/ubicacion.aspx` in the same authenticated session. Account type/status,
email, job title, and department remain sourced from the incident overview.
Birth date, identifiers, telephone, emergency-contact, incident, calendar,
photo, and manager fields are ignored. Student enrichment targets the read-only
student information page and reads only the matching student number, structured
name, email, career, and postal address. The career becomes an AD title in the
+9 -4
View File
@@ -25,8 +25,12 @@ returns the NTLM challenge without waiting for the slow application pages. A
`401` or `403` rejects the credential; an allowed `2xx` or `3xx` proves that IIS
accepted it. The broker then makes a separately bounded, best-effort GET to the
administrative incident overview for `AD`, the student information page for
`AL`, or the portal menu for `DO`. A profile timeout does not invalidate an
already authenticated credential. NTLM may still require its normal
`AL`, or the portal menu for `DO`. After the incident page confirms an `AD`
employee number, two additional GETs in the same in-memory session read the
structured name from `datos/personales.aspx` and the address from
`datos/ubicacion.aspx`. A supplemental failure preserves fields already
collected, and a profile timeout does not invalidate an already authenticated
credential. NTLM may still require its normal
challenge/response round trips on the connection. Transient portal cookies are
kept only in an in-memory per-request container and are never persisted or
returned to the client.
@@ -54,8 +58,9 @@ When the authenticated HTML exposes recognized stable IDs, the broker also
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
`postalCode` attributes. Administrative and student numbers must match the six
numeric digits of the requested identity before any scraped metadata is
trusted. Student faculty/department is deliberately left unset because the
numeric digits of the requested identity before any role-specific metadata is
trusted. Administrative personal and location pages are accepted only after
that incident-page match. Student faculty/department is deliberately left unset because the
verified page does not expose it. Missing metadata does not clear existing AD
values and never changes the password outcome.
+7 -6
View File
@@ -46,13 +46,14 @@ Eso es comportamiento esperado, no una caída del servicio.
## Timeouts y recuperación
- El Credential Provider espera hasta **35 segundos** por el broker.
- El Credential Provider espera hasta **90 segundos** por el broker.
- El broker permite hasta **20 segundos** para el desafío NTLM ligero de
`/psulsa/` y hasta **10 segundos totales** adicionales para enriquecer el
perfil. La consulta de perfil es best effort: si la página pesada queda
congelada después de que NTLM aceptó la contraseña, el usuario se sincroniza
sin metadatos y puede iniciar sesión. El máximo combinado queda por debajo de
los 35 segundos del cliente.
`/psulsa/`. El enriquecimiento usa el límite total independiente
`ProfileTimeoutSeconds` —**90 segundos** en la configuración del laboratorio—
y conserva los campos que alcance a obtener si una página administrativa se
retrasa o falla. El Credential Provider mantiene su propio límite de **90
segundos**: si SGU excede ese presupuesto, Windows continúa por el fallback
normal de AD o credenciales de dominio en caché.
- El instalador configura recuperación del servicio con reinicios a los 5, 15
y 60 segundos y reinicia el contador de fallos después de 24 horas.
- Si el broker o SGU no está disponible, el Credential Provider entrega la
+12 -4
View File
@@ -30,8 +30,14 @@
## Profile minimization
- Administrative enrichment reads only employee number, display name,
employee type/status, email, job title, and department from known element IDs.
- Administrative enrichment first verifies the employee number and reads
employee type/status, email, job title, and department from the incident
overview. Only after that match, it reads given names and paternal/maternal
surnames from the personal page plus street, exterior/interior number,
neighborhood, locality, state, and postal code from the location page.
- Administrative birth date, RFC, CURP, sex, blood type, marital status,
nationality, telephone, email lists, housing type, and emergency-contact
fields are ignored.
- Student enrichment reads only the matching student number, given names,
paternal/maternal surnames, email, career, street, neighborhood,
city/municipality, state, and postal code from known element IDs.
@@ -40,11 +46,13 @@
- Incident details, calendars, photographs, manager names, and manager positions
are deliberately ignored.
- The employee or student number must match the authenticated `AD` or `AL` key
before metadata is synchronized.
before role-specific metadata is synchronized. The two supplemental
administrative pages are never requested unless the incident page supplied
the matching employee number.
- If SGU changes its HTML, authentication and exact-password synchronization
continue without enrichment; existing AD metadata is not erased.
- Slow profile pages cannot change an accepted credential into a rejection. The
lightweight NTLM root is authoritative; enrichment has its own shorter total
lightweight NTLM root is authoritative; enrichment has its own independent total
timeout.
Lab self-signed certificates are appropriate only for the isolated VM network.
+7 -1
View File
@@ -17,6 +17,10 @@ param(
[ValidatePattern('^/')]
[string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx',
[ValidatePattern('^/')]
[string]$AdministrativePersonalProfilePath = '/psulsa/gadmon/capitalhumano/datos/personales.aspx',
[ValidatePattern('^/')]
[string]$AdministrativeLocationProfilePath = '/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx',
[ValidatePattern('^/')]
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
[ValidatePattern('^/')]
[string]$MenuProfilePath = '/psulsa/menu.aspx',
@@ -32,7 +36,7 @@ param(
[ValidateRange(10, 60)]
[int]$NtlmTimeoutSeconds = 20,
[ValidateRange(2, 90)]
[int]$ProfileTimeoutSeconds = 60,
[int]$ProfileTimeoutSeconds = 90,
[switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab
)
@@ -149,6 +153,8 @@ $productionSettings = @{
MaxRedirects = 5
AuthenticationPath = $AuthenticationPath
AdministrativeProfilePath = $AdministrativeProfilePath
AdministrativePersonalProfilePath = $AdministrativePersonalProfilePath
AdministrativeLocationProfilePath = $AdministrativeLocationProfilePath
StudentProfilePath = $StudentProfilePath
MenuProfilePath = $MenuProfilePath
MaxProfileBytes = $MaxProfileBytes
@@ -27,4 +27,23 @@ public sealed record InstitutionalProfile(
City is not null ||
State is not null ||
PostalCode is not null;
public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
values is null
? this
: this with
{
EmployeeNumber = values.EmployeeNumber ?? EmployeeNumber,
DisplayName = values.DisplayName ?? DisplayName,
GivenName = values.GivenName ?? GivenName,
Surname = values.Surname ?? Surname,
Email = values.Email ?? Email,
EmployeeType = values.EmployeeType ?? EmployeeType,
JobTitle = values.JobTitle ?? JobTitle,
Department = values.Department ?? Department,
StreetAddress = values.StreetAddress ?? StreetAddress,
City = values.City ?? City,
State = values.State ?? State,
PostalCode = values.PostalCode ?? PostalCode
};
}
@@ -11,6 +11,16 @@ public static class SguProfileParser
private const string EmailId = "ctl00_contenedor_decEncabezado_lblCorreo";
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
private const string AdministrativePostalCodeId = "ctl00_contenedor_txtCP";
private const string AdministrativeStateId = "ctl00_contenedor_ddlEstado";
private const string AdministrativeCityId = "ctl00_contenedor_ddlLocalidad";
private const string AdministrativeNeighborhoodId = "ctl00_contenedor_ddlColonia";
private const string MenuNameId = "ctl00_lblNombreUsuario";
private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP";
private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP";
@@ -56,6 +66,51 @@ public static class SguProfileParser
return profile.HasValues ? profile : null;
}
public static InstitutionalProfile? ParseAdministrativePersonal(string html)
{
ArgumentNullException.ThrowIfNull(html);
string? givenName = NormalizeName(ExtractInputValue(html, AdministrativeGivenNameId), 64);
string? paternalSurname = NormalizeSurname(
ExtractInputValue(html, AdministrativePaternalSurnameId),
64);
string? maternalSurname = NormalizeSurname(
ExtractInputValue(html, AdministrativeMaternalSurnameId),
64);
string? surname = NormalizeSurname(
JoinNonEmpty(" ", paternalSurname, maternalSurname),
64);
string? displayName = NormalizeName(JoinNonEmpty(" ", givenName, surname), 256);
InstitutionalProfile profile = new(
DisplayName: displayName,
GivenName: givenName,
Surname: surname);
return profile.HasValues ? profile : null;
}
public static InstitutionalProfile? ParseAdministrativeLocation(string html)
{
ArgumentNullException.ThrowIfNull(html);
string? street = NormalizeTitle(ExtractInputValue(html, AdministrativeStreetId), 512);
string? exteriorNumber = NormalizeAddressUnit(
ExtractInputValue(html, AdministrativeExteriorNumberId));
string? interiorNumber = NormalizeAddressUnit(
ExtractInputValue(html, AdministrativeInteriorNumberId));
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
string? neighborhood = NormalizeTitle(
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
256);
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)));
return profile.HasValues ? profile : null;
}
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{
ArgumentNullException.ThrowIfNull(html);
@@ -162,6 +217,158 @@ public static class SguProfileParser
return null;
}
private static string? ExtractInputValue(string html, string id)
{
string? openingTag = FindOpeningTag(html, "input", id);
return openingTag is null
? null
: NormalizeText(ExtractAttributeValue(openingTag, "value") ?? string.Empty);
}
private static string? ExtractSelectedOptionText(string html, string id)
{
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];
string? selectedValue = ExtractAttributeValue(openingTag, "value");
List<string> nonPlaceholderOptions = [];
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? optionText = NormalizeText(optionsHtml[(optionTagEnd + 1)..optionEnd]);
string? optionValue = ExtractAttributeValue(optionTag, "value");
if (optionText is not null &&
!optionText.StartsWith("Seleccione", StringComparison.OrdinalIgnoreCase))
{
nonPlaceholderOptions.Add(optionText);
}
if (optionText is not null &&
(HasAttribute(optionTag, "selected") ||
(selectedValue is not null &&
string.Equals(optionValue, selectedValue, StringComparison.Ordinal))))
{
return optionText;
}
searchFrom = optionEnd + "</option".Length;
}
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null;
}
private static string? FindOpeningTag(string html, string tagName, string id)
{
foreach (char quote in new[] { '"', '\'' })
{
string marker = $"id={quote}{id}{quote}";
int searchFrom = 0;
while (searchFrom < html.Length)
{
int idIndex = html.IndexOf(marker, searchFrom, StringComparison.OrdinalIgnoreCase);
if (idIndex < 0)
{
break;
}
int tagStart = html.LastIndexOf($"<{tagName}", idIndex, StringComparison.OrdinalIgnoreCase);
int precedingTagEnd = html.LastIndexOf('>', idIndex);
if (tagStart > precedingTagEnd)
{
int tagEnd = html.IndexOf('>', idIndex);
if (tagEnd >= 0)
{
return html[tagStart..(tagEnd + 1)];
}
}
searchFrom = idIndex + marker.Length;
}
}
return null;
}
private static string? ExtractAttributeValue(string openingTag, string attributeName)
{
foreach (char quote in new[] { '"', '\'' })
{
string marker = $"{attributeName}={quote}";
int valueStart = openingTag.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (valueStart < 0)
{
continue;
}
valueStart += marker.Length;
int valueEnd = openingTag.IndexOf(quote, valueStart);
if (valueEnd >= 0)
{
return WebUtility.HtmlDecode(openingTag[valueStart..valueEnd]);
}
}
return null;
}
private static bool HasAttribute(string openingTag, string attributeName)
{
int searchFrom = 0;
while (searchFrom < openingTag.Length)
{
int index = openingTag.IndexOf(attributeName, searchFrom, StringComparison.OrdinalIgnoreCase);
if (index < 0)
{
return false;
}
bool validStart = index == 0 ||
char.IsWhiteSpace(openingTag[index - 1]) ||
openingTag[index - 1] == '<';
int after = index + attributeName.Length;
bool validEnd = after >= openingTag.Length ||
char.IsWhiteSpace(openingTag[after]) ||
openingTag[after] is '=' or '>' or '/';
if (validStart && validEnd)
{
return true;
}
searchFrom = after;
}
return false;
}
private static string? NormalizeText(string htmlFragment)
{
StringBuilder withoutTags = new(htmlFragment.Length);
@@ -285,6 +492,28 @@ public static class SguProfileParser
return Limit(string.Join("\r\n", lines), 1024);
}
private static string? BuildAdministrativeStreetLine(
string? street,
string? exteriorNumber,
string? interiorNumber)
{
string? line = JoinNonEmpty(" ", street, exteriorNumber);
if (line is null)
{
return null;
}
return interiorNumber is null
? line
: $"{line}, Int. {interiorNumber}";
}
private static string? NormalizeAddressUnit(string? value)
{
string? candidate = Limit(value, 32);
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
}
private static void AddDistinct(List<string> values, string? candidate)
{
if (!string.IsNullOrWhiteSpace(candidate) &&
+9 -1
View File
@@ -47,6 +47,8 @@ public sealed class BrokerOptions
{
Ntlm.AuthenticationPath,
Ntlm.AdministrativeProfilePath,
Ntlm.AdministrativePersonalProfilePath,
Ntlm.AdministrativeLocationProfilePath,
Ntlm.StudentProfilePath,
Ntlm.MenuProfilePath
})
@@ -122,7 +124,7 @@ public sealed class NtlmOptions
public int TimeoutSeconds { get; init; } = 20;
public int ProfileTimeoutSeconds { get; init; } = 60;
public int ProfileTimeoutSeconds { get; init; } = 90;
public int MaxRedirects { get; init; } = 5;
@@ -131,6 +133,12 @@ public sealed class NtlmOptions
public string AdministrativeProfilePath { get; init; } =
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
public string AdministrativePersonalProfilePath { get; init; } =
"/psulsa/gadmon/capitalhumano/datos/personales.aspx";
public string AdministrativeLocationProfilePath { get; init; } =
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx";
public string StudentProfilePath { get; init; } =
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
@@ -325,6 +325,21 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
response,
identity,
timeout.Token).ConfigureAwait(false);
if (identity.Role == InstitutionalRole.Administrative &&
string.Equals(
profile?.EmployeeNumber,
identity.NumericId,
StringComparison.Ordinal))
{
profile = await TryEnrichAdministrativeProfileAsync(
client,
profile!,
allowedHosts,
timeout.Token,
cancellationToken,
elapsed).ConfigureAwait(false);
}
if (profile is null)
{
logger.LogWarning(
@@ -379,6 +394,104 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return null;
}
private async Task<InstitutionalProfile> TryEnrichAdministrativeProfileAsync(
HttpClient client,
InstitutionalProfile verifiedProfile,
HashSet<string> allowedHosts,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken,
Stopwatch elapsed)
{
InstitutionalProfile profile = verifiedProfile;
(string Path, Func<string, InstitutionalProfile?> Parser)[] pages =
[
(options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal),
(options.AdministrativeLocationProfilePath, SguProfileParser.ParseAdministrativeLocation)
];
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
{
try
{
string? html = await TryFetchAdditionalProfilePageAsync(
client,
GetProfileUri(path),
allowedHosts,
timeoutToken).ConfigureAwait(false);
profile = profile.Overlay(html is null ? null : parser(html));
}
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{
logger.LogWarning(
"SGU administrative profile enrichment reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
elapsed.ElapsedMilliseconds);
break;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"An optional SGU administrative profile page failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
elapsed.ElapsedMilliseconds);
}
}
return profile;
}
private async Task<string?> TryFetchAdditionalProfilePageAsync(
HttpClient client,
Uri requestedUri,
HashSet<string> allowedHosts,
CancellationToken cancellationToken)
{
Uri current = requestedUri;
for (int hop = 0; hop <= options.MaxRedirects; hop++)
{
if (!IsAllowedHttpsUri(current, allowedHosts))
{
return null;
}
using HttpRequestMessage request = new(HttpMethod.Get, current);
using HttpResponseMessage response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
int statusCode = (int)response.StatusCode;
if (statusCode is >= 300 and < 400)
{
Uri? redirect = ResolveAllowedRedirect(current, response, allowedHosts);
if (redirect is null)
{
return null;
}
await DrainResponseAsync(response, cancellationToken).ConfigureAwait(false);
current = redirect;
continue;
}
if (statusCode is >= 200 and < 300)
{
return await ReadLimitedStringAsync(
response.Content,
options.MaxProfileBytes,
cancellationToken).ConfigureAwait(false);
}
logger.LogWarning(
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
requestedUri.AbsolutePath,
statusCode);
return null;
}
logger.LogWarning(
"Optional SGU profile page {Path} exceeded the redirect limit.",
requestedUri.AbsolutePath);
return null;
}
private static void AddCredential(
Uri uri,
CredentialCache credentialCache,
@@ -472,6 +585,9 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return new Uri(endpoint, path);
}
private Uri GetProfileUri(string path) =>
new(new Uri(options.Endpoint, UriKind.Absolute), path);
private async Task<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,
+3 -1
View File
@@ -30,10 +30,12 @@
"Endpoint": "https://sgu.ulsa.edu.mx/",
"Domain": "",
"TimeoutSeconds": 20,
"ProfileTimeoutSeconds": 60,
"ProfileTimeoutSeconds": 90,
"MaxRedirects": 5,
"AuthenticationPath": "/psulsa/",
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"AdministrativePersonalProfilePath": "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"AdministrativeLocationProfilePath": "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
"MenuProfilePath": "/psulsa/menu.aspx",
"MaxProfileBytes": 524288,
@@ -56,6 +56,100 @@ public sealed class SguProfileParserTests
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999"));
}
[Fact]
public void ParsesStructuredAdministrativeNameWithoutReadingOtherPersonalData()
{
const string html = """
<html><body>
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O&#39;CONNOR" />
<input id="ctl00_contenedor_txtCURP" value="DATO-QUE-NO-DEBE-EXTRAERSE" />
<select id="ctl00_contenedor_ddlNacimientoFecha_ddlYear">
<option selected="selected">1990</option>
</select>
</body></html>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
Assert.NotNull(profile);
Assert.Equal("María del Carmen", profile.GivenName);
Assert.Equal("de la Fuente O'Connor", profile.Surname);
Assert.Equal("María del Carmen de la Fuente O'Connor", profile.DisplayName);
Assert.Null(profile.EmployeeNumber);
Assert.Null(profile.Email);
}
[Fact]
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
{
const string html = """
<html><body>
<input id='ctl00_contenedor_txtCalle' value='AVENIDA DE LA UNIVERSIDAD' />
<input id='ctl00_contenedor_txtNoExt' value='123' />
<input id='ctl00_contenedor_txtNoInt' value='B-4' />
<input id='ctl00_contenedor_txtCP' value='8500' />
<select id='ctl00_contenedor_ddlEstado'>
<option value='0'>Seleccione...</option>
<option selected='selected' value='9'>CIUDAD DE MÉXICO</option>
</select>
<select id='ctl00_contenedor_ddlLocalidad'>
<option value='1'>AZCAPOTZALCO</option>
<option selected='selected' value='4'>BENITO JUÁREZ</option>
</select>
<select id='ctl00_contenedor_ddlColonia'>
<option value='10'>COLONIA DEL VALLE</option>
</select>
<input id='ctl00_contenedor_txtTelefono' value='5555555555' />
<textarea id='ctl00_contenedor_txtDirEmergencia'>NO EXTRAER</textarea>
</body></html>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativeLocation(html);
Assert.NotNull(profile);
Assert.Equal(
"Avenida de la Universidad 123, Int. B-4\r\nColonia del Valle",
profile.StreetAddress);
Assert.Equal("Benito Juárez", profile.City);
Assert.Equal("Ciudad de México", profile.State);
Assert.Equal("08500", profile.PostalCode);
Assert.Null(profile.Email);
}
[Fact]
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
{
InstitutionalProfile verified = new(
EmployeeNumber: "017045",
DisplayName: "Nombre Anterior",
Email: "persona@lasalle.mx",
JobTitle: "Analista",
Department: "Ingeniería");
InstitutionalProfile personal = new(
DisplayName: "María del Carmen de la Fuente",
GivenName: "María del Carmen",
Surname: "de la Fuente");
InstitutionalProfile location = new(
StreetAddress: "Calle Uno 10",
City: "Ciudad de México",
State: "Ciudad de México",
PostalCode: "01000");
InstitutionalProfile combined = verified.Overlay(personal).Overlay(location);
Assert.Equal("017045", combined.EmployeeNumber);
Assert.Equal("María del Carmen de la Fuente", combined.DisplayName);
Assert.Equal("María del Carmen", combined.GivenName);
Assert.Equal("de la Fuente", combined.Surname);
Assert.Equal("persona@lasalle.mx", combined.Email);
Assert.Equal("Analista", combined.JobTitle);
Assert.Equal("Ingeniería", combined.Department);
Assert.Equal("Calle Uno 10", combined.StreetAddress);
Assert.Equal("01000", combined.PostalCode);
}
[Fact]
public void ParsesTheRequiredStudentIdentityCareerAndAddressFields()
{
@@ -11,6 +11,12 @@ namespace SGU.AuthBroker.Tests;
public sealed class NtlmCredentialValidatorTests
{
private static readonly UserIdentity Administrative = new(
"AD017045",
"AD",
"017045",
InstitutionalRole.Administrative);
private static readonly UserIdentity Student = new(
"AL123456",
"AL",
@@ -101,6 +107,91 @@ public sealed class NtlmCredentialValidatorTests
handler.RequestPaths[4]);
}
[Fact]
public async Task VerifiedAdministrativeProfileIsEnrichedFromPersonalAndLocationPages()
{
SequenceHandler handler = new(
Challenge(),
Response(HttpStatusCode.OK),
Response(
HttpStatusCode.OK,
"""
<span id="ctl00_contenedor_decEncabezado_lblNombre">017045 - NOMBRE ANTERIOR</span>
<span id="ctl00_contenedor_decEncabezado_lblCorreo">persona@lasalle.mx</span>
<span id="ctl00_contenedor_decEncabezado_lblPuesto">ANALISTA</span>
"""),
Response(
HttpStatusCode.OK,
"""
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
"""),
Response(
HttpStatusCode.OK,
"""
<input id="ctl00_contenedor_txtCalle" value="CALLE DEL SOL" />
<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>
</select>
<select id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option>
</select>
<select id="ctl00_contenedor_ddlColonia">
<option>FLORIDA</option>
</select>
"""));
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Administrative,
"test-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Valid, result.Status);
Assert.NotNull(result.Profile);
Assert.Equal("017045", result.Profile.EmployeeNumber);
Assert.Equal("María del Carmen de la Fuente O'Connor", result.Profile.DisplayName);
Assert.Equal("María del Carmen", result.Profile.GivenName);
Assert.Equal("de la Fuente O'Connor", result.Profile.Surname);
Assert.Equal("persona@lasalle.mx", result.Profile.Email);
Assert.Equal("Analista", result.Profile.JobTitle);
Assert.Equal("Calle del Sol 15\r\nFlorida", result.Profile.StreetAddress);
Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(
[
"/psulsa/",
"/psulsa/",
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
],
handler.RequestPaths);
}
[Fact]
public async Task AdministrativeSupplementalPagesRequireAVerifiedEmployeeNumber()
{
SequenceHandler handler = new(
Challenge(),
Response(HttpStatusCode.OK),
Response(HttpStatusCode.OK, "<html><body>Datos inesperados</body></html>"));
NtlmCredentialValidator validator = CreateValidator(handler);
NtlmValidationResult result = await validator.ValidateAsync(
Administrative,
"test-password",
TestContext.Current.CancellationToken);
Assert.Equal(NtlmValidationStatus.Valid, result.Status);
Assert.Null(result.Profile);
Assert.Equal(3, handler.RequestPaths.Count);
}
private static NtlmCredentialValidator CreateValidator(SequenceHandler handler)
{
BrokerOptions options = new()
@@ -109,6 +200,9 @@ public sealed class NtlmCredentialValidatorTests
{
Endpoint = "https://sgu.example/",
AuthenticationPath = "/psulsa/",
AdministrativeProfilePath = "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
AdministrativePersonalProfilePath = "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
AdministrativeLocationProfilePath = "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
AllowedRedirectHosts = ["sgu.example"],
TimeoutSeconds = 5,
@@ -136,10 +230,12 @@ public sealed class NtlmCredentialValidatorTests
return response;
}
private static HttpResponseMessage Response(HttpStatusCode statusCode) =>
private static HttpResponseMessage Response(
HttpStatusCode statusCode,
string content = "<html></html>") =>
new(statusCode)
{
Content = new StringContent("<html></html>")
Content = new StringContent(content)
};
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler