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
+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,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<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(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<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 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<string> 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);
+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",