225 lines
8.3 KiB
C#
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);
|
|
}
|