921 lines
36 KiB
C#
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);
|
|
}
|