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, No derived password is created. Passwords are not written to a database, file,
event log, application log, command line, or response. event log, application log, command line, or response.
For administrative accounts, profile enrichment targets the read-only incident For administrative accounts, profile enrichment first verifies the employee
overview and reads only the employee number, name, account type/status, email, number against the incident overview, then reads the structured given names and
job title, and department from their stable element IDs. Incident, calendar, 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 photo, and manager fields are ignored. Student enrichment targets the read-only
student information page and reads only the matching student number, structured 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 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 `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 accepted it. The broker then makes a separately bounded, best-effort GET to the
administrative incident overview for `AD`, the student information page for administrative incident overview for `AD`, the student information page for
`AL`, or the portal menu for `DO`. A profile timeout does not invalidate an `AL`, or the portal menu for `DO`. After the incident page confirms an `AD`
already authenticated credential. NTLM may still require its normal 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 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 kept only in an in-memory per-request container and are never persisted or
returned to the client. 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`, updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and `department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
`postalCode` attributes. Administrative and student numbers must match the six `postalCode` attributes. Administrative and student numbers must match the six
numeric digits of the requested identity before any scraped metadata is numeric digits of the requested identity before any role-specific metadata is
trusted. Student faculty/department is deliberately left unset because the 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 verified page does not expose it. Missing metadata does not clear existing AD
values and never changes the password outcome. 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 ## 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 - El broker permite hasta **20 segundos** para el desafío NTLM ligero de
`/psulsa/` y hasta **10 segundos totales** adicionales para enriquecer el `/psulsa/`. El enriquecimiento usa el límite total independiente
perfil. La consulta de perfil es best effort: si la página pesada queda `ProfileTimeoutSeconds` —**90 segundos** en la configuración del laboratorio—
congelada después de que NTLM aceptó la contraseña, el usuario se sincroniza y conserva los campos que alcance a obtener si una página administrativa se
sin metadatos y puede iniciar sesión. El máximo combinado queda por debajo de retrasa o falla. El Credential Provider mantiene su propio límite de **90
los 35 segundos del cliente. 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 - 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. 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 - Si el broker o SGU no está disponible, el Credential Provider entrega la
+12 -4
View File
@@ -30,8 +30,14 @@
## Profile minimization ## Profile minimization
- Administrative enrichment reads only employee number, display name, - Administrative enrichment first verifies the employee number and reads
employee type/status, email, job title, and department from known element IDs. 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, - Student enrichment reads only the matching student number, given names,
paternal/maternal surnames, email, career, street, neighborhood, paternal/maternal surnames, email, career, street, neighborhood,
city/municipality, state, and postal code from known element IDs. city/municipality, state, and postal code from known element IDs.
@@ -40,11 +46,13 @@
- Incident details, calendars, photographs, manager names, and manager positions - Incident details, calendars, photographs, manager names, and manager positions
are deliberately ignored. are deliberately ignored.
- The employee or student number must match the authenticated `AD` or `AL` key - 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 - If SGU changes its HTML, authentication and exact-password synchronization
continue without enrichment; existing AD metadata is not erased. continue without enrichment; existing AD metadata is not erased.
- Slow profile pages cannot change an accepted credential into a rejection. The - 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. timeout.
Lab self-signed certificates are appropriate only for the isolated VM network. Lab self-signed certificates are appropriate only for the isolated VM network.
+7 -1
View File
@@ -17,6 +17,10 @@ param(
[ValidatePattern('^/')] [ValidatePattern('^/')]
[string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx', [string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx',
[ValidatePattern('^/')] [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', [string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
[ValidatePattern('^/')] [ValidatePattern('^/')]
[string]$MenuProfilePath = '/psulsa/menu.aspx', [string]$MenuProfilePath = '/psulsa/menu.aspx',
@@ -32,7 +36,7 @@ param(
[ValidateRange(10, 60)] [ValidateRange(10, 60)]
[int]$NtlmTimeoutSeconds = 20, [int]$NtlmTimeoutSeconds = 20,
[ValidateRange(2, 90)] [ValidateRange(2, 90)]
[int]$ProfileTimeoutSeconds = 60, [int]$ProfileTimeoutSeconds = 90,
[switch]$CreateMissingOus, [switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab [switch]$DisableCertificateRevocationCheckForLab
) )
@@ -149,6 +153,8 @@ $productionSettings = @{
MaxRedirects = 5 MaxRedirects = 5
AuthenticationPath = $AuthenticationPath AuthenticationPath = $AuthenticationPath
AdministrativeProfilePath = $AdministrativeProfilePath AdministrativeProfilePath = $AdministrativeProfilePath
AdministrativePersonalProfilePath = $AdministrativePersonalProfilePath
AdministrativeLocationProfilePath = $AdministrativeLocationProfilePath
StudentProfilePath = $StudentProfilePath StudentProfilePath = $StudentProfilePath
MenuProfilePath = $MenuProfilePath MenuProfilePath = $MenuProfilePath
MaxProfileBytes = $MaxProfileBytes MaxProfileBytes = $MaxProfileBytes
@@ -27,4 +27,23 @@ public sealed record InstitutionalProfile(
City is not null || City is not null ||
State is not null || State is not null ||
PostalCode 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 EmailId = "ctl00_contenedor_decEncabezado_lblCorreo";
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto"; private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia"; 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 MenuNameId = "ctl00_lblNombreUsuario";
private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP"; private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP";
private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP"; private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP";
@@ -56,6 +66,51 @@ public static class SguProfileParser
return profile.HasValues ? profile : null; 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) public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{ {
ArgumentNullException.ThrowIfNull(html); ArgumentNullException.ThrowIfNull(html);
@@ -162,6 +217,158 @@ public static class SguProfileParser
return null; 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) private static string? NormalizeText(string htmlFragment)
{ {
StringBuilder withoutTags = new(htmlFragment.Length); StringBuilder withoutTags = new(htmlFragment.Length);
@@ -285,6 +492,28 @@ public static class SguProfileParser
return Limit(string.Join("\r\n", lines), 1024); 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) private static void AddDistinct(List<string> values, string? candidate)
{ {
if (!string.IsNullOrWhiteSpace(candidate) && if (!string.IsNullOrWhiteSpace(candidate) &&
+9 -1
View File
@@ -47,6 +47,8 @@ public sealed class BrokerOptions
{ {
Ntlm.AuthenticationPath, Ntlm.AuthenticationPath,
Ntlm.AdministrativeProfilePath, Ntlm.AdministrativeProfilePath,
Ntlm.AdministrativePersonalProfilePath,
Ntlm.AdministrativeLocationProfilePath,
Ntlm.StudentProfilePath, Ntlm.StudentProfilePath,
Ntlm.MenuProfilePath Ntlm.MenuProfilePath
}) })
@@ -122,7 +124,7 @@ public sealed class NtlmOptions
public int TimeoutSeconds { get; init; } = 20; public int TimeoutSeconds { get; init; } = 20;
public int ProfileTimeoutSeconds { get; init; } = 60; public int ProfileTimeoutSeconds { get; init; } = 90;
public int MaxRedirects { get; init; } = 5; public int MaxRedirects { get; init; } = 5;
@@ -131,6 +133,12 @@ public sealed class NtlmOptions
public string AdministrativeProfilePath { get; init; } = public string AdministrativeProfilePath { get; init; } =
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx"; "/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; } = public string StudentProfilePath { get; init; } =
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx"; "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
@@ -325,6 +325,21 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
response, response,
identity, identity,
timeout.Token).ConfigureAwait(false); 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) if (profile is null)
{ {
logger.LogWarning( logger.LogWarning(
@@ -379,6 +394,104 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return null; 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( private static void AddCredential(
Uri uri, Uri uri,
CredentialCache credentialCache, CredentialCache credentialCache,
@@ -472,6 +585,9 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return new Uri(endpoint, path); return new Uri(endpoint, path);
} }
private Uri GetProfileUri(string path) =>
new(new Uri(options.Endpoint, UriKind.Absolute), path);
private async Task<InstitutionalProfile?> TryReadProfileAsync( private async Task<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response, HttpResponseMessage response,
UserIdentity identity, UserIdentity identity,
+3 -1
View File
@@ -30,10 +30,12 @@
"Endpoint": "https://sgu.ulsa.edu.mx/", "Endpoint": "https://sgu.ulsa.edu.mx/",
"Domain": "", "Domain": "",
"TimeoutSeconds": 20, "TimeoutSeconds": 20,
"ProfileTimeoutSeconds": 60, "ProfileTimeoutSeconds": 90,
"MaxRedirects": 5, "MaxRedirects": 5,
"AuthenticationPath": "/psulsa/", "AuthenticationPath": "/psulsa/",
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx", "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", "StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
"MenuProfilePath": "/psulsa/menu.aspx", "MenuProfilePath": "/psulsa/menu.aspx",
"MaxProfileBytes": 524288, "MaxProfileBytes": 524288,
@@ -56,6 +56,100 @@ public sealed class SguProfileParserTests
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999")); 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] [Fact]
public void ParsesTheRequiredStudentIdentityCareerAndAddressFields() public void ParsesTheRequiredStudentIdentityCareerAndAddressFields()
{ {
@@ -11,6 +11,12 @@ namespace SGU.AuthBroker.Tests;
public sealed class NtlmCredentialValidatorTests public sealed class NtlmCredentialValidatorTests
{ {
private static readonly UserIdentity Administrative = new(
"AD017045",
"AD",
"017045",
InstitutionalRole.Administrative);
private static readonly UserIdentity Student = new( private static readonly UserIdentity Student = new(
"AL123456", "AL123456",
"AL", "AL",
@@ -101,6 +107,91 @@ public sealed class NtlmCredentialValidatorTests
handler.RequestPaths[4]); 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) private static NtlmCredentialValidator CreateValidator(SequenceHandler handler)
{ {
BrokerOptions options = new() BrokerOptions options = new()
@@ -109,6 +200,9 @@ public sealed class NtlmCredentialValidatorTests
{ {
Endpoint = "https://sgu.example/", Endpoint = "https://sgu.example/",
AuthenticationPath = "/psulsa/", 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", StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
AllowedRedirectHosts = ["sgu.example"], AllowedRedirectHosts = ["sgu.example"],
TimeoutSeconds = 5, TimeoutSeconds = 5,
@@ -136,10 +230,12 @@ public sealed class NtlmCredentialValidatorTests
return response; return response;
} }
private static HttpResponseMessage Response(HttpStatusCode statusCode) => private static HttpResponseMessage Response(
HttpStatusCode statusCode,
string content = "<html></html>") =>
new(statusCode) new(statusCode)
{ {
Content = new StringContent("<html></html>") Content = new StringContent(content)
}; };
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler