diff --git a/README.md b/README.md index 3b611ef..7406a02 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,24 @@ Directory synchronization, deployment scripts, and tests. 1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password. 2. It sends that exact password over mutually authenticated TLS to the broker. 3. The broker validates the same key/password pair against the configured SGU - NTLM endpoint. -4. On success, the broker creates or moves the AD user and sets the AD password + 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 to the exact submitted password. 5. The Credential Provider serializes the original `SecureString` to Windows. 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, +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. + | Prefix | Role | Default OU | |---|---|---| | `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | diff --git a/docs/architecture.md b/docs/architecture.md index 352efee..d695d0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,8 @@ LogonUI -> HTTPS 1.1 + client certificate -> SGU Auth Broker -> SGU IIS NTLM endpoint (original password) - -> Active Directory (same original password) + -> minimum SGU profile metadata (same authenticated response) + -> Active Directory (same original password + optional profile) <- domain + canonical username; never a password -> Windows credential serialization (original SecureString) -> LSA / Kerberos / cached domain logon @@ -19,6 +20,12 @@ It follows only HTTPS redirects whose host appears in `AllowedRedirectHosts`, 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. + ## Offline authentication ```text @@ -38,6 +45,13 @@ plus six digits. It searches `BaseDn` by `sAMAccountName`, creates the user when 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. + The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`, and `Administrativos` are direct child OUs beneath it. diff --git a/docs/security.md b/docs/security.md index d79d789..82290bf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -24,6 +24,20 @@ - Client private keys are non-exportable and reside in `LocalMachine\My`. - The NTLM validator rejects non-HTTPS redirects, URI user information, and hosts outside its explicit redirect allow-list. +- Profile enrichment reads only allow-listed HTTPS pages and caps the response + body at 512 KiB by default. Portal cookies are request-scoped and held only in + memory. + +## Profile minimization + +- Administrative enrichment reads only employee number, display name, + employee type/status, email, job title, and department from known element IDs. +- 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. +- If SGU changes its HTML, authentication and exact-password synchronization + continue without enrichment; existing AD metadata is not erased. Lab self-signed certificates are appropriate only for the isolated VM network. Use an enterprise CA with revocation checking in production. diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 index 04e76f0..cd3cee6 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -12,6 +12,12 @@ param( [string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/', [string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'), + [ValidatePattern('^/')] + [string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx', + [ValidatePattern('^/')] + [string]$MenuProfilePath = '/psulsa/menu.aspx', + [ValidateRange(32768, 2097152)] + [int]$MaxProfileBytes = 524288, [string]$LdapHost = 'localhost', [string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx', [string]$DomainNetbios = 'LCI', @@ -120,6 +126,9 @@ $productionSettings = @{ Domain = '' TimeoutSeconds = 15 MaxRedirects = 5 + AdministrativeProfilePath = $AdministrativeProfilePath + MenuProfilePath = $MenuProfilePath + MaxProfileBytes = $MaxProfileBytes AllowedRedirectHosts = $AllowedNtlmRedirectHosts } Directory = @{ diff --git a/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs b/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs index d22b842..b5607d7 100644 --- a/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs +++ b/src/SGU.AuthBroker.Core/Authentication/AuthenticationWorkflow.cs @@ -23,7 +23,7 @@ public sealed class AuthenticationWorkflow( try { validation = await ntlmValidator - .ValidateAsync(identity.UserName, password, cancellationToken) + .ValidateAsync(identity, password, cancellationToken) .ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -57,7 +57,7 @@ public sealed class AuthenticationWorkflow( try { DirectorySyncResult directory = await directorySynchronizer - .SynchronizeAsync(identity, password, cancellationToken) + .SynchronizeAsync(identity, validation.Profile, password, cancellationToken) .ConfigureAwait(false); return new AuthenticationFlowResult( diff --git a/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs b/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs index 703fcfd..25e5439 100644 --- a/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs +++ b/src/SGU.AuthBroker.Core/Authentication/INtlmCredentialValidator.cs @@ -1,9 +1,11 @@ +using SGU.AuthBroker.Core.Identity; + namespace SGU.AuthBroker.Core.Authentication; public interface INtlmCredentialValidator { Task ValidateAsync( - string userName, + UserIdentity identity, string password, CancellationToken cancellationToken); } diff --git a/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs b/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs index 2d1d1ff..a788305 100644 --- a/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs +++ b/src/SGU.AuthBroker.Core/Authentication/NtlmValidationResult.cs @@ -1,3 +1,5 @@ +using SGU.AuthBroker.Core.Profiles; + namespace SGU.AuthBroker.Core.Authentication; public enum NtlmValidationStatus @@ -7,9 +9,13 @@ public enum NtlmValidationStatus Unavailable } -public sealed record NtlmValidationResult(NtlmValidationStatus Status, string? ErrorCode = null) +public sealed record NtlmValidationResult( + NtlmValidationStatus Status, + string? ErrorCode = null, + InstitutionalProfile? Profile = null) { - public static NtlmValidationResult Valid() => new(NtlmValidationStatus.Valid); + public static NtlmValidationResult Valid(InstitutionalProfile? profile = null) => + new(NtlmValidationStatus.Valid, Profile: profile); public static NtlmValidationResult Invalid() => new(NtlmValidationStatus.Invalid, "INVALID_INSTITUTIONAL_CREDENTIALS"); diff --git a/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs index 4674c8d..923bb26 100644 --- a/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs +++ b/src/SGU.AuthBroker.Core/Directory/IActiveDirectorySynchronizer.cs @@ -1,4 +1,5 @@ using SGU.AuthBroker.Core.Identity; +using SGU.AuthBroker.Core.Profiles; namespace SGU.AuthBroker.Core.Directory; @@ -6,6 +7,7 @@ public interface IActiveDirectorySynchronizer { Task SynchronizeAsync( UserIdentity identity, + InstitutionalProfile? profile, string password, CancellationToken cancellationToken); } diff --git a/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs new file mode 100644 index 0000000..2fd894f --- /dev/null +++ b/src/SGU.AuthBroker.Core/Profiles/InstitutionalProfile.cs @@ -0,0 +1,18 @@ +namespace SGU.AuthBroker.Core.Profiles; + +public sealed record InstitutionalProfile( + string? EmployeeNumber = null, + string? DisplayName = null, + string? Email = null, + string? EmployeeType = null, + string? JobTitle = null, + string? Department = null) +{ + public bool HasValues => + EmployeeNumber is not null || + DisplayName is not null || + Email is not null || + EmployeeType is not null || + JobTitle is not null || + Department is not null; +} diff --git a/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs new file mode 100644 index 0000000..717aa22 --- /dev/null +++ b/src/SGU.AuthBroker.Core/Profiles/SguProfileParser.cs @@ -0,0 +1,180 @@ +using System.Net; +using System.Net.Mail; +using System.Text; + +namespace SGU.AuthBroker.Core.Profiles; + +public static class SguProfileParser +{ + private const string AdministrativeNameId = "ctl00_contenedor_decEncabezado_lblNombre"; + private const string EmployeeTypeId = "ctl00_contenedor_decEncabezado_lblIndicadorValue"; + 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 MenuNameId = "ctl00_lblNombreUsuario"; + + public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber) + { + ArgumentNullException.ThrowIfNull(html); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedEmployeeNumber); + + string? identity = ExtractSpanText(html, AdministrativeNameId); + if (!TrySplitAdministrativeIdentity(identity, out string? employeeNumber, out string? displayName) || + !string.Equals(employeeNumber, expectedEmployeeNumber, StringComparison.Ordinal)) + { + return null; + } + + InstitutionalProfile profile = new( + EmployeeNumber: employeeNumber, + DisplayName: Limit(displayName, 256), + Email: NormalizeEmail(ExtractSpanText(html, EmailId)), + EmployeeType: Limit(ExtractSpanText(html, EmployeeTypeId), 256), + JobTitle: Limit(ExtractSpanText(html, JobTitleId), 64), + Department: Limit(ExtractSpanText(html, DepartmentId), 64)); + return profile.HasValues ? profile : null; + } + + public static InstitutionalProfile? ParseMenu(string html) + { + ArgumentNullException.ThrowIfNull(html); + + InstitutionalProfile profile = new( + DisplayName: Limit(ExtractSpanText(html, MenuNameId), 256)); + return profile.HasValues ? profile : null; + } + + private static bool TrySplitAdministrativeIdentity( + string? value, + out string? employeeNumber, + out string? displayName) + { + employeeNumber = null; + displayName = null; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + int separator = value.IndexOf(" - ", StringComparison.Ordinal); + if (separator != 6) + { + return false; + } + + string candidateNumber = value[..separator]; + string candidateName = value[(separator + 3)..].Trim(); + if (candidateNumber.Length != 6 || + !candidateNumber.All(char.IsAsciiDigit) || + string.IsNullOrWhiteSpace(candidateName)) + { + return false; + } + + employeeNumber = candidateNumber; + displayName = candidateName; + return true; + } + + private static string? ExtractSpanText(string html, 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 spanStart = html.LastIndexOf("', idIndex); + if (spanStart > precedingTagEnd) + { + int openingTagEnd = html.IndexOf('>', idIndex); + int closingTagStart = openingTagEnd < 0 + ? -1 + : html.IndexOf("= 0 && closingTagStart >= 0) + { + string innerHtml = html[(openingTagEnd + 1)..closingTagStart]; + return NormalizeText(innerHtml); + } + } + + searchFrom = idIndex + marker.Length; + } + } + + return null; + } + + private static string? NormalizeText(string htmlFragment) + { + StringBuilder withoutTags = new(htmlFragment.Length); + bool insideTag = false; + foreach (char character in htmlFragment) + { + if (character == '<') + { + insideTag = true; + } + else if (character == '>') + { + insideTag = false; + } + else if (!insideTag) + { + withoutTags.Append(character); + } + } + + string decoded = WebUtility.HtmlDecode(withoutTags.ToString()); + StringBuilder normalized = new(decoded.Length); + bool previousWasWhitespace = true; + foreach (char character in decoded) + { + bool whitespace = char.IsWhiteSpace(character) || character == '\u00A0'; + if (whitespace) + { + if (!previousWasWhitespace) + { + normalized.Append(' '); + } + } + else + { + normalized.Append(character); + } + + previousWasWhitespace = whitespace; + } + + string result = normalized.ToString().Trim(); + return result.Length == 0 ? null : result; + } + + private static string? NormalizeEmail(string? value) + { + string? candidate = Limit(value, 256); + if (candidate is null || + !MailAddress.TryCreate(candidate, out MailAddress? address) || + !string.Equals(address.Address, candidate, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return address.Address; + } + + private static string? Limit(string? value, int maximumLength) + { + string? candidate = value?.Trim(); + return string.IsNullOrEmpty(candidate) || candidate.Length > maximumLength + ? null + : candidate; + } +} diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs index b899a13..93981b4 100644 --- a/src/SGU.AuthBroker/Options/BrokerOptions.cs +++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs @@ -36,6 +36,27 @@ public sealed class BrokerOptions throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range."); } + if (Ntlm.MaxProfileBytes is < 32 * 1024 or > 2 * 1024 * 1024) + { + throw new InvalidOperationException("The SGU profile response limit is outside the supported range."); + } + + foreach (string profilePath in new[] { Ntlm.AdministrativeProfilePath, Ntlm.MenuProfilePath }) + { + if (string.IsNullOrWhiteSpace(profilePath)) + { + throw new InvalidOperationException("SGU profile paths are required."); + } + + Uri profileUri = new(endpoint, profilePath); + if (profileUri.Scheme != Uri.UriSchemeHttps || + !string.IsNullOrEmpty(profileUri.UserInfo) || + !Ntlm.AllowedRedirectHosts.Contains(profileUri.IdnHost, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("SGU profile paths must resolve to an allowed HTTPS host."); + } + } + if (string.IsNullOrWhiteSpace(Directory.LdapHost) || string.IsNullOrWhiteSpace(Directory.BaseDn) || string.IsNullOrWhiteSpace(Directory.DomainNetbios) || @@ -83,6 +104,13 @@ public sealed class NtlmOptions public int MaxRedirects { get; init; } = 5; + public string AdministrativeProfilePath { get; init; } = + "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx"; + + public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx"; + + public int MaxProfileBytes { get; init; } = 512 * 1024; + public string[] AllowedRedirectHosts { get; init; } = ["sgu.ulsa.edu.mx"]; } diff --git a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs index eb1ac37..cf6b165 100644 --- a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs +++ b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.DirectoryServices; using SGU.AuthBroker.Core.Directory; using SGU.AuthBroker.Core.Identity; +using SGU.AuthBroker.Core.Profiles; using SGU.AuthBroker.Options; namespace SGU.AuthBroker.Services; @@ -19,6 +20,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive public async Task SynchronizeAsync( UserIdentity identity, + InstitutionalProfile? profile, string password, CancellationToken cancellationToken) { @@ -27,7 +29,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive try { return await Task.Run( - () => Synchronize(identity, password), + () => Synchronize(identity, profile, password), cancellationToken).ConfigureAwait(false); } finally @@ -40,7 +42,10 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive } } - private DirectorySyncResult Synchronize(UserIdentity identity, string password) + private DirectorySyncResult Synchronize( + UserIdentity identity, + InstitutionalProfile? profile, + string password) { string targetOuDn = options.GetOuDn(identity.Role); using DirectoryEntry root = Bind(options.BaseDn); @@ -95,6 +100,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive user.Properties["pwdLastSet"].Value = -1; user.CommitChanges(); + TryApplyProfile(user, identity, profile); + return new DirectorySyncResult( options.DomainNetbios, identity.UserName, @@ -108,6 +115,54 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive } } + private static void TryApplyProfile( + DirectoryEntry user, + UserIdentity identity, + InstitutionalProfile? profile) + { + if (profile is null) + { + return; + } + + try + { + SetOptionalProperty(user, "displayName", profile.DisplayName); + SetOptionalProperty(user, "mail", profile.Email); + SetOptionalProperty(user, "title", profile.JobTitle); + SetOptionalProperty(user, "department", profile.Department); + SetOptionalProperty(user, "employeeType", profile.EmployeeType); + if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal)) + { + SetOptionalProperty(user, "employeeID", profile.EmployeeNumber); + } + + user.CommitChanges(); + } + catch + { + // Metadata is intentionally best-effort. User creation, password sync, + // and account enablement have already committed successfully. + try + { + user.RefreshCache(); + } + catch + { + // Discarding the optional property cache must not alter the + // already committed password synchronization result. + } + } + } + + private static void SetOptionalProperty(DirectoryEntry entry, string propertyName, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + entry.Properties[propertyName].Value = value; + } + } + private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root) { try diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs index 01dcbc7..74eee5b 100644 --- a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs +++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs @@ -1,5 +1,8 @@ using System.Net; +using System.Text; using SGU.AuthBroker.Core.Authentication; +using SGU.AuthBroker.Core.Identity; +using SGU.AuthBroker.Core.Profiles; using SGU.AuthBroker.Options; namespace SGU.AuthBroker.Services; @@ -9,29 +12,31 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden private readonly NtlmOptions options = options.Ntlm; public async Task ValidateAsync( - string userName, + UserIdentity identity, string password, CancellationToken cancellationToken) { - Uri current = new(this.options.Endpoint, UriKind.Absolute); + Uri current = GetProfileUri(identity.Role); HashSet allowedHosts = new( this.options.AllowedRedirectHosts, StringComparer.OrdinalIgnoreCase); - NetworkCredential credential = new(userName, password, this.options.Domain); + NetworkCredential credential = new(identity.UserName, password, this.options.Domain); CredentialCache credentialCache = new(); HashSet credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase); + CookieContainer cookieContainer = new(); using HttpClientHandler handler = new() { AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.All, CheckCertificateRevocationList = true, + CookieContainer = cookieContainer, Credentials = credentialCache, MaxConnectionsPerServer = 4, MaxResponseHeadersLength = 64, PreAuthenticate = false, - UseCookies = false, + UseCookies = true, UseDefaultCredentials = false, UseProxy = false }; @@ -104,9 +109,17 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden continue; } - return statusCode is >= 200 and < 300 - ? NtlmValidationResult.Valid() - : NtlmValidationResult.Invalid(); + if (statusCode is >= 200 and < 300) + { + InstitutionalProfile? profile = await TryReadProfileAsync( + response, + identity, + timeout.Token, + cancellationToken).ConfigureAwait(false); + return NtlmValidationResult.Valid(profile); + } + + return NtlmValidationResult.Invalid(); } } @@ -118,6 +131,93 @@ 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; + return new Uri(endpoint, path); + } + + private async Task TryReadProfileAsync( + HttpResponseMessage response, + UserIdentity identity, + CancellationToken timeoutToken, + CancellationToken requestCancellationToken) + { + try + { + string html = await ReadLimitedStringAsync( + response.Content, + options.MaxProfileBytes, + timeoutToken).ConfigureAwait(false); + return identity.Role == InstitutionalRole.Administrative + ? SguProfileParser.ParseAdministrative(html, identity.NumericId) ?? + SguProfileParser.ParseMenu(html) + : SguProfileParser.ParseMenu(html); + } + catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // Profile enrichment is optional. A successful NTLM response must still + // synchronize the exact password even if SGU changes its presentation HTML. + return null; + } + } + + private static async Task ReadLimitedStringAsync( + HttpContent content, + int maximumBytes, + CancellationToken cancellationToken) + { + if (content.Headers.ContentLength is long contentLength && contentLength > maximumBytes) + { + throw new InvalidDataException("The SGU profile response exceeded the configured limit."); + } + + await using Stream stream = await content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using MemoryStream buffer = new(Math.Min(maximumBytes, 64 * 1024)); + byte[] chunk = new byte[8192]; + while (true) + { + int read = await stream + .ReadAsync(chunk.AsMemory(), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (buffer.Length + read > maximumBytes) + { + throw new InvalidDataException("The SGU profile response exceeded the configured limit."); + } + + buffer.Write(chunk, 0, read); + } + + string? charset = content.Headers.ContentType?.CharSet?.Trim('"', '\''); + Encoding encoding; + try + { + encoding = string.IsNullOrWhiteSpace(charset) + ? Encoding.UTF8 + : Encoding.GetEncoding(charset); + } + catch (ArgumentException) + { + encoding = Encoding.UTF8; + } + + return encoding.GetString(buffer.GetBuffer(), 0, checked((int)buffer.Length)); + } + private static bool IsAllowedHttpsUri(Uri uri, HashSet allowedHosts) => uri.Scheme == Uri.UriSchemeHttps && string.IsNullOrEmpty(uri.UserInfo) && diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json index f60e263..f207497 100644 --- a/src/SGU.AuthBroker/appsettings.json +++ b/src/SGU.AuthBroker/appsettings.json @@ -31,6 +31,9 @@ "Domain": "", "TimeoutSeconds": 15, "MaxRedirects": 5, + "AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx", + "MenuProfilePath": "/psulsa/menu.aspx", + "MaxProfileBytes": 524288, "AllowedRedirectHosts": [ "sgu.ulsa.edu.mx" ] diff --git a/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs b/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs index fe5c24b..29c6e80 100644 --- a/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs +++ b/tests/SGU.AuthBroker.Core.Tests/AuthenticationWorkflowTests.cs @@ -1,6 +1,7 @@ using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Directory; using SGU.AuthBroker.Core.Identity; +using SGU.AuthBroker.Core.Profiles; using Xunit; namespace SGU.AuthBroker.Core.Tests; @@ -11,7 +12,13 @@ public sealed class AuthenticationWorkflowTests public async Task PassesTheExactOriginalPasswordToNtlmAndActiveDirectory() { const string original = "Árbol-Exacto-🔐-NoDerivar-27!"; - CapturingNtlmValidator ntlm = new(NtlmValidationResult.Valid()); + InstitutionalProfile profile = new( + EmployeeNumber: "123456", + DisplayName: "Persona de Prueba", + Email: "persona@lasalle.mx", + JobTitle: "DOCENTE", + Department: "FACULTAD DE PRUEBA"); + CapturingNtlmValidator ntlm = new(NtlmValidationResult.Valid(profile)); CapturingDirectorySynchronizer directory = new(); AuthenticationWorkflow workflow = new(ntlm, directory); @@ -23,8 +30,9 @@ public sealed class AuthenticationWorkflowTests Assert.Equal(AuthenticationFlowOutcome.Authorized, result.Outcome); Assert.Same(original, ntlm.Password); Assert.Same(original, directory.Password); - Assert.Equal("DO123456", ntlm.UserName); + Assert.Equal("DO123456", ntlm.Identity?.UserName); Assert.Equal(InstitutionalRole.Professor, directory.Identity?.Role); + Assert.Same(profile, directory.Profile); } [Fact] @@ -63,13 +71,16 @@ public sealed class AuthenticationWorkflowTests private sealed class CapturingNtlmValidator(NtlmValidationResult result) : INtlmCredentialValidator { - public string? UserName { get; private set; } + public UserIdentity? Identity { get; private set; } public string? Password { get; private set; } - public Task ValidateAsync(string userName, string password, CancellationToken cancellationToken) + public Task ValidateAsync( + UserIdentity identity, + string password, + CancellationToken cancellationToken) { - UserName = userName; + Identity = identity; Password = password; return Task.FromResult(result); } @@ -81,12 +92,16 @@ public sealed class AuthenticationWorkflowTests public string? Password { get; private set; } + public InstitutionalProfile? Profile { get; private set; } + public Task SynchronizeAsync( UserIdentity identity, + InstitutionalProfile? profile, string password, CancellationToken cancellationToken) { Identity = identity; + Profile = profile; Password = password; return Task.FromResult(new DirectorySyncResult( "LCI", diff --git a/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs new file mode 100644 index 0000000..e79b359 --- /dev/null +++ b/tests/SGU.AuthBroker.Core.Tests/SguProfileParserTests.cs @@ -0,0 +1,85 @@ +using SGU.AuthBroker.Core.Profiles; +using Xunit; + +namespace SGU.AuthBroker.Core.Tests; + +public sealed class SguProfileParserTests +{ + [Fact] + public void ParsesOnlyTheRequiredAdministrativeFieldsFromTheInitialResponse() + { + const string html = """ + + + 017045 - JESÚS ALEJANDRO ROSALES GONZÁLEZ + + + SINDICALIZADO QUINCENAL (ACTIVO) + + + persona@lasalle.mx + +
+ + ANALISTA DE PROYECTOS + + + FACULTAD DE INGENIERÍA + + + ESTE CAMPO NO DEBE EXTRAERSE + +
+
DATOS NO REQUERIDOS
+ + """; + + InstitutionalProfile? profile = SguProfileParser.ParseAdministrative(html, "017045"); + + Assert.NotNull(profile); + Assert.Equal("017045", profile.EmployeeNumber); + Assert.Equal("JESÚS ALEJANDRO ROSALES GONZÁLEZ", profile.DisplayName); + Assert.Equal("persona@lasalle.mx", profile.Email); + Assert.Equal("SINDICALIZADO QUINCENAL (ACTIVO)", profile.EmployeeType); + Assert.Equal("ANALISTA DE PROYECTOS", profile.JobTitle); + Assert.Equal("FACULTAD DE INGENIERÍA", profile.Department); + } + + [Fact] + public void RejectsAdministrativeMetadataForADifferentEmployeeNumber() + { + const string html = """ + 017045 - PERSONA INCORRECTA + incorrecta@lasalle.mx + """; + + Assert.Null(SguProfileParser.ParseAdministrative(html, "999999")); + } + + [Fact] + public void ParsesTheMenuNameAsAConservativeFallback() + { + const string html = """ + + MARÍA & JOSÉ + + """; + + InstitutionalProfile? profile = SguProfileParser.ParseMenu(html); + + Assert.NotNull(profile); + Assert.Equal("MARÍA & JOSÉ", profile.DisplayName); + Assert.Null(profile.Email); + Assert.Null(profile.JobTitle); + Assert.Null(profile.Department); + } + + [Fact] + public void MissingKnownFieldsProducesNoProfile() + { + Assert.Null(SguProfileParser.ParseMenu("Portal SGU")); + Assert.Null(SguProfileParser.ParseAdministrative( + "Portal SGU", + "017045")); + } +}