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 ValidateAsync( UserIdentity identity, string password, CancellationToken cancellationToken) { Uri current = GetProfileUri(identity.Role); HashSet allowedHosts = new( this.options.AllowedRedirectHosts, StringComparer.OrdinalIgnoreCase); NetworkCredential credential = new(identity.UserName, password, this.options.Domain); CredentialCache credentialCache = new(); HashSet 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 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 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); }