using System.Diagnostics; 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 : INtlmCredentialValidator { private readonly NtlmOptions options; private readonly ILogger logger; private readonly Func handlerFactory; public NtlmCredentialValidator( BrokerOptions options, ILogger logger) : this(options, logger, CreateHandler) { } internal NtlmCredentialValidator( BrokerOptions options, ILogger logger, Func handlerFactory) { this.options = options.Ntlm; this.logger = logger; this.handlerFactory = handlerFactory; } public async Task ValidateAsync( UserIdentity identity, string password, CancellationToken cancellationToken) { Uri authenticationUri = new( new Uri(options.Endpoint, UriKind.Absolute), options.AuthenticationPath); HashSet allowedHosts = new( this.options.AllowedRedirectHosts, StringComparer.OrdinalIgnoreCase); NetworkCredential credential = new(identity.UserName, password, this.options.Domain); CredentialCache credentialCache = new(); CookieContainer cookieContainer = 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 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 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 TryFetchProfileAsync( HttpClient client, UserIdentity identity, HashSet 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 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, CheckCertificateRevocationList = true, CookieContainer = cookieContainer, Credentials = credentialCache, MaxConnectionsPerServer = 4, MaxResponseHeadersLength = 64, PreAuthenticate = false, UseCookies = true, UseDefaultCredentials = false, UseProxy = false }; private static bool HasSamePath(Uri left, Uri right) => string.Equals( left.AbsolutePath.TrimEnd('/'), right.AbsolutePath.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); private static async Task DrainResponseAsync( HttpResponseMessage response, CancellationToken cancellationToken) { const int maximumDrainBytes = 64 * 1024; if (response.Content.Headers.ContentLength is > maximumDrainBytes) { return; } await using Stream stream = await response.Content .ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); byte[] buffer = new byte[8192]; int total = 0; while (total <= maximumDrainBytes) { int read = await stream .ReadAsync(buffer.AsMemory(), cancellationToken) .ConfigureAwait(false); if (read == 0) { return; } total += read; } } 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 TryReadProfileAsync( HttpResponseMessage response, UserIdentity identity, CancellationToken timeoutToken) { 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 }; } private static async Task 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 allowedHosts) => uri.Scheme == Uri.UriSchemeHttps && string.IsNullOrEmpty(uri.UserInfo) && allowedHosts.Contains(uri.IdnHost); }