diff --git a/README.md b/README.md index 2fb7df0..da515ec 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Directory synchronization, deployment scripts, and tests. NTLM endpoint. The same logical authenticated request reads the minimum available SGU profile fields. 4. On success, the broker creates or moves the AD user, updates the available - name/mail/title/department metadata, and sets the AD password + name/mail/title/department/address metadata when available, and sets the AD password to the exact submitted password. 5. The Credential Provider serializes the original `SecureString` to Windows. @@ -26,10 +26,13 @@ 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, -photo, and manager fields are ignored. For students and professors, the menu -display name is a conservative fallback until a richer role-specific page is -verified. Missing or changed presentation HTML never blocks authentication or -password synchronization. +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 +form `Estudiante de ...`; faculty/department remains unset because the verified +page does not expose it. Professors retain the menu display-name fallback until +a richer role-specific page is verified. Missing or changed presentation HTML +never blocks authentication or password synchronization. Operational documentation: diff --git a/docs/architecture.md b/docs/architecture.md index c38540b..8d24895 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,10 +21,11 @@ which prevents credential forwarding to an unexpected redirect target. HTTP/1.1 is forced because NTLM authentication is connection-bound. The logical GET is sent directly to the administrative incident overview for -`AD` identities or to the portal menu for `DO`/`AL` identities. NTLM may still -require its normal challenge/response round trips on that connection. The -broker keeps any transient portal cookie in an in-memory per-request container; -it is never persisted or returned to the client. +`AD` identities, the student information page for `AL` identities, or the +portal menu for `DO` identities. NTLM may still require its normal +challenge/response round trips on that connection. The broker keeps any +transient portal cookie in an in-memory per-request container; it is never +persisted or returned to the client. ## Offline authentication @@ -46,11 +47,13 @@ absent, moves it to the mapped OU when required, sets `userPrincipalName`, and passes the submitted password directly to ADSI `SetPassword`. When the authenticated HTML exposes recognized stable IDs, the broker also -updates `displayName`, `mail`, `title`, `department`, `employeeType`, and -`employeeID`. The administrative employee number must match the six numeric -digits of the requested `AD` identity before any scraped metadata is trusted. -Missing metadata does not clear existing AD values and never changes the -password outcome. +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 +verified page does not expose it. Missing metadata does not clear existing AD +values and never changes the password outcome. Human-readable SGU values are decoded with BOM/header/meta detection, strict UTF-8 validation, and a Windows-1252 fallback for the legacy portal. Names and diff --git a/docs/broker-operations.md b/docs/broker-operations.md index 2d057ce..75a6f10 100644 --- a/docs/broker-operations.md +++ b/docs/broker-operations.md @@ -46,10 +46,11 @@ Eso es comportamiento esperado, no una caída del servicio. ## Timeouts y recuperación -- El Credential Provider espera hasta **20 segundos** por el broker. -- El broker espera hasta **15 segundos** por SGU. Un portal que normalmente - tarda alrededor de seis segundos queda dentro del margen sin bloquear LogonUI - indefinidamente. +- El Credential Provider espera hasta **35 segundos** por el broker. +- El broker espera hasta **30 segundos** por SGU. Este margen cubre las + degradaciones observadas del portal sin bloquear LogonUI indefinidamente; el + cliente conserva cinco segundos adicionales para que el broker cierre la + respuesta de manera limpia. - 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/lab-runbook.md b/docs/lab-runbook.md index 282f7ed..e358266 100644 --- a/docs/lab-runbook.md +++ b/docs/lab-runbook.md @@ -124,7 +124,7 @@ On Windows 10: -BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate ` -ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT ` -ServerCertificateThumbprint SERVER_CERT_THUMBPRINT ` - -TimeoutSeconds 20 ` + -TimeoutSeconds 35 ` -InstallDotNetRuntime ` -DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe ``` diff --git a/docs/security.md b/docs/security.md index e30a437..1ceca9e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -32,10 +32,15 @@ - Administrative enrichment reads only employee number, display name, employee type/status, email, job title, and department from known element IDs. +- 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. +- Student CURP, birth date, sex, blood type, marital status, telephone, mobile, + guardian, medical, financial, and academic-history values are ignored. - Incident details, calendars, photographs, manager names, and manager positions are deliberately ignored. -- The employee number must match the authenticated `AD` key before metadata is - synchronized. +- The employee or student number must match the authenticated `AD` or `AL` key + before metadata is synchronized. - If SGU changes its HTML, authentication and exact-password synchronization continue without enrichment; existing AD metadata is not erased. diff --git a/docs/windows-client-onboarding.md b/docs/windows-client-onboarding.md index 8d907aa..90c4e9b 100644 --- a/docs/windows-client-onboarding.md +++ b/docs/windows-client-onboarding.md @@ -82,7 +82,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass ` -BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate ` -ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT ` -ServerCertificateThumbprint SERVER_CERT_THUMBPRINT ` - -TimeoutSeconds 20 + -TimeoutSeconds 35 ``` Los binarios se guardan en un subdirectorio `versions` identificado por su diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 index 97479d9..6460c72 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -15,6 +15,8 @@ param( [ValidatePattern('^/')] [string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx', [ValidatePattern('^/')] + [string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx', + [ValidatePattern('^/')] [string]$MenuProfilePath = '/psulsa/menu.aspx', [ValidateRange(32768, 2097152)] [int]$MaxProfileBytes = 524288, @@ -26,7 +28,7 @@ param( [ValidateLength(1, 64)] [string]$DefaultCompany = 'Universidad La Salle', [ValidateRange(10, 60)] - [int]$NtlmTimeoutSeconds = 15, + [int]$NtlmTimeoutSeconds = 30, [switch]$CreateMissingOus, [switch]$DisableCertificateRevocationCheckForLab ) @@ -141,6 +143,7 @@ $productionSettings = @{ TimeoutSeconds = $NtlmTimeoutSeconds MaxRedirects = 5 AdministrativeProfilePath = $AdministrativeProfilePath + StudentProfilePath = $StudentProfilePath MenuProfilePath = $MenuProfilePath MaxProfileBytes = $MaxProfileBytes AllowedRedirectHosts = $AllowedNtlmRedirectHosts diff --git a/scripts/Enroll-SguDomainClient.ps1 b/scripts/Enroll-SguDomainClient.ps1 index 0fc1f38..699db6d 100644 --- a/scripts/Enroll-SguDomainClient.ps1 +++ b/scripts/Enroll-SguDomainClient.ps1 @@ -55,7 +55,7 @@ $installParams = @{ ClientCertificateThumbprint = $ClientCertificateThumbprint ServerCertificateThumbprint = $ServerCertificateThumbprint DomainNetbios = $DomainNetbios - TimeoutSeconds = 20 + TimeoutSeconds = 35 } if ($DotNetRuntimeInstallerPath) { $installParams.InstallDotNetRuntime = $true @@ -68,7 +68,7 @@ $guardParams = @{ ClientCertificateThumbprint = $ClientCertificateThumbprint ServerCertificateThumbprint = $ServerCertificateThumbprint DomainNetbios = $DomainNetbios - TimeoutSeconds = 20 + TimeoutSeconds = 35 RemoteDesktopPrincipal = $RemoteDesktopPrincipal DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath } diff --git a/scripts/Install-CredentialProvider.ps1 b/scripts/Install-CredentialProvider.ps1 index 4df349c..349e754 100644 --- a/scripts/Install-CredentialProvider.ps1 +++ b/scripts/Install-CredentialProvider.ps1 @@ -18,7 +18,7 @@ param( [string]$DomainNetbios = 'LCI', [ValidateRange(2, 60)] - [int]$TimeoutSeconds = 20, + [int]$TimeoutSeconds = 35, [switch]$DoNotSetAsDefaultCredentialProvider, diff --git a/scripts/Install-SguEnrollmentGuard.ps1 b/scripts/Install-SguEnrollmentGuard.ps1 index e26edbc..8ac9659 100644 --- a/scripts/Install-SguEnrollmentGuard.ps1 +++ b/scripts/Install-SguEnrollmentGuard.ps1 @@ -17,7 +17,7 @@ param( [string]$DomainNetbios = 'LCI', [ValidateRange(2, 60)] - [int]$TimeoutSeconds = 20, + [int]$TimeoutSeconds = 35, [string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP', [string]$DotNetRuntimeInstallerPath ) diff --git a/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs index 2fd894f..630b128 100644 --- a/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs +++ b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs @@ -3,16 +3,28 @@ namespace SGU.AuthBroker.Core.Profiles; public sealed record InstitutionalProfile( string? EmployeeNumber = null, string? DisplayName = null, + string? GivenName = null, + string? Surname = null, string? Email = null, string? EmployeeType = null, string? JobTitle = null, - string? Department = null) + string? Department = null, + string? StreetAddress = null, + string? City = null, + string? State = null, + string? PostalCode = null) { public bool HasValues => EmployeeNumber is not null || DisplayName is not null || + GivenName is not null || + Surname is not null || Email is not null || EmployeeType is not null || JobTitle is not null || - Department is not null; + Department is not null || + StreetAddress is not null || + City is not null || + State is not null || + PostalCode is not null; } diff --git a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs index 020df40..03bc33f 100644 --- a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs +++ b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs @@ -12,6 +12,18 @@ public static class SguProfileParser private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto"; private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia"; private const string MenuNameId = "ctl00_lblNombreUsuario"; + private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP"; + private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP"; + private const string StudentPaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApPatAlumnoHP"; + private const string StudentMaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApMatAlumnoHP"; + private const string StudentEmailId = "ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP"; + private const string StudentCareerId = "ctl00_contenedor_HistorialAlumno1_Header1_lblCarrera"; + private const string StudentStreetId = "ctl00_contenedor_HistorialAlumno1_lblDomicilioAlumnoHP"; + private const string StudentNeighborhoodId = "ctl00_contenedor_HistorialAlumno1_lblColoniaAlumnoHP"; + private const string StudentStateId = "ctl00_contenedor_HistorialAlumno1_lblEstadoAlumnoHP"; + private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP"; + private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP"; + private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP"; public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber) { @@ -44,6 +56,44 @@ public static class SguProfileParser return profile.HasValues ? profile : null; } + public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber) + { + ArgumentNullException.ThrowIfNull(html); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedStudentNumber); + + string? studentNumber = NormalizeInstitutionalNumber(ExtractSpanText(html, StudentNumberId)); + if (!string.Equals(studentNumber, expectedStudentNumber, StringComparison.Ordinal)) + { + return null; + } + + string? givenName = NormalizeName(ExtractSpanText(html, StudentGivenNameId), 64); + string? paternalSurname = NormalizeSurname(ExtractSpanText(html, StudentPaternalSurnameId), 64); + string? maternalSurname = NormalizeSurname(ExtractSpanText(html, StudentMaternalSurnameId), 64); + string? surname = NormalizeSurname(JoinNonEmpty(" ", paternalSurname, maternalSurname), 64); + string? displayName = NormalizeName(JoinNonEmpty(" ", givenName, surname), 256); + + string? street = NormalizeTitle(ExtractSpanText(html, StudentStreetId), 512); + string? neighborhood = NormalizeTitle(ExtractSpanText(html, StudentNeighborhoodId), 256); + string? city = NormalizeTitle(ExtractSpanText(html, StudentCityId), 128); + string? municipality = NormalizeTitle(ExtractSpanText(html, StudentMunicipalityId), 128); + string? streetAddress = BuildStreetAddress(street, neighborhood, municipality, city); + + InstitutionalProfile profile = new( + EmployeeNumber: studentNumber, + DisplayName: displayName, + GivenName: givenName, + Surname: surname, + Email: NormalizeEmail(ExtractSpanText(html, StudentEmailId)), + EmployeeType: "Alumno", + JobTitle: BuildStudentJobTitle(ExtractSpanText(html, StudentCareerId)), + StreetAddress: streetAddress, + City: city ?? municipality, + State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128), + PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId))); + return profile.HasValues ? profile : null; + } + private static bool TrySplitAdministrativeIdentity( string? value, out string? employeeNumber, @@ -176,6 +226,104 @@ public static class SguProfileParser return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate); } + private static string? NormalizeName(string? value, int maximumLength) + { + string? candidate = Limit(value, maximumLength); + return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate); + } + + private static string? NormalizeSurname(string? value, int maximumLength) + { + string? candidate = Limit(value, maximumLength); + return candidate is null ? null : SpanishTextNormalizer.ToSurnameCase(candidate); + } + + private static string? NormalizeInstitutionalNumber(string? value) + { + string? candidate = value?.Trim(); + return candidate is { Length: 6 } && candidate.All(char.IsAsciiDigit) + ? candidate + : null; + } + + private static string? BuildStudentJobTitle(string? value) + { + string? candidate = Limit(value, 256); + if (candidate is null) + { + return null; + } + + candidate = candidate.Replace( + "COMPUTACIO NALES", + "COMPUTACIONALES", + StringComparison.OrdinalIgnoreCase); + string career = SpanishTextNormalizer.ToTitleCase(candidate); + const string degreePrefix = "Licenciatura en "; + if (career.StartsWith(degreePrefix, StringComparison.OrdinalIgnoreCase)) + { + career = career[degreePrefix.Length..]; + } + + return TruncateAtWordBoundary($"Estudiante de {career}", 64); + } + + private static string? BuildStreetAddress( + string? street, + string? neighborhood, + string? municipality, + string? city) + { + List lines = []; + AddDistinct(lines, street); + AddDistinct(lines, neighborhood); + if (!string.Equals(municipality, city, StringComparison.OrdinalIgnoreCase)) + { + AddDistinct(lines, municipality); + } + + return Limit(string.Join("\r\n", lines), 1024); + } + + private static void AddDistinct(List values, string? candidate) + { + if (!string.IsNullOrWhiteSpace(candidate) && + !values.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + { + values.Add(candidate); + } + } + + private static string? NormalizePostalCode(string? value) + { + string? candidate = value?.Trim(); + if (candidate is null || + candidate.Length is < 4 or > 5 || + !candidate.All(char.IsAsciiDigit)) + { + return null; + } + + return candidate.PadLeft(5, '0'); + } + + private static string? JoinNonEmpty(string separator, params string?[] values) + { + string result = string.Join(separator, values.Where(value => !string.IsNullOrWhiteSpace(value))); + return string.IsNullOrWhiteSpace(result) ? null : result; + } + + private static string TruncateAtWordBoundary(string value, int maximumLength) + { + if (value.Length <= maximumLength) + { + return value; + } + + int boundary = value.LastIndexOf(' ', maximumLength - 1, maximumLength); + return value[..(boundary > 0 ? boundary : maximumLength)].TrimEnd(); + } + private static string? NormalizeSentence(string? value, int maximumLength) { string? candidate = Limit(value, maximumLength); diff --git a/src/SGU.AuthBroker.Core/Profiles/SpanishTextNormalizer.cs b/src/SGU.AuthBroker.Core/Profiles/SpanishTextNormalizer.cs index af546c3..3f5ceb2 100644 --- a/src/SGU.AuthBroker.Core/Profiles/SpanishTextNormalizer.cs +++ b/src/SGU.AuthBroker.Core/Profiles/SpanishTextNormalizer.cs @@ -15,6 +15,16 @@ public static class SpanishTextNormalizer StringComparer.OrdinalIgnoreCase); public static string ToTitleCase(string value) + { + return ToTitleCase(value, lowercaseLeadingParticle: false); + } + + public static string ToSurnameCase(string value) + { + return ToTitleCase(value, lowercaseLeadingParticle: true); + } + + private static string ToTitleCase(string value, bool lowercaseLeadingParticle) { ArgumentNullException.ThrowIfNull(value); @@ -23,7 +33,7 @@ public static class SpanishTextNormalizer { string lowercase = words[index].ToLower(SpanishCulture); string comparisonToken = lowercase.Trim('(', ')', '[', ']', '{', '}', ',', '.', ';', ':'); - words[index] = index > 0 && LowercaseParticles.Contains(comparisonToken) + words[index] = (index > 0 || lowercaseLeadingParticle) && LowercaseParticles.Contains(comparisonToken) ? lowercase : CapitalizeCompound(lowercase); } diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs index 3845fbc..3e7f573 100644 --- a/src/SGU.AuthBroker/Options/BrokerOptions.cs +++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs @@ -41,7 +41,12 @@ public sealed class BrokerOptions throw new InvalidOperationException("The SGU profile response limit is outside the supported range."); } - foreach (string profilePath in new[] { Ntlm.AdministrativeProfilePath, Ntlm.MenuProfilePath }) + foreach (string profilePath in new[] + { + Ntlm.AdministrativeProfilePath, + Ntlm.StudentProfilePath, + Ntlm.MenuProfilePath + }) { if (string.IsNullOrWhiteSpace(profilePath)) { @@ -112,13 +117,16 @@ public sealed class NtlmOptions public string Domain { get; init; } = string.Empty; - public int TimeoutSeconds { get; init; } = 15; + public int TimeoutSeconds { get; init; } = 30; public int MaxRedirects { get; init; } = 5; public string AdministrativeProfilePath { get; init; } = "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx"; + public string StudentProfilePath { get; init; } = + "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx"; + public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx"; public int MaxProfileBytes { get; init; } = 512 * 1024; diff --git a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs index a4053f6..98e0107 100644 --- a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs +++ b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs @@ -128,10 +128,16 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive if (profile is not null) { SetOptionalProperty(user, "displayName", profile.DisplayName); + SetOptionalProperty(user, "givenName", profile.GivenName); + SetOptionalProperty(user, "sn", profile.Surname); SetOptionalProperty(user, "mail", profile.Email); SetOptionalProperty(user, "title", profile.JobTitle); SetOptionalProperty(user, "department", profile.Department); SetOptionalProperty(user, "employeeType", profile.EmployeeType); + SetOptionalProperty(user, "streetAddress", profile.StreetAddress); + SetOptionalProperty(user, "l", profile.City); + SetOptionalProperty(user, "st", profile.State); + SetOptionalProperty(user, "postalCode", profile.PostalCode); if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal)) { SetOptionalProperty(user, "employeeID", profile.EmployeeNumber); diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs index 94d9e46..50e3081 100644 --- a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs +++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs @@ -133,9 +133,13 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden private Uri GetProfileUri(InstitutionalRole role) { Uri endpoint = new(options.Endpoint, UriKind.Absolute); - string path = role == InstitutionalRole.Administrative - ? options.AdministrativeProfilePath - : options.MenuProfilePath; + string path = role switch + { + InstitutionalRole.Administrative => options.AdministrativeProfilePath, + InstitutionalRole.Student => options.StudentProfilePath, + InstitutionalRole.Professor => options.MenuProfilePath, + _ => throw new ArgumentOutOfRangeException(nameof(role), role, null) + }; return new Uri(endpoint, path); } @@ -151,10 +155,17 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden response.Content, options.MaxProfileBytes, timeoutToken).ConfigureAwait(false); - return identity.Role == InstitutionalRole.Administrative - ? SguProfileParser.ParseAdministrative(html, identity.NumericId) ?? - SguProfileParser.ParseMenu(html) - : SguProfileParser.ParseMenu(html); + return identity.Role switch + { + InstitutionalRole.Administrative => + SguProfileParser.ParseAdministrative(html, identity.NumericId) ?? + SguProfileParser.ParseMenu(html), + InstitutionalRole.Student => + SguProfileParser.ParseStudent(html, identity.NumericId) ?? + SguProfileParser.ParseMenu(html), + InstitutionalRole.Professor => SguProfileParser.ParseMenu(html), + _ => null + }; } catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested) { diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json index 4c4af0c..3b96346 100644 --- a/src/SGU.AuthBroker/appsettings.json +++ b/src/SGU.AuthBroker/appsettings.json @@ -29,9 +29,10 @@ "Ntlm": { "Endpoint": "https://sgu.ulsa.edu.mx/", "Domain": "", - "TimeoutSeconds": 15, + "TimeoutSeconds": 30, "MaxRedirects": 5, "AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx", + "StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx", "MenuProfilePath": "/psulsa/menu.aspx", "MaxProfileBytes": 524288, "AllowedRedirectHosts": [ diff --git a/src/SGU.CredentialProvider/ProviderSettings.cs b/src/SGU.CredentialProvider/ProviderSettings.cs index ed0ea90..27dc3dd 100644 --- a/src/SGU.CredentialProvider/ProviderSettings.cs +++ b/src/SGU.CredentialProvider/ProviderSettings.cs @@ -10,7 +10,7 @@ internal sealed class ProviderSettings public string DomainNetbios { get; init; } = "LCI"; - public int TimeoutSeconds { get; init; } = 20; + public int TimeoutSeconds { get; init; } = 35; public string ClientCertificateThumbprint { get; init; } = string.Empty; diff --git a/src/SGU.CredentialProvider/settings.example.json b/src/SGU.CredentialProvider/settings.example.json index 742ce6d..33536df 100644 --- a/src/SGU.CredentialProvider/settings.example.json +++ b/src/SGU.CredentialProvider/settings.example.json @@ -1,7 +1,7 @@ { "BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate", "DomainNetbios": "LCI", - "TimeoutSeconds": 20, + "TimeoutSeconds": 35, "ClientCertificateThumbprint": "0000000000000000000000000000000000000000", "ServerCertificateThumbprint": "0000000000000000000000000000000000000000" } diff --git a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs index 2b17235..0f0b7ea 100644 --- a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs +++ b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs @@ -56,6 +56,97 @@ public sealed class SguProfileParserTests Assert.Null(SguProfileParser.ParseAdministrative(html, "999999")); } + [Fact] + public void ParsesTheRequiredStudentIdentityCareerAndAddressFields() + { + const string html = """ + +
+ 123456 + MARÍA DEL CARMEN + DE LA FUENTE + O'CONNOR + + ALUMNA@LASALLE.MX + + + DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE + +
+ + LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIO NALES + +
+ + AVENIDA DE LA UNIVERSIDAD 123 + + + COLONIA DEL VALLE + + + CIUDAD DE MÉXICO + + + CIUDAD DE MÉXICO + + + ALCALDÍA BENITO JUÁREZ + + 8500 +
+ + """; + + InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456"); + + Assert.NotNull(profile); + Assert.Equal("123456", profile.EmployeeNumber); + Assert.Equal("María del Carmen de la Fuente O'Connor", profile.DisplayName); + Assert.Equal("María del Carmen", profile.GivenName); + Assert.Equal("de la Fuente O'Connor", profile.Surname); + Assert.Equal("alumna@lasalle.mx", profile.Email); + Assert.Equal("Alumno", profile.EmployeeType); + Assert.Equal( + "Estudiante de Ingeniería Cibernética y Sistemas Computacionales", + profile.JobTitle); + Assert.Null(profile.Department); + Assert.Equal( + "Avenida de la Universidad 123\r\nColonia del Valle\r\nAlcaldía Benito Juárez", + profile.StreetAddress); + Assert.Equal("Ciudad de México", profile.City); + Assert.Equal("Ciudad de México", profile.State); + Assert.Equal("08500", profile.PostalCode); + } + + [Fact] + public void RejectsStudentMetadataForADifferentStudentNumber() + { + const string html = """ + 123456 + PERSONA INCORRECTA + incorrecta@lasalle.mx + """; + + Assert.Null(SguProfileParser.ParseStudent(html, "654321")); + } + + [Fact] + public void UsesMunicipalityAsStudentCityWhenThePortalCityIsMissing() + { + const string html = """ + 123456 + SAN PEDRO CHOLULA + 72760 + """; + + InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456"); + + Assert.NotNull(profile); + Assert.Equal("San Pedro Cholula", profile.City); + Assert.Equal("San Pedro Cholula", profile.StreetAddress); + Assert.Equal("72760", profile.PostalCode); + } + [Fact] public void ParsesTheMenuNameAsAConservativeFallback() { diff --git a/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs b/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs index 868691a..4b04235 100644 --- a/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs +++ b/tests/SGU.CredentialProvider.Tests/BrokerClientTests.cs @@ -10,7 +10,7 @@ public sealed class BrokerClientTests [Fact] public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker() { - Assert.Equal(20, new ProviderSettings().TimeoutSeconds); + Assert.Equal(35, new ProviderSettings().TimeoutSeconds); } [Fact]