Enrich AD users from SGU profile metadata

This commit is contained in:
2026-09-01 07:34:00 -06:00
parent 289a67e371
commit 3d0897316d
16 changed files with 563 additions and 22 deletions
@@ -23,7 +23,7 @@ public sealed class AuthenticationWorkflow(
try
{
validation = await ntlmValidator
.ValidateAsync(identity.UserName, password, cancellationToken)
.ValidateAsync(identity, password, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -57,7 +57,7 @@ public sealed class AuthenticationWorkflow(
try
{
DirectorySyncResult directory = await directorySynchronizer
.SynchronizeAsync(identity, password, cancellationToken)
.SynchronizeAsync(identity, validation.Profile, password, cancellationToken)
.ConfigureAwait(false);
return new AuthenticationFlowResult(
@@ -1,9 +1,11 @@
using SGU.AuthBroker.Core.Identity;
namespace SGU.AuthBroker.Core.Authentication;
public interface INtlmCredentialValidator
{
Task<NtlmValidationResult> ValidateAsync(
string userName,
UserIdentity identity,
string password,
CancellationToken cancellationToken);
}
@@ -1,3 +1,5 @@
using SGU.AuthBroker.Core.Profiles;
namespace SGU.AuthBroker.Core.Authentication;
public enum NtlmValidationStatus
@@ -7,9 +9,13 @@ public enum NtlmValidationStatus
Unavailable
}
public sealed record NtlmValidationResult(NtlmValidationStatus Status, string? ErrorCode = null)
public sealed record NtlmValidationResult(
NtlmValidationStatus Status,
string? ErrorCode = null,
InstitutionalProfile? Profile = null)
{
public static NtlmValidationResult Valid() => new(NtlmValidationStatus.Valid);
public static NtlmValidationResult Valid(InstitutionalProfile? profile = null) =>
new(NtlmValidationStatus.Valid, Profile: profile);
public static NtlmValidationResult Invalid() => new(NtlmValidationStatus.Invalid, "INVALID_INSTITUTIONAL_CREDENTIALS");
@@ -1,4 +1,5 @@
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
namespace SGU.AuthBroker.Core.Directory;
@@ -6,6 +7,7 @@ public interface IActiveDirectorySynchronizer
{
Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity,
InstitutionalProfile? profile,
string password,
CancellationToken cancellationToken);
}
@@ -0,0 +1,18 @@
namespace SGU.AuthBroker.Core.Profiles;
public sealed record InstitutionalProfile(
string? EmployeeNumber = null,
string? DisplayName = null,
string? Email = null,
string? EmployeeType = null,
string? JobTitle = null,
string? Department = null)
{
public bool HasValues =>
EmployeeNumber is not null ||
DisplayName is not null ||
Email is not null ||
EmployeeType is not null ||
JobTitle is not null ||
Department is not null;
}
@@ -0,0 +1,180 @@
using System.Net;
using System.Net.Mail;
using System.Text;
namespace SGU.AuthBroker.Core.Profiles;
public static class SguProfileParser
{
private const string AdministrativeNameId = "ctl00_contenedor_decEncabezado_lblNombre";
private const string EmployeeTypeId = "ctl00_contenedor_decEncabezado_lblIndicadorValue";
private const string EmailId = "ctl00_contenedor_decEncabezado_lblCorreo";
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
private const string MenuNameId = "ctl00_lblNombreUsuario";
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
{
ArgumentNullException.ThrowIfNull(html);
ArgumentException.ThrowIfNullOrWhiteSpace(expectedEmployeeNumber);
string? identity = ExtractSpanText(html, AdministrativeNameId);
if (!TrySplitAdministrativeIdentity(identity, out string? employeeNumber, out string? displayName) ||
!string.Equals(employeeNumber, expectedEmployeeNumber, StringComparison.Ordinal))
{
return null;
}
InstitutionalProfile profile = new(
EmployeeNumber: employeeNumber,
DisplayName: Limit(displayName, 256),
Email: NormalizeEmail(ExtractSpanText(html, EmailId)),
EmployeeType: Limit(ExtractSpanText(html, EmployeeTypeId), 256),
JobTitle: Limit(ExtractSpanText(html, JobTitleId), 64),
Department: Limit(ExtractSpanText(html, DepartmentId), 64));
return profile.HasValues ? profile : null;
}
public static InstitutionalProfile? ParseMenu(string html)
{
ArgumentNullException.ThrowIfNull(html);
InstitutionalProfile profile = new(
DisplayName: Limit(ExtractSpanText(html, MenuNameId), 256));
return profile.HasValues ? profile : null;
}
private static bool TrySplitAdministrativeIdentity(
string? value,
out string? employeeNumber,
out string? displayName)
{
employeeNumber = null;
displayName = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
int separator = value.IndexOf(" - ", StringComparison.Ordinal);
if (separator != 6)
{
return false;
}
string candidateNumber = value[..separator];
string candidateName = value[(separator + 3)..].Trim();
if (candidateNumber.Length != 6 ||
!candidateNumber.All(char.IsAsciiDigit) ||
string.IsNullOrWhiteSpace(candidateName))
{
return false;
}
employeeNumber = candidateNumber;
displayName = candidateName;
return true;
}
private static string? ExtractSpanText(string html, string id)
{
foreach (char quote in new[] { '"', '\'' })
{
string marker = $"id={quote}{id}{quote}";
int searchFrom = 0;
while (searchFrom < html.Length)
{
int idIndex = html.IndexOf(marker, searchFrom, StringComparison.OrdinalIgnoreCase);
if (idIndex < 0)
{
break;
}
int spanStart = html.LastIndexOf("<span", idIndex, StringComparison.OrdinalIgnoreCase);
int precedingTagEnd = html.LastIndexOf('>', idIndex);
if (spanStart > precedingTagEnd)
{
int openingTagEnd = html.IndexOf('>', idIndex);
int closingTagStart = openingTagEnd < 0
? -1
: html.IndexOf("</span", openingTagEnd + 1, StringComparison.OrdinalIgnoreCase);
if (openingTagEnd >= 0 && closingTagStart >= 0)
{
string innerHtml = html[(openingTagEnd + 1)..closingTagStart];
return NormalizeText(innerHtml);
}
}
searchFrom = idIndex + marker.Length;
}
}
return null;
}
private static string? NormalizeText(string htmlFragment)
{
StringBuilder withoutTags = new(htmlFragment.Length);
bool insideTag = false;
foreach (char character in htmlFragment)
{
if (character == '<')
{
insideTag = true;
}
else if (character == '>')
{
insideTag = false;
}
else if (!insideTag)
{
withoutTags.Append(character);
}
}
string decoded = WebUtility.HtmlDecode(withoutTags.ToString());
StringBuilder normalized = new(decoded.Length);
bool previousWasWhitespace = true;
foreach (char character in decoded)
{
bool whitespace = char.IsWhiteSpace(character) || character == '\u00A0';
if (whitespace)
{
if (!previousWasWhitespace)
{
normalized.Append(' ');
}
}
else
{
normalized.Append(character);
}
previousWasWhitespace = whitespace;
}
string result = normalized.ToString().Trim();
return result.Length == 0 ? null : result;
}
private static string? NormalizeEmail(string? value)
{
string? candidate = Limit(value, 256);
if (candidate is null ||
!MailAddress.TryCreate(candidate, out MailAddress? address) ||
!string.Equals(address.Address, candidate, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return address.Address;
}
private static string? Limit(string? value, int maximumLength)
{
string? candidate = value?.Trim();
return string.IsNullOrEmpty(candidate) || candidate.Length > maximumLength
? null
: candidate;
}
}
@@ -36,6 +36,27 @@ public sealed class BrokerOptions
throw new InvalidOperationException("NTLM timeout or redirect limits are outside the supported range.");
}
if (Ntlm.MaxProfileBytes is < 32 * 1024 or > 2 * 1024 * 1024)
{
throw new InvalidOperationException("The SGU profile response limit is outside the supported range.");
}
foreach (string profilePath in new[] { Ntlm.AdministrativeProfilePath, Ntlm.MenuProfilePath })
{
if (string.IsNullOrWhiteSpace(profilePath))
{
throw new InvalidOperationException("SGU profile paths are required.");
}
Uri profileUri = new(endpoint, profilePath);
if (profileUri.Scheme != Uri.UriSchemeHttps ||
!string.IsNullOrEmpty(profileUri.UserInfo) ||
!Ntlm.AllowedRedirectHosts.Contains(profileUri.IdnHost, StringComparer.OrdinalIgnoreCase))
{
throw new InvalidOperationException("SGU profile paths must resolve to an allowed HTTPS host.");
}
}
if (string.IsNullOrWhiteSpace(Directory.LdapHost) ||
string.IsNullOrWhiteSpace(Directory.BaseDn) ||
string.IsNullOrWhiteSpace(Directory.DomainNetbios) ||
@@ -83,6 +104,13 @@ public sealed class NtlmOptions
public int MaxRedirects { get; init; } = 5;
public string AdministrativeProfilePath { get; init; } =
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx";
public int MaxProfileBytes { get; init; } = 512 * 1024;
public string[] AllowedRedirectHosts { get; init; } = ["sgu.ulsa.edu.mx"];
}
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.DirectoryServices;
using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Options;
namespace SGU.AuthBroker.Services;
@@ -19,6 +20,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
public async Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity,
InstitutionalProfile? profile,
string password,
CancellationToken cancellationToken)
{
@@ -27,7 +29,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
try
{
return await Task.Run(
() => Synchronize(identity, password),
() => Synchronize(identity, profile, password),
cancellationToken).ConfigureAwait(false);
}
finally
@@ -40,7 +42,10 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
}
}
private DirectorySyncResult Synchronize(UserIdentity identity, string password)
private DirectorySyncResult Synchronize(
UserIdentity identity,
InstitutionalProfile? profile,
string password)
{
string targetOuDn = options.GetOuDn(identity.Role);
using DirectoryEntry root = Bind(options.BaseDn);
@@ -95,6 +100,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
user.Properties["pwdLastSet"].Value = -1;
user.CommitChanges();
TryApplyProfile(user, identity, profile);
return new DirectorySyncResult(
options.DomainNetbios,
identity.UserName,
@@ -108,6 +115,54 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
}
}
private static void TryApplyProfile(
DirectoryEntry user,
UserIdentity identity,
InstitutionalProfile? profile)
{
if (profile is null)
{
return;
}
try
{
SetOptionalProperty(user, "displayName", profile.DisplayName);
SetOptionalProperty(user, "mail", profile.Email);
SetOptionalProperty(user, "title", profile.JobTitle);
SetOptionalProperty(user, "department", profile.Department);
SetOptionalProperty(user, "employeeType", profile.EmployeeType);
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
{
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
}
user.CommitChanges();
}
catch
{
// Metadata is intentionally best-effort. User creation, password sync,
// and account enablement have already committed successfully.
try
{
user.RefreshCache();
}
catch
{
// Discarding the optional property cache must not alter the
// already committed password synchronization result.
}
}
}
private static void SetOptionalProperty(DirectoryEntry entry, string propertyName, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
entry.Properties[propertyName].Value = value;
}
}
private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
{
try
@@ -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) &&
+3
View File
@@ -31,6 +31,9 @@
"Domain": "",
"TimeoutSeconds": 15,
"MaxRedirects": 5,
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"MenuProfilePath": "/psulsa/menu.aspx",
"MaxProfileBytes": 524288,
"AllowedRedirectHosts": [
"sgu.ulsa.edu.mx"
]