Decouple SGU authentication from profile pages

This commit is contained in:
2026-09-01 14:43:57 -06:00
parent fd3eb537a1
commit 0391320a3e
8 changed files with 221 additions and 96 deletions
+8 -6
View File
@@ -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:
+12 -8
View File
@@ -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
+6 -4
View File
@@ -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
+6 -3
View File
@@ -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.
+7 -1
View File
@@ -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
+9 -2
View File
@@ -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";
@@ -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<string> allowedHosts = new(
this.options.AllowedRedirectHosts,
StringComparer.OrdinalIgnoreCase);
@@ -50,22 +52,57 @@ 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");
return authenticationFailure;
}
string authority = current.GetLeftPart(UriPartial.Authority);
if (credentialedAuthorities.Add(authority))
InstitutionalProfile? profile = await TryFetchProfileAsync(
client,
identity,
allowedHosts,
credentialCache,
credentialedAuthorities,
credential,
cancellationToken).ConfigureAwait(false);
return NtlmValidationResult.Valid(profile);
}
finally
{
credentialCache.Add(new Uri(authority + "/"), "NTLM", credential);
credential.Password = string.Empty;
}
}
using HttpRequestMessage request = new(HttpMethod.Get, current);
private async Task<NtlmValidationResult?> ValidateCredentialsAsync(
HttpClient client,
Uri authenticationUri,
HashSet<string> allowedHosts,
CredentialCache credentialCache,
HashSet<string> 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(this.options.TimeoutSeconds));
timeout.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds));
HttpResponseMessage response;
try
@@ -91,17 +128,63 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
}
int statusCode = (int)response.StatusCode;
if (statusCode >= 500)
if (statusCode is >= 200 and < 300)
{
return NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR");
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<InstitutionalProfile?> TryFetchProfileAsync(
HttpClient client,
UserIdentity identity,
HashSet<string> allowedHosts,
CredentialCache credentialCache,
HashSet<string> 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 NtlmValidationResult.Unavailable("NTLM_INVALID_REDIRECT");
return null;
}
current = location.IsAbsoluteUri ? location : new Uri(current, location);
@@ -110,23 +193,39 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
if (statusCode is >= 200 and < 300)
{
InstitutionalProfile? profile = await TryReadProfileAsync(
return await TryReadProfileAsync(
response,
identity,
timeout.Token,
cancellationToken).ConfigureAwait(false);
return NtlmValidationResult.Valid(profile);
}
return NtlmValidationResult.Invalid();
return null;
}
}
return NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT");
}
finally
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
credential.Password = string.Empty;
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<string> credentialedAuthorities,
NetworkCredential credential)
{
string authority = uri.GetLeftPart(UriPartial.Authority);
if (credentialedAuthorities.Add(authority))
{
credentialCache.Add(new Uri(authority + "/"), "NTLM", credential);
}
}
+3 -1
View File
@@ -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",