Harden NTLM authentication and restore SGU profiles
This commit is contained in:
@@ -32,7 +32,7 @@ public sealed class BrokerOptions
|
||||
}
|
||||
|
||||
if (Ntlm.TimeoutSeconds is < 2 or > 60 ||
|
||||
Ntlm.ProfileTimeoutSeconds is < 2 or > 30 ||
|
||||
Ntlm.ProfileTimeoutSeconds is < 2 or > 90 ||
|
||||
Ntlm.MaxRedirects is < 0 or > 10)
|
||||
{
|
||||
throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range.");
|
||||
@@ -122,7 +122,7 @@ public sealed class NtlmOptions
|
||||
|
||||
public int TimeoutSeconds { get; init; } = 20;
|
||||
|
||||
public int ProfileTimeoutSeconds { get; init; } = 10;
|
||||
public int ProfileTimeoutSeconds { get; init; } = 60;
|
||||
|
||||
public int MaxRedirects { get; init; } = 5;
|
||||
|
||||
|
||||
@@ -12,4 +12,10 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.11" />
|
||||
<PackageReference Include="System.DirectoryServices" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>SGU.AuthBroker.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
@@ -6,9 +7,28 @@ using SGU.AuthBroker.Options;
|
||||
|
||||
namespace SGU.AuthBroker.Services;
|
||||
|
||||
public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCredentialValidator
|
||||
public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
{
|
||||
private readonly NtlmOptions options = options.Ntlm;
|
||||
private readonly NtlmOptions options;
|
||||
private readonly ILogger<NtlmCredentialValidator> logger;
|
||||
private readonly Func<CredentialCache, CookieContainer, HttpMessageHandler> handlerFactory;
|
||||
|
||||
public NtlmCredentialValidator(
|
||||
BrokerOptions options,
|
||||
ILogger<NtlmCredentialValidator> logger)
|
||||
: this(options, logger, CreateHandler)
|
||||
{
|
||||
}
|
||||
|
||||
internal NtlmCredentialValidator(
|
||||
BrokerOptions options,
|
||||
ILogger<NtlmCredentialValidator> logger,
|
||||
Func<CredentialCache, CookieContainer, HttpMessageHandler> handlerFactory)
|
||||
{
|
||||
this.options = options.Ntlm;
|
||||
this.logger = logger;
|
||||
this.handlerFactory = handlerFactory;
|
||||
}
|
||||
|
||||
public async Task<NtlmValidationResult> ValidateAsync(
|
||||
UserIdentity identity,
|
||||
@@ -24,10 +44,372 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
|
||||
NetworkCredential credential = new(identity.UserName, password, this.options.Domain);
|
||||
CredentialCache credentialCache = new();
|
||||
HashSet<string> credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase);
|
||||
CookieContainer cookieContainer = new();
|
||||
|
||||
using HttpClientHandler handler = new()
|
||||
using HttpMessageHandler handler = handlerFactory(credentialCache, cookieContainer);
|
||||
using HttpClient client = new(handler)
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan,
|
||||
DefaultRequestVersion = HttpVersion.Version11,
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||
};
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0");
|
||||
|
||||
try
|
||||
{
|
||||
(NtlmValidationResult? authenticationFailure, Uri? continuationUri) = await ValidateCredentialsAsync(
|
||||
client,
|
||||
authenticationUri,
|
||||
allowedHosts,
|
||||
credentialCache,
|
||||
credential,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (authenticationFailure is not null)
|
||||
{
|
||||
return authenticationFailure;
|
||||
}
|
||||
|
||||
InstitutionalProfile? profile = await TryFetchProfileAsync(
|
||||
client,
|
||||
identity,
|
||||
allowedHosts,
|
||||
continuationUri,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return NtlmValidationResult.Valid(profile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
credential.Password = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(NtlmValidationResult? Failure, Uri? ContinuationUri)> ValidateCredentialsAsync(
|
||||
HttpClient client,
|
||||
Uri authenticationUri,
|
||||
HashSet<string> allowedHosts,
|
||||
CredentialCache credentialCache,
|
||||
NetworkCredential credential,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsAllowedHttpsUri(authenticationUri, allowedHosts))
|
||||
{
|
||||
return (NtlmValidationResult.Unavailable("NTLM_AUTH_ENDPOINT_REJECTED"), null);
|
||||
}
|
||||
|
||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds));
|
||||
Stopwatch elapsed = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
Uri current = authenticationUri;
|
||||
for (int hop = 0; hop <= options.MaxRedirects; hop++)
|
||||
{
|
||||
if (!IsAllowedHttpsUri(current, allowedHosts))
|
||||
{
|
||||
return (NtlmValidationResult.Unavailable("NTLM_AUTH_ENDPOINT_REJECTED"), null);
|
||||
}
|
||||
|
||||
using HttpRequestMessage probeRequest = new(HttpMethod.Get, current);
|
||||
using HttpResponseMessage probeResponse = await client
|
||||
.SendAsync(probeRequest, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
int probeStatus = (int)probeResponse.StatusCode;
|
||||
if (probeStatus is >= 300 and < 400)
|
||||
{
|
||||
Uri? redirect = ResolveAllowedRedirect(current, probeResponse, allowedHosts);
|
||||
if (redirect is null)
|
||||
{
|
||||
return (NtlmValidationResult.Unavailable("NTLM_AUTH_REDIRECT_REJECTED"), null);
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"SGU authentication discovery followed redirect hop {Hop} to {Path}.",
|
||||
hop + 1,
|
||||
redirect.AbsolutePath);
|
||||
current = redirect;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (probeResponse.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
if (!OffersNtlmChallenge(probeResponse))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SGU returned 401 without an NTLM challenge at {Path}.",
|
||||
current.AbsolutePath);
|
||||
return (NtlmValidationResult.Unavailable("NTLM_CHALLENGE_MISSING"), null);
|
||||
}
|
||||
|
||||
await DrainResponseAsync(probeResponse, timeout.Token).ConfigureAwait(false);
|
||||
AddCredential(current, credentialCache, credential);
|
||||
(NtlmValidationResult? failure, Uri? continuationUri) = await AuthenticateChallengedEndpointAsync(
|
||||
client,
|
||||
current,
|
||||
allowedHosts,
|
||||
timeout.Token).ConfigureAwait(false);
|
||||
if (failure is not null)
|
||||
{
|
||||
return (failure, null);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.",
|
||||
elapsed.ElapsedMilliseconds);
|
||||
return (null, continuationUri);
|
||||
}
|
||||
|
||||
if (probeResponse.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
return (NtlmValidationResult.Unavailable("NTLM_CHALLENGE_REJECTED"), null);
|
||||
}
|
||||
|
||||
if (probeStatus is >= 200 and < 300)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SGU authentication discovery reached {StatusCode} at {Path} without an NTLM challenge; credentials were not accepted.",
|
||||
probeStatus,
|
||||
current.AbsolutePath);
|
||||
return (NtlmValidationResult.Unavailable("NTLM_CHALLENGE_MISSING"), null);
|
||||
}
|
||||
|
||||
return probeStatus is 429 or >= 500
|
||||
? (NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR"), null)
|
||||
: (NtlmValidationResult.Unavailable("NTLM_UNEXPECTED_RESPONSE"), null);
|
||||
}
|
||||
|
||||
return (NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT"), null);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.",
|
||||
elapsed.ElapsedMilliseconds);
|
||||
return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"SGU NTLM authentication failed after {ElapsedMilliseconds} ms.",
|
||||
elapsed.ElapsedMilliseconds);
|
||||
return (NtlmValidationResult.Unavailable(), null);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(NtlmValidationResult? Failure, Uri? ContinuationUri)> AuthenticateChallengedEndpointAsync(
|
||||
HttpClient client,
|
||||
Uri challengeUri,
|
||||
HashSet<string> allowedHosts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using HttpRequestMessage authenticationRequest = new(HttpMethod.Get, challengeUri);
|
||||
using HttpResponseMessage authenticationResponse = await client
|
||||
.SendAsync(authenticationRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (authenticationResponse.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
return (NtlmValidationResult.Invalid(), null);
|
||||
}
|
||||
|
||||
int statusCode = (int)authenticationResponse.StatusCode;
|
||||
if (statusCode is >= 200 and < 300)
|
||||
{
|
||||
await DrainResponseAsync(authenticationResponse, cancellationToken).ConfigureAwait(false);
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
if (statusCode is >= 300 and < 400)
|
||||
{
|
||||
Uri? continuationUri = ResolveAllowedRedirect(
|
||||
challengeUri,
|
||||
authenticationResponse,
|
||||
allowedHosts);
|
||||
if (continuationUri is null)
|
||||
{
|
||||
return (NtlmValidationResult.Unavailable("NTLM_AUTH_REDIRECT_REJECTED"), null);
|
||||
}
|
||||
|
||||
await DrainResponseAsync(authenticationResponse, cancellationToken).ConfigureAwait(false);
|
||||
return (null, continuationUri);
|
||||
}
|
||||
|
||||
return statusCode is 429 or >= 500
|
||||
? (NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR"), null)
|
||||
: (NtlmValidationResult.Unavailable("NTLM_UNEXPECTED_RESPONSE"), null);
|
||||
}
|
||||
|
||||
private async Task<InstitutionalProfile?> TryFetchProfileAsync(
|
||||
HttpClient client,
|
||||
UserIdentity identity,
|
||||
HashSet<string> allowedHosts,
|
||||
Uri? continuationUri,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Stopwatch elapsed = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
Uri originalProfileUri = GetProfileUri(identity.Role);
|
||||
Uri menuUri = GetProfileUri(InstitutionalRole.Professor);
|
||||
Uri current = continuationUri ?? originalProfileUri;
|
||||
bool bootstrappingSession = continuationUri is not null;
|
||||
bool retriedAfterSessionBootstrap = continuationUri is not null;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
logger.LogDebug(
|
||||
"SGU profile response for role {Role} returned HTTP {StatusCode} at hop {Hop} after {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
statusCode,
|
||||
hop,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
if (statusCode is >= 300 and < 400)
|
||||
{
|
||||
Uri? redirect = ResolveAllowedRedirect(current, response, allowedHosts);
|
||||
if (redirect is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SGU profile redirect was rejected for role {Role} at hop {Hop}.",
|
||||
identity.Role,
|
||||
hop + 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
await DrainResponseAsync(response, timeout.Token).ConfigureAwait(false);
|
||||
current = redirect;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (statusCode is >= 200 and < 300)
|
||||
{
|
||||
if (bootstrappingSession)
|
||||
{
|
||||
await DrainResponseAsync(response, timeout.Token).ConfigureAwait(false);
|
||||
bootstrappingSession = false;
|
||||
current = originalProfileUri;
|
||||
logger.LogInformation(
|
||||
"SGU post-authentication session bootstrap completed for role {Role}; requesting the original profile page.",
|
||||
identity.Role);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (identity.Role != InstitutionalRole.Professor &&
|
||||
!retriedAfterSessionBootstrap &&
|
||||
HasSamePath(current, menuUri))
|
||||
{
|
||||
await DrainResponseAsync(response, timeout.Token).ConfigureAwait(false);
|
||||
retriedAfterSessionBootstrap = true;
|
||||
current = originalProfileUri;
|
||||
logger.LogInformation(
|
||||
"SGU ASP.NET session bootstrap completed for role {Role}; retrying the original profile page.",
|
||||
identity.Role);
|
||||
continue;
|
||||
}
|
||||
|
||||
InstitutionalProfile? profile = await TryReadProfileAsync(
|
||||
response,
|
||||
identity,
|
||||
timeout.Token).ConfigureAwait(false);
|
||||
if (profile is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(
|
||||
"SGU profile enrichment completed for role {Role} in {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"SGU profile request for role {Role} returned HTTP {StatusCode} after {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
statusCode,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"SGU profile request for role {Role} exceeded the redirect limit after {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddCredential(
|
||||
Uri uri,
|
||||
CredentialCache credentialCache,
|
||||
NetworkCredential credential)
|
||||
{
|
||||
string authority = uri.GetLeftPart(UriPartial.Authority);
|
||||
credentialCache.Add(new Uri(authority + "/"), "NTLM", credential);
|
||||
}
|
||||
|
||||
private static bool OffersNtlmChallenge(HttpResponseMessage response) =>
|
||||
response.Headers.WwwAuthenticate.Any(value =>
|
||||
string.Equals(value.Scheme, "NTLM", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static Uri? ResolveAllowedRedirect(
|
||||
Uri current,
|
||||
HttpResponseMessage response,
|
||||
HashSet<string> allowedHosts)
|
||||
{
|
||||
Uri? location = response.Headers.Location;
|
||||
Uri? redirect = location is null
|
||||
? null
|
||||
: location.IsAbsoluteUri ? location : new Uri(current, location);
|
||||
return redirect is not null && IsAllowedHttpsUri(redirect, allowedHosts)
|
||||
? redirect
|
||||
: null;
|
||||
}
|
||||
|
||||
private static HttpMessageHandler CreateHandler(
|
||||
CredentialCache credentialCache,
|
||||
CookieContainer cookieContainer) =>
|
||||
new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
@@ -42,190 +424,38 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
UseProxy = false
|
||||
};
|
||||
|
||||
using HttpClient client = new(handler)
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan,
|
||||
DefaultRequestVersion = HttpVersion.Version11,
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||
};
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0");
|
||||
private static bool HasSamePath(Uri left, Uri right) =>
|
||||
string.Equals(
|
||||
left.AbsolutePath.TrimEnd('/'),
|
||||
right.AbsolutePath.TrimEnd('/'),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
try
|
||||
{
|
||||
NtlmValidationResult? authenticationFailure = await ValidateCredentialsAsync(
|
||||
client,
|
||||
authenticationUri,
|
||||
allowedHosts,
|
||||
credentialCache,
|
||||
credentialedAuthorities,
|
||||
credential,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (authenticationFailure is not null)
|
||||
{
|
||||
return authenticationFailure;
|
||||
}
|
||||
|
||||
InstitutionalProfile? profile = await TryFetchProfileAsync(
|
||||
client,
|
||||
identity,
|
||||
allowedHosts,
|
||||
credentialCache,
|
||||
credentialedAuthorities,
|
||||
credential,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return NtlmValidationResult.Valid(profile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
credential.Password = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NtlmValidationResult?> ValidateCredentialsAsync(
|
||||
HttpClient client,
|
||||
Uri authenticationUri,
|
||||
HashSet<string> allowedHosts,
|
||||
CredentialCache credentialCache,
|
||||
HashSet<string> credentialedAuthorities,
|
||||
NetworkCredential credential,
|
||||
private static async Task DrainResponseAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsAllowedHttpsUri(authenticationUri, allowedHosts))
|
||||
const int maximumDrainBytes = 64 * 1024;
|
||||
if (response.Content.Headers.ContentLength is > maximumDrainBytes)
|
||||
{
|
||||
return NtlmValidationResult.Unavailable("NTLM_AUTH_ENDPOINT_REJECTED");
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
await using Stream stream = await response.Content
|
||||
.ReadAsStreamAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
byte[] buffer = new byte[8192];
|
||||
int total = 0;
|
||||
while (total <= maximumDrainBytes)
|
||||
{
|
||||
response = await client
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
||||
int read = await stream
|
||||
.ReadAsync(buffer.AsMemory(), cancellationToken)
|
||||
.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)
|
||||
if (read == 0)
|
||||
{
|
||||
return NtlmValidationResult.Invalid();
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
total += read;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,37 +475,23 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||
HttpResponseMessage response,
|
||||
UserIdentity identity,
|
||||
CancellationToken timeoutToken,
|
||||
CancellationToken requestCancellationToken)
|
||||
CancellationToken timeoutToken)
|
||||
{
|
||||
try
|
||||
string html = await ReadLimitedStringAsync(
|
||||
response.Content,
|
||||
options.MaxProfileBytes,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
return identity.Role switch
|
||||
{
|
||||
string html = await ReadLimitedStringAsync(
|
||||
response.Content,
|
||||
options.MaxProfileBytes,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLimitedStringAsync(
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"Endpoint": "https://sgu.ulsa.edu.mx/",
|
||||
"Domain": "",
|
||||
"TimeoutSeconds": 20,
|
||||
"ProfileTimeoutSeconds": 10,
|
||||
"ProfileTimeoutSeconds": 60,
|
||||
"MaxRedirects": 5,
|
||||
"AuthenticationPath": "/psulsa/",
|
||||
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||
|
||||
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
|
||||
|
||||
public string DomainNetbios { get; init; } = "LCI";
|
||||
|
||||
public int TimeoutSeconds { get; init; } = 35;
|
||||
public int TimeoutSeconds { get; init; } = 90;
|
||||
|
||||
public string ClientCertificateThumbprint { get; init; } = string.Empty;
|
||||
|
||||
@@ -54,7 +54,7 @@ internal sealed class ProviderSettings
|
||||
throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 60)
|
||||
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 90)
|
||||
{
|
||||
throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ internal static class ProviderTileIcon
|
||||
using Graphics graphics = Graphics.FromImage(bitmap);
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
graphics.Clear(Color.FromArgb(0, 83, 155));
|
||||
graphics.Clear(Color.Transparent);
|
||||
|
||||
using SolidBrush background = new(Color.FromArgb(0, 83, 155));
|
||||
graphics.FillEllipse(background, 1, 1, Size - 2, Size - 2);
|
||||
|
||||
using Pen key = new(Color.White, 5.5f)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
|
||||
"DomainNetbios": "LCI",
|
||||
"TimeoutSeconds": 35,
|
||||
"TimeoutSeconds": 90,
|
||||
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
|
||||
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user