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

921 lines
36 KiB
C#

using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
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<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,
string password,
CancellationToken cancellationToken)
{
using IDisposable? logScope = logger.BeginScope(
"InstitutionalUser={InstitutionalUser}; InstitutionalRole={InstitutionalRole}",
identity.UserName,
identity.Role);
Uri authenticationUri = new(
new Uri(options.Endpoint, UriKind.Absolute),
options.AuthenticationPath);
HashSet<string> 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<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(
BrokerEventIds.SguAuthenticationAccepted,
"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(
BrokerEventIds.SguAuthenticationTimeout,
"SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds);
return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null);
}
catch (HttpRequestException exception)
{
logger.LogWarning(
BrokerEventIds.SguAuthenticationNetworkFailure,
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);
bool mayEnrichStaffProfile =
(identity.Role == InstitutionalRole.Administrative &&
string.Equals(
profile?.EmployeeNumber,
identity.NumericId,
StringComparison.Ordinal)) ||
(identity.Role == InstitutionalRole.Professor && profile is not null);
if (mayEnrichStaffProfile)
{
profile = await TryEnrichStaffProfileAsync(
client,
profile!,
identity,
allowedHosts,
timeout.Token,
cancellationToken,
elapsed).ConfigureAwait(false);
}
if (profile is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"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(
BrokerEventIds.ProfileEnrichmentCompleted,
"SGU profile enrichment completed for role {Role} with {ProfileFieldCount} supported fields in {ElapsedMilliseconds} ms.",
identity.Role,
CountProfileFields(profile),
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(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
catch (Exception exception)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception,
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
return null;
}
private async Task<InstitutionalProfile> TryEnrichStaffProfileAsync(
HttpClient client,
InstitutionalProfile baseProfile,
UserIdentity identity,
HashSet<string> allowedHosts,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken,
Stopwatch elapsed)
{
InstitutionalProfile profile = baseProfile;
List<(string Path, Func<string, InstitutionalProfile?> Parser)> pages = [];
if (identity.Role == InstitutionalRole.Professor)
{
pages.Add((
options.ProfessorPayrollProfilePath,
html => SguProfileParser.ParseProfessorPayroll(html, identity.NumericId)));
}
pages.Add((
options.AdministrativePersonalProfilePath,
SguProfileParser.ParseAdministrativePersonal));
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
{
try
{
string? html = await TryFetchAdditionalProfilePageAsync(
client,
GetProfileUri(path),
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (html is null)
{
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
path,
identity.Role);
continue;
}
InstitutionalProfile? pageProfile = parser(html);
if (pageProfile is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
path,
identity.Role);
continue;
}
profile = profile.Overlay(pageProfile);
}
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
return profile;
}
catch (Exception exception)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception,
"An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
}
return await TryEnrichStaffLocationAsync(
client,
profile,
identity,
allowedHosts,
timeoutToken,
requestCancellationToken,
elapsed).ConfigureAwait(false);
}
private async Task<InstitutionalProfile> TryEnrichStaffLocationAsync(
HttpClient client,
InstitutionalProfile profile,
UserIdentity identity,
HashSet<string> allowedHosts,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken,
Stopwatch elapsed)
{
string path = options.AdministrativeLocationProfilePath;
Uri locationPageUri = GetProfileUri(path);
try
{
string? html = await TryFetchAdditionalProfilePageAsync(
client,
locationPageUri,
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (html is null)
{
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html);
if (staticLocation is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
profile = profile.Overlay(staticLocation);
if (string.IsNullOrWhiteSpace(staticLocation.PostalCode))
{
return profile;
}
string? directionJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetDireccion"),
locationPageUri,
new Dictionary<string, string>
{
["CodigoPostal"] = staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (directionJson is null)
{
return profile;
}
SguAdministrativeLocationSelection? selection =
SguProfileParser.ParseAdministrativeLocationSelection(
directionJson,
staticLocation.PostalCode);
if (selection is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.",
identity.Role);
return profile;
}
string? localitiesJson = null;
if (!string.IsNullOrWhiteSpace(selection.StateId))
{
localitiesJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetLocalidadListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = selection.StateId
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
}
string? neighborhoodsJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetColoniasListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = string.Empty,
["pLocalidad"] = string.Empty,
["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation(
html,
selection,
localitiesJson,
neighborhoodsJson);
return profile.Overlay(resolvedLocation);
}
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
catch (Exception exception)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception,
"SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
return profile;
}
private async Task<string?> TryFetchAdditionalProfilePageAsync(
HttpClient client,
Uri requestedUri,
HashSet<string> allowedHosts,
CancellationToken cancellationToken)
{
Uri current = requestedUri;
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, cancellationToken)
.ConfigureAwait(false);
int statusCode = (int)response.StatusCode;
if (statusCode is >= 300 and < 400)
{
Uri? redirect = ResolveAllowedRedirect(current, response, allowedHosts);
if (redirect is null)
{
return null;
}
await DrainResponseAsync(response, cancellationToken).ConfigureAwait(false);
current = redirect;
continue;
}
if (statusCode is >= 200 and < 300)
{
return await ReadLimitedStringAsync(
response.Content,
options.MaxProfileBytes,
cancellationToken).ConfigureAwait(false);
}
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
requestedUri.AbsolutePath,
statusCode);
return null;
}
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} exceeded the redirect limit.",
requestedUri.AbsolutePath);
return null;
}
private async Task<string?> TryPostProfilePageMethodAsync(
HttpClient client,
Uri requestedUri,
Uri referrerUri,
IReadOnlyDictionary<string, string> payload,
HashSet<string> allowedHosts,
CancellationToken cancellationToken)
{
if (!IsAllowedHttpsUri(requestedUri, allowedHosts) ||
!IsAllowedHttpsUri(referrerUri, allowedHosts))
{
return null;
}
using HttpRequestMessage request = new(HttpMethod.Post, requestedUri);
request.Headers.Referrer = referrerUri;
request.Content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
using HttpResponseMessage response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
int statusCode = (int)response.StatusCode;
if (statusCode is >= 200 and < 300)
{
return await ReadLimitedStringAsync(
response.Content,
options.MaxProfileBytes,
cancellationToken).ConfigureAwait(false);
}
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile method {Path} returned HTTP {StatusCode}.",
requestedUri.AbsolutePath,
statusCode);
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,
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 int CountProfileFields(InstitutionalProfile profile) =>
new[]
{
profile.EmployeeNumber,
profile.DisplayName,
profile.GivenName,
profile.Surname,
profile.Email,
profile.EmployeeType,
profile.JobTitle,
profile.Department,
profile.StreetAddress,
profile.City,
profile.State,
profile.PostalCode
}.Count(value => !string.IsNullOrWhiteSpace(value));
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 Uri GetProfileUri(string path) =>
new(new Uri(options.Endpoint, UriKind.Absolute), path);
private Uri GetAdministrativeLocationMethodUri(string methodName)
{
Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath);
return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}");
}
private async Task<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,
CancellationToken timeoutToken)
{
string html = await ReadLimitedStringAsync(
response.Content,
options.MaxProfileBytes,
timeoutToken).ConfigureAwait(false);
InstitutionalProfile? profile;
switch (identity.Role)
{
case InstitutionalRole.Administrative:
profile = SguProfileParser.ParseAdministrative(html, identity.NumericId);
break;
case InstitutionalRole.Student:
profile = SguProfileParser.ParseStudent(html, identity.NumericId);
break;
case InstitutionalRole.Professor:
return SguProfileParser.ParseMenu(html);
default:
return null;
}
if (profile is not null)
{
return profile;
}
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"The primary SGU profile HTML did not contain the supported field IDs for role {Role}; attempting the menu-name fallback.",
identity.Role);
return SguProfileParser.ParseMenu(html);
}
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);
}