Add six-month domain and broker monitoring
This commit is contained in:
@@ -7,7 +7,9 @@ using SGU.AuthBroker.Options;
|
||||
|
||||
namespace SGU.AuthBroker.Services;
|
||||
|
||||
public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer
|
||||
public sealed class ActiveDirectorySynchronizer(
|
||||
BrokerOptions options,
|
||||
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
|
||||
{
|
||||
private const int AccountDisabled = 0x0002;
|
||||
private const int NormalAccount = 0x0200;
|
||||
@@ -28,9 +30,26 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await Task.Run(
|
||||
() => Synchronize(identity, profile, password),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await Task.Run(
|
||||
() => Synchronize(identity, profile, password),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
BrokerEventIds.DirectorySynchronizationFailure,
|
||||
exception,
|
||||
"Active Directory synchronization failed for {InstitutionalUser} with role {Role}.",
|
||||
identity.UserName,
|
||||
identity.Role);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -100,8 +119,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
user.Properties["pwdLastSet"].Value = -1;
|
||||
user.CommitChanges();
|
||||
|
||||
TryApplyProfile(user, identity, profile, options.DefaultCompany);
|
||||
TryEnsureRemoteDesktopGroupMembership(user);
|
||||
TryApplyProfile(user, identity, profile, options.DefaultCompany, logger);
|
||||
TryEnsureRemoteDesktopGroupMembership(user, identity.UserName);
|
||||
|
||||
return new DirectorySyncResult(
|
||||
options.DomainNetbios,
|
||||
@@ -120,7 +139,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
DirectoryEntry user,
|
||||
UserIdentity identity,
|
||||
InstitutionalProfile? profile,
|
||||
string defaultCompany)
|
||||
string defaultCompany,
|
||||
ILogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -146,10 +166,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
|
||||
user.CommitChanges();
|
||||
}
|
||||
catch
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Metadata is intentionally best-effort. User creation, password sync,
|
||||
// and account enablement have already committed successfully.
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.DirectoryOptionalMetadataFailure,
|
||||
exception,
|
||||
"Optional Active Directory profile metadata could not be committed for {InstitutionalUser}; password synchronization remains completed.",
|
||||
identity.UserName);
|
||||
try
|
||||
{
|
||||
user.RefreshCache();
|
||||
@@ -170,7 +195,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
}
|
||||
}
|
||||
|
||||
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user)
|
||||
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
|
||||
{
|
||||
@@ -194,10 +219,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
group.CommitChanges();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Remote access is lab policy and must not invalidate a completed
|
||||
// password synchronization if the optional group is unavailable.
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.DirectoryGroupMembershipFailure,
|
||||
exception,
|
||||
"Optional remote-desktop group membership could not be updated for {InstitutionalUser}; password synchronization remains completed.",
|
||||
institutionalUser);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
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);
|
||||
@@ -155,6 +159,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
BrokerEventIds.SguAuthenticationAccepted,
|
||||
"SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.",
|
||||
elapsed.ElapsedMilliseconds);
|
||||
return (null, continuationUri);
|
||||
@@ -184,6 +189,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
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);
|
||||
@@ -191,6 +197,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.SguAuthenticationNetworkFailure,
|
||||
exception,
|
||||
"SGU NTLM authentication failed after {ElapsedMilliseconds} ms.",
|
||||
elapsed.ElapsedMilliseconds);
|
||||
@@ -347,6 +354,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
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);
|
||||
@@ -354,8 +362,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
else
|
||||
{
|
||||
logger.LogInformation(
|
||||
"SGU profile enrichment completed for role {Role} in {ElapsedMilliseconds} ms.",
|
||||
BrokerEventIds.ProfileEnrichmentCompleted,
|
||||
"SGU profile enrichment completed for role {Role} with {ProfileFieldCount} supported fields in {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
CountProfileFields(profile),
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
@@ -382,6 +392,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileEnrichmentTimeout,
|
||||
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
@@ -389,6 +400,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileEnrichmentFailure,
|
||||
exception,
|
||||
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
|
||||
identity.Role,
|
||||
@@ -423,11 +435,33 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
GetProfileUri(path),
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
profile = profile.Overlay(html is null ? null : parser(html));
|
||||
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,
|
||||
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,
|
||||
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.",
|
||||
role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
@@ -436,6 +470,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
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.",
|
||||
role,
|
||||
@@ -487,6 +522,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfilePageUnavailable,
|
||||
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
|
||||
requestedUri.AbsolutePath,
|
||||
statusCode);
|
||||
@@ -494,6 +530,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfilePageUnavailable,
|
||||
"Optional SGU profile page {Path} exceeded the redirect limit.",
|
||||
requestedUri.AbsolutePath);
|
||||
return null;
|
||||
@@ -550,6 +587,23 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
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)
|
||||
@@ -604,17 +658,31 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
response.Content,
|
||||
options.MaxProfileBytes,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
return identity.Role switch
|
||||
InstitutionalProfile? profile;
|
||||
switch (identity.Role)
|
||||
{
|
||||
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
|
||||
};
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user