diff --git a/README.md b/README.md index da515ec..0ce9300 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,14 @@ 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. 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 +3. The broker validates the same key/password pair against the lightweight SGU + NTLM root. Only an authoritative `401`/`403` rejects the credential. +4. After successful authentication, the broker makes a separately bounded, + best-effort request for the minimum available SGU profile fields. +5. On success, the broker creates or moves the AD user, updates the available 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. +6. 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. @@ -32,7 +33,8 @@ 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. +never blocks authentication or password synchronization after the lightweight +NTLM root has accepted the credential. Operational documentation: diff --git a/docs/architecture.md b/docs/architecture.md index 8d24895..77e5b0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,8 +7,8 @@ LogonUI -> SGU Credential Provider (SecureString) -> HTTPS 1.1 + client certificate -> SGU Auth Broker - -> SGU IIS NTLM endpoint (original password) - -> minimum SGU profile metadata (same authenticated response) + -> SGU IIS lightweight NTLM root (original password) + -> minimum SGU profile metadata (bounded, best effort) -> Active Directory (same original password + optional profile) <- domain + canonical username; never a password -> Windows credential serialization (original SecureString) @@ -20,12 +20,16 @@ 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, 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. +The authoritative logical GET is sent to `/psulsa/`, a lightweight route that +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 +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. ## Offline authentication diff --git a/docs/broker-operations.md b/docs/broker-operations.md index 75a6f10..84178f7 100644 --- a/docs/broker-operations.md +++ b/docs/broker-operations.md @@ -47,10 +47,12 @@ 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 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 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. - 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 1ceca9e..3b93784 100644 --- a/docs/security.md +++ b/docs/security.md @@ -24,9 +24,9 @@ - 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. +- Authentication and profile enrichment read only allow-listed HTTPS pages. + Profile bodies are capped at 512 KiB by default; portal cookies are + request-scoped and held only in memory. ## Profile minimization @@ -43,6 +43,9 @@ before metadata is synchronized. - 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 + timeout. 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 6460c72..69d9d96 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -13,6 +13,8 @@ param( [string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/', [string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'), [ValidatePattern('^/')] + [string]$AuthenticationPath = '/psulsa/', + [ValidatePattern('^/')] [string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx', [ValidatePattern('^/')] [string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx', @@ -28,7 +30,9 @@ param( [ValidateLength(1, 64)] [string]$DefaultCompany = 'Universidad La Salle', [ValidateRange(10, 60)] - [int]$NtlmTimeoutSeconds = 30, + [int]$NtlmTimeoutSeconds = 20, + [ValidateRange(2, 30)] + [int]$ProfileTimeoutSeconds = 10, [switch]$CreateMissingOus, [switch]$DisableCertificateRevocationCheckForLab ) @@ -141,7 +145,9 @@ $productionSettings = @{ Endpoint = $NtlmEndpoint Domain = '' TimeoutSeconds = $NtlmTimeoutSeconds + ProfileTimeoutSeconds = $ProfileTimeoutSeconds MaxRedirects = 5 + AuthenticationPath = $AuthenticationPath AdministrativeProfilePath = $AdministrativeProfilePath StudentProfilePath = $StudentProfilePath MenuProfilePath = $MenuProfilePath diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs index 3e7f573..615290a 100644 --- a/src/SGU.AuthBroker/Options/BrokerOptions.cs +++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs @@ -31,7 +31,9 @@ public sealed class BrokerOptions throw new InvalidOperationException("The NTLM endpoint host must be present in AllowedRedirectHosts."); } - if (Ntlm.TimeoutSeconds is < 2 or > 60 || Ntlm.MaxRedirects is < 0 or > 10) + if (Ntlm.TimeoutSeconds is < 2 or > 60 || + Ntlm.ProfileTimeoutSeconds is < 2 or > 30 || + Ntlm.MaxRedirects is < 0 or > 10) { throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range."); } @@ -43,6 +45,7 @@ public sealed class BrokerOptions foreach (string profilePath in new[] { + Ntlm.AuthenticationPath, Ntlm.AdministrativeProfilePath, Ntlm.StudentProfilePath, Ntlm.MenuProfilePath @@ -117,10 +120,14 @@ public sealed class NtlmOptions public string Domain { get; init; } = string.Empty; - public int TimeoutSeconds { get; init; } = 30; + public int TimeoutSeconds { get; init; } = 20; + + public int ProfileTimeoutSeconds { get; init; } = 10; public int MaxRedirects { get; init; } = 5; + public string AuthenticationPath { get; init; } = "/psulsa/"; + public string AdministrativeProfilePath { get; init; } = "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx"; diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs index 50e3081..5eeb865 100644 --- a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs +++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs @@ -15,7 +15,9 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden string password, CancellationToken cancellationToken) { - Uri current = GetProfileUri(identity.Role); + Uri authenticationUri = new( + new Uri(options.Endpoint, UriKind.Absolute), + options.AuthenticationPath); HashSet allowedHosts = new( this.options.AllowedRedirectHosts, StringComparer.OrdinalIgnoreCase); @@ -50,79 +52,28 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden try { - for (int hop = 0; hop <= this.options.MaxRedirects; hop++) + NtlmValidationResult? authenticationFailure = await ValidateCredentialsAsync( + client, + authenticationUri, + allowedHosts, + credentialCache, + credentialedAuthorities, + credential, + cancellationToken).ConfigureAwait(false); + if (authenticationFailure is not null) { - if (!IsAllowedHttpsUri(current, allowedHosts)) - { - return NtlmValidationResult.Unavailable("NTLM_REDIRECT_REJECTED"); - } - - string authority = current.GetLeftPart(UriPartial.Authority); - if (credentialedAuthorities.Add(authority)) - { - credentialCache.Add(new Uri(authority + "/"), "NTLM", credential); - } - - using HttpRequestMessage request = new(HttpMethod.Get, current); - using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(TimeSpan.FromSeconds(this.options.TimeoutSeconds)); - - HttpResponseMessage response; - try - { - response = await client - .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return NtlmValidationResult.Unavailable("NTLM_TIMEOUT"); - } - catch (HttpRequestException) - { - return NtlmValidationResult.Unavailable(); - } - - using (response) - { - if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) - { - return NtlmValidationResult.Invalid(); - } - - int statusCode = (int)response.StatusCode; - if (statusCode >= 500) - { - return NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR"); - } - - if (statusCode is >= 300 and < 400) - { - Uri? location = response.Headers.Location; - if (location is null) - { - return NtlmValidationResult.Unavailable("NTLM_INVALID_REDIRECT"); - } - - current = location.IsAbsoluteUri ? location : new Uri(current, location); - continue; - } - - if (statusCode is >= 200 and < 300) - { - InstitutionalProfile? profile = await TryReadProfileAsync( - response, - identity, - timeout.Token, - cancellationToken).ConfigureAwait(false); - return NtlmValidationResult.Valid(profile); - } - - return NtlmValidationResult.Invalid(); - } + return authenticationFailure; } - return NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT"); + InstitutionalProfile? profile = await TryFetchProfileAsync( + client, + identity, + allowedHosts, + credentialCache, + credentialedAuthorities, + credential, + cancellationToken).ConfigureAwait(false); + return NtlmValidationResult.Valid(profile); } finally { @@ -130,6 +81,154 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden } } + private async Task ValidateCredentialsAsync( + HttpClient client, + Uri authenticationUri, + HashSet allowedHosts, + CredentialCache credentialCache, + HashSet credentialedAuthorities, + NetworkCredential credential, + CancellationToken cancellationToken) + { + if (!IsAllowedHttpsUri(authenticationUri, allowedHosts)) + { + return NtlmValidationResult.Unavailable("NTLM_AUTH_ENDPOINT_REJECTED"); + } + + AddCredential( + authenticationUri, + credentialCache, + credentialedAuthorities, + credential); + using HttpRequestMessage request = new(HttpMethod.Get, authenticationUri); + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds)); + + HttpResponseMessage response; + try + { + response = await client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return NtlmValidationResult.Unavailable("NTLM_TIMEOUT"); + } + catch (HttpRequestException) + { + return NtlmValidationResult.Unavailable(); + } + + using (response) + { + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + return NtlmValidationResult.Invalid(); + } + + int statusCode = (int)response.StatusCode; + if (statusCode is >= 200 and < 300) + { + return null; + } + + if (statusCode is >= 300 and < 400) + { + Uri? location = response.Headers.Location; + Uri? redirect = location is null + ? null + : location.IsAbsoluteUri ? location : new Uri(authenticationUri, location); + return redirect is not null && IsAllowedHttpsUri(redirect, allowedHosts) + ? null + : NtlmValidationResult.Unavailable("NTLM_AUTH_REDIRECT_REJECTED"); + } + + return statusCode is 429 or >= 500 + ? NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR") + : NtlmValidationResult.Unavailable("NTLM_UNEXPECTED_RESPONSE"); + } + } + + private async Task TryFetchProfileAsync( + HttpClient client, + UserIdentity identity, + HashSet allowedHosts, + CredentialCache credentialCache, + HashSet credentialedAuthorities, + NetworkCredential credential, + CancellationToken cancellationToken) + { + try + { + Uri current = GetProfileUri(identity.Role); + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(options.ProfileTimeoutSeconds)); + + for (int hop = 0; hop <= options.MaxRedirects; hop++) + { + if (!IsAllowedHttpsUri(current, allowedHosts)) + { + return null; + } + + AddCredential(current, credentialCache, credentialedAuthorities, credential); + using HttpRequestMessage request = new(HttpMethod.Get, current); + using HttpResponseMessage response = await client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token) + .ConfigureAwait(false); + + int statusCode = (int)response.StatusCode; + if (statusCode is >= 300 and < 400) + { + Uri? location = response.Headers.Location; + if (location is null) + { + return null; + } + + current = location.IsAbsoluteUri ? location : new Uri(current, location); + continue; + } + + if (statusCode is >= 200 and < 300) + { + return await TryReadProfileAsync( + response, + identity, + timeout.Token, + cancellationToken).ConfigureAwait(false); + } + + return null; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // Enrichment is optional once the lightweight NTLM endpoint has + // authoritatively accepted the credentials. + } + + return null; + } + + private static void AddCredential( + Uri uri, + CredentialCache credentialCache, + HashSet credentialedAuthorities, + NetworkCredential credential) + { + string authority = uri.GetLeftPart(UriPartial.Authority); + if (credentialedAuthorities.Add(authority)) + { + credentialCache.Add(new Uri(authority + "/"), "NTLM", credential); + } + } + private Uri GetProfileUri(InstitutionalRole role) { Uri endpoint = new(options.Endpoint, UriKind.Absolute); diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json index 3b96346..7a65163 100644 --- a/src/SGU.AuthBroker/appsettings.json +++ b/src/SGU.AuthBroker/appsettings.json @@ -29,8 +29,10 @@ "Ntlm": { "Endpoint": "https://sgu.ulsa.edu.mx/", "Domain": "", - "TimeoutSeconds": 30, + "TimeoutSeconds": 20, + "ProfileTimeoutSeconds": 10, "MaxRedirects": 5, + "AuthenticationPath": "/psulsa/", "AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx", "StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx", "MenuProfilePath": "/psulsa/menu.aspx",