diff --git a/README.md b/README.md index 0ce9300..6cb10a1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index af72372..ff61d79 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/broker-operations.md b/docs/broker-operations.md index 84178f7..76c4899 100644 --- a/docs/broker-operations.md +++ b/docs/broker-operations.md @@ -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 diff --git a/docs/security.md b/docs/security.md index 3b93784..e96c323 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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. diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 index c37822c..a7cf44a 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -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 diff --git a/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs index 630b128..90ce581 100644 --- a/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs +++ b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs @@ -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 + }; } diff --git a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs index 03bc33f..fe2c22a 100644 --- a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs +++ b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs @@ -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(" nonPlaceholderOptions = []; + int searchFrom = 0; + while (searchFrom < optionsHtml.Length) + { + int optionStart = optionsHtml.IndexOf("', optionStart); + int optionEnd = optionTagEnd < 0 + ? -1 + : optionsHtml.IndexOf("', 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 values, string? candidate) { if (!string.IsNullOrWhiteSpace(candidate) && diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs index ec43b07..735bfe8 100644 --- a/src/SGU.AuthBroker/Options/BrokerOptions.cs +++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs @@ -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"; diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs index 3ab7b95..967b595 100644 --- a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs +++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs @@ -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 TryEnrichAdministrativeProfileAsync( + HttpClient client, + InstitutionalProfile verifiedProfile, + HashSet allowedHosts, + CancellationToken timeoutToken, + CancellationToken requestCancellationToken, + Stopwatch elapsed) + { + InstitutionalProfile profile = verifiedProfile; + (string Path, Func Parser)[] pages = + [ + (options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal), + (options.AdministrativeLocationProfilePath, SguProfileParser.ParseAdministrativeLocation) + ]; + + foreach ((string path, Func 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 TryFetchAdditionalProfilePageAsync( + HttpClient client, + Uri requestedUri, + HashSet 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 TryReadProfileAsync( HttpResponseMessage response, UserIdentity identity, diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json index 5907d36..94d4163 100644 --- a/src/SGU.AuthBroker/appsettings.json +++ b/src/SGU.AuthBroker/appsettings.json @@ -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, diff --git a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs index 0f0b7ea..2c61217 100644 --- a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs +++ b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs @@ -56,6 +56,100 @@ public sealed class SguProfileParserTests Assert.Null(SguProfileParser.ParseAdministrative(html, "999999")); } + [Fact] + public void ParsesStructuredAdministrativeNameWithoutReadingOtherPersonalData() + { + const string 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 = """ + + + + + + + + + + + + """; + + 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() { diff --git a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs index fc4b2dc..7adc271 100644 --- a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs +++ b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs @@ -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, + """ + 017045 - NOMBRE ANTERIOR + persona@lasalle.mx + ANALISTA + """), + Response( + HttpStatusCode.OK, + """ + + + + """), + Response( + HttpStatusCode.OK, + """ + + + + + + + """)); + 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, "Datos inesperados")); + 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 = "") => new(statusCode) { - Content = new StringContent("") + Content = new StringContent(content) }; private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler