|
|
|
@@ -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(
|
|
|
|
|