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
+12 -2
View File
@@ -13,14 +13,24 @@ Directory synchronization, deployment scripts, and tests.
1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password. 1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password.
2. It sends that exact password over mutually authenticated TLS to the broker. 2. It sends that exact password over mutually authenticated TLS to the broker.
3. The broker validates the same key/password pair against the configured SGU 3. The broker validates the same key/password pair against the configured SGU
NTLM endpoint. NTLM endpoint. The same logical authenticated request reads the minimum
4. On success, the broker creates or moves the AD user and sets the AD password available SGU profile fields.
4. On success, the broker creates or moves the AD user, updates the available
name/mail/title/department metadata, and sets the AD password
to the exact submitted password. to the exact submitted password.
5. The Credential Provider serializes the original `SecureString` to Windows. 5. The Credential Provider serializes the original `SecureString` to Windows.
No derived password is created. Passwords are not written to a database, file, No derived password is created. Passwords are not written to a database, file,
event log, application log, command line, or response. event log, application log, command line, or response.
For administrative accounts, profile enrichment targets the read-only incident
overview and reads only the employee number, name, account type/status, email,
job title, and department from their stable element IDs. Incident, calendar,
photo, and manager fields are ignored. For students and professors, the menu
display name is a conservative fallback until a richer role-specific page is
verified. Missing or changed presentation HTML never blocks authentication or
password synchronization.
| Prefix | Role | Default OU | | Prefix | Role | Default OU |
|---|---|---| |---|---|---|
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | | `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
+15 -1
View File
@@ -8,7 +8,8 @@ LogonUI
-> HTTPS 1.1 + client certificate -> HTTPS 1.1 + client certificate
-> SGU Auth Broker -> SGU Auth Broker
-> SGU IIS NTLM endpoint (original password) -> SGU IIS NTLM endpoint (original password)
-> Active Directory (same original password) -> minimum SGU profile metadata (same authenticated response)
-> Active Directory (same original password + optional profile)
<- domain + canonical username; never a password <- domain + canonical username; never a password
-> Windows credential serialization (original SecureString) -> Windows credential serialization (original SecureString)
-> LSA / Kerberos / cached domain logon -> LSA / Kerberos / cached domain logon
@@ -19,6 +20,12 @@ It follows only HTTPS redirects whose host appears in `AllowedRedirectHosts`,
which prevents credential forwarding to an unexpected redirect target. HTTP/1.1 which prevents credential forwarding to an unexpected redirect target. HTTP/1.1
is forced because NTLM authentication is connection-bound. is forced because NTLM authentication is connection-bound.
The logical GET is sent directly to the administrative incident overview for
`AD` identities or to the portal menu for `DO`/`AL` identities. NTLM may still
require its normal challenge/response round trips on that connection. The
broker keeps any transient portal cookie in an in-memory per-request container;
it is never persisted or returned to the client.
## Offline authentication ## Offline authentication
```text ```text
@@ -38,6 +45,13 @@ plus six digits. It searches `BaseDn` by `sAMAccountName`, creates the user when
absent, moves it to the mapped OU when required, sets `userPrincipalName`, and absent, moves it to the mapped OU when required, sets `userPrincipalName`, and
passes the submitted password directly to ADSI `SetPassword`. passes the submitted password directly to ADSI `SetPassword`.
When the authenticated HTML exposes recognized stable IDs, the broker also
updates `displayName`, `mail`, `title`, `department`, `employeeType`, and
`employeeID`. The administrative employee number must match the six numeric
digits of the requested `AD` identity before any scraped metadata is trusted.
Missing metadata does not clear existing AD values and never changes the
password outcome.
The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`, The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`,
and `Administrativos` are direct child OUs beneath it. and `Administrativos` are direct child OUs beneath it.
+14
View File
@@ -24,6 +24,20 @@
- Client private keys are non-exportable and reside in `LocalMachine\My`. - Client private keys are non-exportable and reside in `LocalMachine\My`.
- The NTLM validator rejects non-HTTPS redirects, URI user information, and hosts - The NTLM validator rejects non-HTTPS redirects, URI user information, and hosts
outside its explicit redirect allow-list. outside its explicit redirect allow-list.
- Profile enrichment reads only allow-listed HTTPS pages and caps the response
body at 512 KiB by default. Portal cookies are request-scoped and held only in
memory.
## Profile minimization
- Administrative enrichment reads only employee number, display name,
employee type/status, email, job title, and department from known element IDs.
- Incident details, calendars, photographs, manager names, and manager positions
are deliberately ignored.
- The employee number must match the authenticated `AD` key before metadata is
synchronized.
- If SGU changes its HTML, authentication and exact-password synchronization
continue without enrichment; existing AD metadata is not erased.
Lab self-signed certificates are appropriate only for the isolated VM network. Lab self-signed certificates are appropriate only for the isolated VM network.
Use an enterprise CA with revocation checking in production. Use an enterprise CA with revocation checking in production.
+9
View File
@@ -12,6 +12,12 @@ param(
[string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/', [string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/',
[string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'), [string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'),
[ValidatePattern('^/')]
[string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx',
[ValidatePattern('^/')]
[string]$MenuProfilePath = '/psulsa/menu.aspx',
[ValidateRange(32768, 2097152)]
[int]$MaxProfileBytes = 524288,
[string]$LdapHost = 'localhost', [string]$LdapHost = 'localhost',
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx', [string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios = 'LCI',
@@ -120,6 +126,9 @@ $productionSettings = @{
Domain = '' Domain = ''
TimeoutSeconds = 15 TimeoutSeconds = 15
MaxRedirects = 5 MaxRedirects = 5
AdministrativeProfilePath = $AdministrativeProfilePath
MenuProfilePath = $MenuProfilePath
MaxProfileBytes = $MaxProfileBytes
AllowedRedirectHosts = $AllowedNtlmRedirectHosts AllowedRedirectHosts = $AllowedNtlmRedirectHosts
} }
Directory = @{ Directory = @{
@@ -23,7 +23,7 @@ public sealed class AuthenticationWorkflow(
try try
{ {
validation = await ntlmValidator validation = await ntlmValidator
.ValidateAsync(identity.UserName, password, cancellationToken) .ValidateAsync(identity, password, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -57,7 +57,7 @@ public sealed class AuthenticationWorkflow(
try try
{ {
DirectorySyncResult directory = await directorySynchronizer DirectorySyncResult directory = await directorySynchronizer
.SynchronizeAsync(identity, password, cancellationToken) .SynchronizeAsync(identity, validation.Profile, password, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return new AuthenticationFlowResult( return new AuthenticationFlowResult(
@@ -1,9 +1,11 @@
using SGU.AuthBroker.Core.Identity;
namespace SGU.AuthBroker.Core.Authentication; namespace SGU.AuthBroker.Core.Authentication;
public interface INtlmCredentialValidator public interface INtlmCredentialValidator
{ {
Task<NtlmValidationResult> ValidateAsync( Task<NtlmValidationResult> ValidateAsync(
string userName, UserIdentity identity,
string password, string password,
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }
@@ -1,3 +1,5 @@
using SGU.AuthBroker.Core.Profiles;
namespace SGU.AuthBroker.Core.Authentication; namespace SGU.AuthBroker.Core.Authentication;
public enum NtlmValidationStatus public enum NtlmValidationStatus
@@ -7,9 +9,13 @@ public enum NtlmValidationStatus
Unavailable 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"); public static NtlmValidationResult Invalid() => new(NtlmValidationStatus.Invalid, "INVALID_INSTITUTIONAL_CREDENTIALS");
@@ -1,4 +1,5 @@
using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
namespace SGU.AuthBroker.Core.Directory; namespace SGU.AuthBroker.Core.Directory;
@@ -6,6 +7,7 @@ public interface IActiveDirectorySynchronizer
{ {
Task<DirectorySyncResult> SynchronizeAsync( Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity, UserIdentity identity,
InstitutionalProfile? profile,
string password, string password,
CancellationToken cancellationToken); 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."); 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) || if (string.IsNullOrWhiteSpace(Directory.LdapHost) ||
string.IsNullOrWhiteSpace(Directory.BaseDn) || string.IsNullOrWhiteSpace(Directory.BaseDn) ||
string.IsNullOrWhiteSpace(Directory.DomainNetbios) || string.IsNullOrWhiteSpace(Directory.DomainNetbios) ||
@@ -83,6 +104,13 @@ public sealed class NtlmOptions
public int MaxRedirects { get; init; } = 5; 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"]; public string[] AllowedRedirectHosts { get; init; } = ["sgu.ulsa.edu.mx"];
} }
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.DirectoryServices; using System.DirectoryServices;
using SGU.AuthBroker.Core.Directory; using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Options; using SGU.AuthBroker.Options;
namespace SGU.AuthBroker.Services; namespace SGU.AuthBroker.Services;
@@ -19,6 +20,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
public async Task<DirectorySyncResult> SynchronizeAsync( public async Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity, UserIdentity identity,
InstitutionalProfile? profile,
string password, string password,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -27,7 +29,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
try try
{ {
return await Task.Run( return await Task.Run(
() => Synchronize(identity, password), () => Synchronize(identity, profile, password),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
} }
finally 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); string targetOuDn = options.GetOuDn(identity.Role);
using DirectoryEntry root = Bind(options.BaseDn); using DirectoryEntry root = Bind(options.BaseDn);
@@ -95,6 +100,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
user.Properties["pwdLastSet"].Value = -1; user.Properties["pwdLastSet"].Value = -1;
user.CommitChanges(); user.CommitChanges();
TryApplyProfile(user, identity, profile);
return new DirectorySyncResult( return new DirectorySyncResult(
options.DomainNetbios, options.DomainNetbios,
identity.UserName, 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) private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
{ {
try try
@@ -1,5 +1,8 @@
using System.Net; using System.Net;
using System.Text;
using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Options; using SGU.AuthBroker.Options;
namespace SGU.AuthBroker.Services; namespace SGU.AuthBroker.Services;
@@ -9,29 +12,31 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
private readonly NtlmOptions options = options.Ntlm; private readonly NtlmOptions options = options.Ntlm;
public async Task<NtlmValidationResult> ValidateAsync( public async Task<NtlmValidationResult> ValidateAsync(
string userName, UserIdentity identity,
string password, string password,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Uri current = new(this.options.Endpoint, UriKind.Absolute); Uri current = GetProfileUri(identity.Role);
HashSet<string> allowedHosts = new( HashSet<string> allowedHosts = new(
this.options.AllowedRedirectHosts, this.options.AllowedRedirectHosts,
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
NetworkCredential credential = new(userName, password, this.options.Domain); NetworkCredential credential = new(identity.UserName, password, this.options.Domain);
CredentialCache credentialCache = new(); CredentialCache credentialCache = new();
HashSet<string> credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase); HashSet<string> credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase);
CookieContainer cookieContainer = new();
using HttpClientHandler handler = new() using HttpClientHandler handler = new()
{ {
AllowAutoRedirect = false, AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.All, AutomaticDecompression = DecompressionMethods.All,
CheckCertificateRevocationList = true, CheckCertificateRevocationList = true,
CookieContainer = cookieContainer,
Credentials = credentialCache, Credentials = credentialCache,
MaxConnectionsPerServer = 4, MaxConnectionsPerServer = 4,
MaxResponseHeadersLength = 64, MaxResponseHeadersLength = 64,
PreAuthenticate = false, PreAuthenticate = false,
UseCookies = false, UseCookies = true,
UseDefaultCredentials = false, UseDefaultCredentials = false,
UseProxy = false UseProxy = false
}; };
@@ -104,9 +109,17 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
continue; continue;
} }
return statusCode is >= 200 and < 300 if (statusCode is >= 200 and < 300)
? NtlmValidationResult.Valid() {
: NtlmValidationResult.Invalid(); 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) => private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
uri.Scheme == Uri.UriSchemeHttps && uri.Scheme == Uri.UriSchemeHttps &&
string.IsNullOrEmpty(uri.UserInfo) && string.IsNullOrEmpty(uri.UserInfo) &&
+3
View File
@@ -31,6 +31,9 @@
"Domain": "", "Domain": "",
"TimeoutSeconds": 15, "TimeoutSeconds": 15,
"MaxRedirects": 5, "MaxRedirects": 5,
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"MenuProfilePath": "/psulsa/menu.aspx",
"MaxProfileBytes": 524288,
"AllowedRedirectHosts": [ "AllowedRedirectHosts": [
"sgu.ulsa.edu.mx" "sgu.ulsa.edu.mx"
] ]
@@ -1,6 +1,7 @@
using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Directory; using SGU.AuthBroker.Core.Directory;
using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using Xunit; using Xunit;
namespace SGU.AuthBroker.Core.Tests; namespace SGU.AuthBroker.Core.Tests;
@@ -11,7 +12,13 @@ public sealed class AuthenticationWorkflowTests
public async Task PassesTheExactOriginalPasswordToNtlmAndActiveDirectory() public async Task PassesTheExactOriginalPasswordToNtlmAndActiveDirectory()
{ {
const string original = "Árbol-Exacto-🔐-NoDerivar-27!"; const string original = "Árbol-Exacto-🔐-NoDerivar-27!";
CapturingNtlmValidator ntlm = new(NtlmValidationResult.Valid()); InstitutionalProfile profile = new(
EmployeeNumber: "123456",
DisplayName: "Persona de Prueba",
Email: "persona@lasalle.mx",
JobTitle: "DOCENTE",
Department: "FACULTAD DE PRUEBA");
CapturingNtlmValidator ntlm = new(NtlmValidationResult.Valid(profile));
CapturingDirectorySynchronizer directory = new(); CapturingDirectorySynchronizer directory = new();
AuthenticationWorkflow workflow = new(ntlm, directory); AuthenticationWorkflow workflow = new(ntlm, directory);
@@ -23,8 +30,9 @@ public sealed class AuthenticationWorkflowTests
Assert.Equal(AuthenticationFlowOutcome.Authorized, result.Outcome); Assert.Equal(AuthenticationFlowOutcome.Authorized, result.Outcome);
Assert.Same(original, ntlm.Password); Assert.Same(original, ntlm.Password);
Assert.Same(original, directory.Password); Assert.Same(original, directory.Password);
Assert.Equal("DO123456", ntlm.UserName); Assert.Equal("DO123456", ntlm.Identity?.UserName);
Assert.Equal(InstitutionalRole.Professor, directory.Identity?.Role); Assert.Equal(InstitutionalRole.Professor, directory.Identity?.Role);
Assert.Same(profile, directory.Profile);
} }
[Fact] [Fact]
@@ -63,13 +71,16 @@ public sealed class AuthenticationWorkflowTests
private sealed class CapturingNtlmValidator(NtlmValidationResult result) : INtlmCredentialValidator private sealed class CapturingNtlmValidator(NtlmValidationResult result) : INtlmCredentialValidator
{ {
public string? UserName { get; private set; } public UserIdentity? Identity { get; private set; }
public string? Password { get; private set; } public string? Password { get; private set; }
public Task<NtlmValidationResult> ValidateAsync(string userName, string password, CancellationToken cancellationToken) public Task<NtlmValidationResult> ValidateAsync(
UserIdentity identity,
string password,
CancellationToken cancellationToken)
{ {
UserName = userName; Identity = identity;
Password = password; Password = password;
return Task.FromResult(result); return Task.FromResult(result);
} }
@@ -81,12 +92,16 @@ public sealed class AuthenticationWorkflowTests
public string? Password { get; private set; } public string? Password { get; private set; }
public InstitutionalProfile? Profile { get; private set; }
public Task<DirectorySyncResult> SynchronizeAsync( public Task<DirectorySyncResult> SynchronizeAsync(
UserIdentity identity, UserIdentity identity,
InstitutionalProfile? profile,
string password, string password,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Identity = identity; Identity = identity;
Profile = profile;
Password = password; Password = password;
return Task.FromResult(new DirectorySyncResult( return Task.FromResult(new DirectorySyncResult(
"LCI", "LCI",
@@ -0,0 +1,85 @@
using SGU.AuthBroker.Core.Profiles;
using Xunit;
namespace SGU.AuthBroker.Core.Tests;
public sealed class SguProfileParserTests
{
[Fact]
public void ParsesOnlyTheRequiredAdministrativeFieldsFromTheInitialResponse()
{
const string html = """
<html><body>
<span id="ctl00_contenedor_decEncabezado_lblNombre">
017045 - JESÚS&nbsp;ALEJANDRO ROSALES GONZÁLEZ
</span>
<span id="ctl00_contenedor_decEncabezado_lblIndicadorValue">
SINDICALIZADO QUINCENAL (ACTIVO)
</span>
<span id="ctl00_contenedor_decEncabezado_lblCorreo">
<a href="mailto:persona@lasalle.mx">persona@lasalle.mx</a>
</span>
<div style="display:none">
<span id="ctl00_contenedor_decEncabezado_lblPuesto">
ANALISTA DE PROYECTOS
</span>
<span id="ctl00_contenedor_decEncabezado_lblDependencia">
FACULTAD DE INGENIERÍA
</span>
<span id="ctl00_contenedor_decEncabezado_lblJefeNombre">
ESTE CAMPO NO DEBE EXTRAERSE
</span>
</div>
<table id="incidencias"><tr><td>DATOS NO REQUERIDOS</td></tr></table>
</body></html>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrative(html, "017045");
Assert.NotNull(profile);
Assert.Equal("017045", profile.EmployeeNumber);
Assert.Equal("JESÚS ALEJANDRO ROSALES GONZÁLEZ", profile.DisplayName);
Assert.Equal("persona@lasalle.mx", profile.Email);
Assert.Equal("SINDICALIZADO QUINCENAL (ACTIVO)", profile.EmployeeType);
Assert.Equal("ANALISTA DE PROYECTOS", profile.JobTitle);
Assert.Equal("FACULTAD DE INGENIERÍA", profile.Department);
}
[Fact]
public void RejectsAdministrativeMetadataForADifferentEmployeeNumber()
{
const string html = """
<span id='ctl00_contenedor_decEncabezado_lblNombre'>017045 - PERSONA INCORRECTA</span>
<span id='ctl00_contenedor_decEncabezado_lblCorreo'>incorrecta@lasalle.mx</span>
""";
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999"));
}
[Fact]
public void ParsesTheMenuNameAsAConservativeFallback()
{
const string html = """
<span class="usuario" id="ctl00_lblNombreUsuario">
MARÍA&nbsp;&amp;&nbsp;JOSÉ
</span>
""";
InstitutionalProfile? profile = SguProfileParser.ParseMenu(html);
Assert.NotNull(profile);
Assert.Equal("MARÍA & JOSÉ", profile.DisplayName);
Assert.Null(profile.Email);
Assert.Null(profile.JobTitle);
Assert.Null(profile.Department);
}
[Fact]
public void MissingKnownFieldsProducesNoProfile()
{
Assert.Null(SguProfileParser.ParseMenu("<html><body>Portal SGU</body></html>"));
Assert.Null(SguProfileParser.ParseAdministrative(
"<html><body>Portal SGU</body></html>",
"017045"));
}
}