Enrich AD users from SGU profile metadata
This commit is contained in:
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
@@ -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 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 & 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user