Files
SGU-CredentialProvider/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs
T

225 lines
8.3 KiB
C#

using System.Net;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Options;
namespace SGU.AuthBroker.Services;
public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCredentialValidator
{
private readonly NtlmOptions options = options.Ntlm;
public async Task<NtlmValidationResult> ValidateAsync(
UserIdentity identity,
string password,
CancellationToken cancellationToken)
{
Uri current = GetProfileUri(identity.Role);
HashSet<string> allowedHosts = new(
this.options.AllowedRedirectHosts,
StringComparer.OrdinalIgnoreCase);
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()
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.All,
CheckCertificateRevocationList = true,
CookieContainer = cookieContainer,
Credentials = credentialCache,
MaxConnectionsPerServer = 4,
MaxResponseHeadersLength = 64,
PreAuthenticate = false,
UseCookies = true,
UseDefaultCredentials = false,
UseProxy = false
};
using HttpClient client = new(handler)
{
Timeout = Timeout.InfiniteTimeSpan,
DefaultRequestVersion = HttpVersion.Version11,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0");
try
{
for (int hop = 0; hop <= this.options.MaxRedirects; hop++)
{
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 NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT");
}
finally
{
credential.Password = string.Empty;
}
}
private Uri GetProfileUri(InstitutionalRole role)
{
Uri endpoint = new(options.Endpoint, UriKind.Absolute);
string path = role switch
{
InstitutionalRole.Administrative => options.AdministrativeProfilePath,
InstitutionalRole.Student => options.StudentProfilePath,
InstitutionalRole.Professor => options.MenuProfilePath,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
};
return new Uri(endpoint, path);
}
private async Task<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken)
{
try
{
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;
}
}
private static async Task<string> ReadLimitedStringAsync(
HttpContent content,
int maximumBytes,
CancellationToken cancellationToken)
{
if (content.Headers.ContentLength is long contentLength && contentLength > maximumBytes)
{
throw new InvalidDataException("The SGU profile response exceeded the configured limit.");
}
await using Stream stream = await content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
using MemoryStream buffer = new(Math.Min(maximumBytes, 64 * 1024));
byte[] chunk = new byte[8192];
while (true)
{
int read = await stream
.ReadAsync(chunk.AsMemory(), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
if (buffer.Length + read > maximumBytes)
{
throw new InvalidDataException("The SGU profile response exceeded the configured limit.");
}
buffer.Write(chunk, 0, read);
}
return SguHtmlDecoder.Decode(
buffer.GetBuffer().AsSpan(0, checked((int)buffer.Length)),
content.Headers.ContentType?.CharSet);
}
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
uri.Scheme == Uri.UriSchemeHttps &&
string.IsNullOrEmpty(uri.UserInfo) &&
allowedHosts.Contains(uri.IdnHost);
}