Enrich administrative profiles from SGU
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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
|
||||
|
||||
Reference in New Issue
Block a user