Enrich AD users from SGU profile metadata
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
using SGU.AuthBroker.Options;
|
||||
|
||||
namespace SGU.AuthBroker.Services;
|
||||
@@ -9,29 +12,31 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
private readonly NtlmOptions options = options.Ntlm;
|
||||
|
||||
public async Task<NtlmValidationResult> ValidateAsync(
|
||||
string userName,
|
||||
UserIdentity identity,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Uri current = new(this.options.Endpoint, UriKind.Absolute);
|
||||
Uri current = GetProfileUri(identity.Role);
|
||||
HashSet<string> allowedHosts = new(
|
||||
this.options.AllowedRedirectHosts,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
NetworkCredential credential = new(userName, password, this.options.Domain);
|
||||
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 = false,
|
||||
UseCookies = true,
|
||||
UseDefaultCredentials = false,
|
||||
UseProxy = false
|
||||
};
|
||||
@@ -104,9 +109,17 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
continue;
|
||||
}
|
||||
|
||||
return statusCode is >= 200 and < 300
|
||||
? NtlmValidationResult.Valid()
|
||||
: NtlmValidationResult.Invalid();
|
||||
if (statusCode is >= 200 and < 300)
|
||||
{
|
||||
InstitutionalProfile? profile = await TryReadProfileAsync(
|
||||
response,
|
||||
identity,
|
||||
timeout.Token,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return NtlmValidationResult.Valid(profile);
|
||||
}
|
||||
|
||||
return NtlmValidationResult.Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +131,93 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
}
|
||||
}
|
||||
|
||||
private Uri GetProfileUri(InstitutionalRole role)
|
||||
{
|
||||
Uri endpoint = new(options.Endpoint, UriKind.Absolute);
|
||||
string path = role == InstitutionalRole.Administrative
|
||||
? options.AdministrativeProfilePath
|
||||
: options.MenuProfilePath;
|
||||
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 == InstitutionalRole.Administrative
|
||||
? SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
|
||||
SguProfileParser.ParseMenu(html)
|
||||
: SguProfileParser.ParseMenu(html);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
string? charset = content.Headers.ContentType?.CharSet?.Trim('"', '\'');
|
||||
Encoding encoding;
|
||||
try
|
||||
{
|
||||
encoding = string.IsNullOrWhiteSpace(charset)
|
||||
? Encoding.UTF8
|
||||
: Encoding.GetEncoding(charset);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
encoding = Encoding.UTF8;
|
||||
}
|
||||
|
||||
return encoding.GetString(buffer.GetBuffer(), 0, checked((int)buffer.Length));
|
||||
}
|
||||
|
||||
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
|
||||
uri.Scheme == Uri.UriSchemeHttps &&
|
||||
string.IsNullOrEmpty(uri.UserInfo) &&
|
||||
|
||||
Reference in New Issue
Block a user