Add student SGU profile synchronization

This commit is contained in:
2026-09-01 14:23:20 -06:00
parent 3b5cf723cc
commit fd3eb537a1
21 changed files with 345 additions and 43 deletions
+8 -5
View File
@@ -16,7 +16,7 @@ Directory synchronization, deployment scripts, and tests.
NTLM endpoint. The same logical authenticated request reads the minimum NTLM endpoint. The same logical authenticated request reads the minimum
available SGU profile fields. available SGU profile fields.
4. On success, the broker creates or moves the AD user, updates the available 4. On success, the broker creates or moves the AD user, updates the available
name/mail/title/department metadata, and sets the AD password name/mail/title/department/address metadata when available, 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.
@@ -26,10 +26,13 @@ event log, application log, command line, or response.
For administrative accounts, profile enrichment targets the read-only incident For administrative accounts, profile enrichment targets the read-only incident
overview and reads only the employee number, name, account type/status, email, overview and reads only the employee number, name, account type/status, email,
job title, and department from their stable element IDs. Incident, calendar, job title, and department from their stable element IDs. Incident, calendar,
photo, and manager fields are ignored. For students and professors, the menu photo, and manager fields are ignored. Student enrichment targets the read-only
display name is a conservative fallback until a richer role-specific page is student information page and reads only the matching student number, structured
verified. Missing or changed presentation HTML never blocks authentication or name, email, career, and postal address. The career becomes an AD title in the
password synchronization. form `Estudiante de ...`; faculty/department remains unset because the verified
page does not expose it. Professors retain the menu display-name fallback until
a richer role-specific page is verified. Missing or changed presentation HTML
never blocks authentication or password synchronization.
Operational documentation: Operational documentation:
+12 -9
View File
@@ -21,10 +21,11 @@ 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 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 `AD` identities, the student information page for `AL` identities, or the
require its normal challenge/response round trips on that connection. The portal menu for `DO` identities. NTLM may still require its normal
broker keeps any transient portal cookie in an in-memory per-request container; challenge/response round trips on that connection. The broker keeps any
it is never persisted or returned to the client. transient portal cookie in an in-memory per-request container; it is never
persisted or returned to the client.
## Offline authentication ## Offline authentication
@@ -46,11 +47,13 @@ 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 When the authenticated HTML exposes recognized stable IDs, the broker also
updates `displayName`, `mail`, `title`, `department`, `employeeType`, and updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`employeeID`. The administrative employee number must match the six numeric `department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
digits of the requested `AD` identity before any scraped metadata is trusted. `postalCode` attributes. Administrative and student numbers must match the six
Missing metadata does not clear existing AD values and never changes the numeric digits of the requested identity before any scraped metadata is
password outcome. trusted. Student faculty/department is deliberately left unset because the
verified page does not expose it. Missing metadata does not clear existing AD
values and never changes the password outcome.
Human-readable SGU values are decoded with BOM/header/meta detection, strict Human-readable SGU values are decoded with BOM/header/meta detection, strict
UTF-8 validation, and a Windows-1252 fallback for the legacy portal. Names and UTF-8 validation, and a Windows-1252 fallback for the legacy portal. Names and
+5 -4
View File
@@ -46,10 +46,11 @@ Eso es comportamiento esperado, no una caída del servicio.
## Timeouts y recuperación ## Timeouts y recuperación
- El Credential Provider espera hasta **20 segundos** por el broker. - El Credential Provider espera hasta **35 segundos** por el broker.
- El broker espera hasta **15 segundos** por SGU. Un portal que normalmente - El broker espera hasta **30 segundos** por SGU. Este margen cubre las
tarda alrededor de seis segundos queda dentro del margen sin bloquear LogonUI degradaciones observadas del portal sin bloquear LogonUI indefinidamente; el
indefinidamente. cliente conserva cinco segundos adicionales para que el broker cierre la
respuesta de manera limpia.
- El instalador configura recuperación del servicio con reinicios a los 5, 15 - El instalador configura recuperación del servicio con reinicios a los 5, 15
y 60 segundos y reinicia el contador de fallos después de 24 horas. y 60 segundos y reinicia el contador de fallos después de 24 horas.
- Si el broker o SGU no está disponible, el Credential Provider entrega la - Si el broker o SGU no está disponible, el Credential Provider entrega la
+1 -1
View File
@@ -124,7 +124,7 @@ On Windows 10:
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate ` -BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT ` -ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT ` -ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
-TimeoutSeconds 20 ` -TimeoutSeconds 35 `
-InstallDotNetRuntime ` -InstallDotNetRuntime `
-DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe -DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe
``` ```
+7 -2
View File
@@ -32,10 +32,15 @@
- Administrative enrichment reads only employee number, display name, - Administrative enrichment reads only employee number, display name,
employee type/status, email, job title, and department from known element IDs. employee type/status, email, job title, and department from known element IDs.
- Student enrichment reads only the matching student number, given names,
paternal/maternal surnames, email, career, street, neighborhood,
city/municipality, state, and postal code from known element IDs.
- Student CURP, birth date, sex, blood type, marital status, telephone, mobile,
guardian, medical, financial, and academic-history values are ignored.
- Incident details, calendars, photographs, manager names, and manager positions - Incident details, calendars, photographs, manager names, and manager positions
are deliberately ignored. are deliberately ignored.
- The employee number must match the authenticated `AD` key before metadata is - The employee or student number must match the authenticated `AD` or `AL` key
synchronized. before metadata is synchronized.
- If SGU changes its HTML, authentication and exact-password synchronization - If SGU changes its HTML, authentication and exact-password synchronization
continue without enrichment; existing AD metadata is not erased. continue without enrichment; existing AD metadata is not erased.
+1 -1
View File
@@ -82,7 +82,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass `
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate ` -BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT ` -ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT ` -ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
-TimeoutSeconds 20 -TimeoutSeconds 35
``` ```
Los binarios se guardan en un subdirectorio `versions` identificado por su Los binarios se guardan en un subdirectorio `versions` identificado por su
+4 -1
View File
@@ -15,6 +15,8 @@ param(
[ValidatePattern('^/')] [ValidatePattern('^/')]
[string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx', [string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx',
[ValidatePattern('^/')] [ValidatePattern('^/')]
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
[ValidatePattern('^/')]
[string]$MenuProfilePath = '/psulsa/menu.aspx', [string]$MenuProfilePath = '/psulsa/menu.aspx',
[ValidateRange(32768, 2097152)] [ValidateRange(32768, 2097152)]
[int]$MaxProfileBytes = 524288, [int]$MaxProfileBytes = 524288,
@@ -26,7 +28,7 @@ param(
[ValidateLength(1, 64)] [ValidateLength(1, 64)]
[string]$DefaultCompany = 'Universidad La Salle', [string]$DefaultCompany = 'Universidad La Salle',
[ValidateRange(10, 60)] [ValidateRange(10, 60)]
[int]$NtlmTimeoutSeconds = 15, [int]$NtlmTimeoutSeconds = 30,
[switch]$CreateMissingOus, [switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab [switch]$DisableCertificateRevocationCheckForLab
) )
@@ -141,6 +143,7 @@ $productionSettings = @{
TimeoutSeconds = $NtlmTimeoutSeconds TimeoutSeconds = $NtlmTimeoutSeconds
MaxRedirects = 5 MaxRedirects = 5
AdministrativeProfilePath = $AdministrativeProfilePath AdministrativeProfilePath = $AdministrativeProfilePath
StudentProfilePath = $StudentProfilePath
MenuProfilePath = $MenuProfilePath MenuProfilePath = $MenuProfilePath
MaxProfileBytes = $MaxProfileBytes MaxProfileBytes = $MaxProfileBytes
AllowedRedirectHosts = $AllowedNtlmRedirectHosts AllowedRedirectHosts = $AllowedNtlmRedirectHosts
+2 -2
View File
@@ -55,7 +55,7 @@ $installParams = @{
ClientCertificateThumbprint = $ClientCertificateThumbprint ClientCertificateThumbprint = $ClientCertificateThumbprint
ServerCertificateThumbprint = $ServerCertificateThumbprint ServerCertificateThumbprint = $ServerCertificateThumbprint
DomainNetbios = $DomainNetbios DomainNetbios = $DomainNetbios
TimeoutSeconds = 20 TimeoutSeconds = 35
} }
if ($DotNetRuntimeInstallerPath) { if ($DotNetRuntimeInstallerPath) {
$installParams.InstallDotNetRuntime = $true $installParams.InstallDotNetRuntime = $true
@@ -68,7 +68,7 @@ $guardParams = @{
ClientCertificateThumbprint = $ClientCertificateThumbprint ClientCertificateThumbprint = $ClientCertificateThumbprint
ServerCertificateThumbprint = $ServerCertificateThumbprint ServerCertificateThumbprint = $ServerCertificateThumbprint
DomainNetbios = $DomainNetbios DomainNetbios = $DomainNetbios
TimeoutSeconds = 20 TimeoutSeconds = 35
RemoteDesktopPrincipal = $RemoteDesktopPrincipal RemoteDesktopPrincipal = $RemoteDesktopPrincipal
DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath
} }
+1 -1
View File
@@ -18,7 +18,7 @@ param(
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios = 'LCI',
[ValidateRange(2, 60)] [ValidateRange(2, 60)]
[int]$TimeoutSeconds = 20, [int]$TimeoutSeconds = 35,
[switch]$DoNotSetAsDefaultCredentialProvider, [switch]$DoNotSetAsDefaultCredentialProvider,
+1 -1
View File
@@ -17,7 +17,7 @@ param(
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios = 'LCI',
[ValidateRange(2, 60)] [ValidateRange(2, 60)]
[int]$TimeoutSeconds = 20, [int]$TimeoutSeconds = 35,
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP', [string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath [string]$DotNetRuntimeInstallerPath
) )
@@ -3,16 +3,28 @@ namespace SGU.AuthBroker.Core.Profiles;
public sealed record InstitutionalProfile( public sealed record InstitutionalProfile(
string? EmployeeNumber = null, string? EmployeeNumber = null,
string? DisplayName = null, string? DisplayName = null,
string? GivenName = null,
string? Surname = null,
string? Email = null, string? Email = null,
string? EmployeeType = null, string? EmployeeType = null,
string? JobTitle = null, string? JobTitle = null,
string? Department = null) string? Department = null,
string? StreetAddress = null,
string? City = null,
string? State = null,
string? PostalCode = null)
{ {
public bool HasValues => public bool HasValues =>
EmployeeNumber is not null || EmployeeNumber is not null ||
DisplayName is not null || DisplayName is not null ||
GivenName is not null ||
Surname is not null ||
Email is not null || Email is not null ||
EmployeeType is not null || EmployeeType is not null ||
JobTitle is not null || JobTitle is not null ||
Department is not null; Department is not null ||
StreetAddress is not null ||
City is not null ||
State is not null ||
PostalCode is not null;
} }
@@ -12,6 +12,18 @@ public static class SguProfileParser
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto"; private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia"; private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
private const string MenuNameId = "ctl00_lblNombreUsuario"; private const string MenuNameId = "ctl00_lblNombreUsuario";
private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP";
private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP";
private const string StudentPaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApPatAlumnoHP";
private const string StudentMaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApMatAlumnoHP";
private const string StudentEmailId = "ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP";
private const string StudentCareerId = "ctl00_contenedor_HistorialAlumno1_Header1_lblCarrera";
private const string StudentStreetId = "ctl00_contenedor_HistorialAlumno1_lblDomicilioAlumnoHP";
private const string StudentNeighborhoodId = "ctl00_contenedor_HistorialAlumno1_lblColoniaAlumnoHP";
private const string StudentStateId = "ctl00_contenedor_HistorialAlumno1_lblEstadoAlumnoHP";
private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber) public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
{ {
@@ -44,6 +56,44 @@ public static class SguProfileParser
return profile.HasValues ? profile : null; return profile.HasValues ? profile : null;
} }
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{
ArgumentNullException.ThrowIfNull(html);
ArgumentException.ThrowIfNullOrWhiteSpace(expectedStudentNumber);
string? studentNumber = NormalizeInstitutionalNumber(ExtractSpanText(html, StudentNumberId));
if (!string.Equals(studentNumber, expectedStudentNumber, StringComparison.Ordinal))
{
return null;
}
string? givenName = NormalizeName(ExtractSpanText(html, StudentGivenNameId), 64);
string? paternalSurname = NormalizeSurname(ExtractSpanText(html, StudentPaternalSurnameId), 64);
string? maternalSurname = NormalizeSurname(ExtractSpanText(html, StudentMaternalSurnameId), 64);
string? surname = NormalizeSurname(JoinNonEmpty(" ", paternalSurname, maternalSurname), 64);
string? displayName = NormalizeName(JoinNonEmpty(" ", givenName, surname), 256);
string? street = NormalizeTitle(ExtractSpanText(html, StudentStreetId), 512);
string? neighborhood = NormalizeTitle(ExtractSpanText(html, StudentNeighborhoodId), 256);
string? city = NormalizeTitle(ExtractSpanText(html, StudentCityId), 128);
string? municipality = NormalizeTitle(ExtractSpanText(html, StudentMunicipalityId), 128);
string? streetAddress = BuildStreetAddress(street, neighborhood, municipality, city);
InstitutionalProfile profile = new(
EmployeeNumber: studentNumber,
DisplayName: displayName,
GivenName: givenName,
Surname: surname,
Email: NormalizeEmail(ExtractSpanText(html, StudentEmailId)),
EmployeeType: "Alumno",
JobTitle: BuildStudentJobTitle(ExtractSpanText(html, StudentCareerId)),
StreetAddress: streetAddress,
City: city ?? municipality,
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128),
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)));
return profile.HasValues ? profile : null;
}
private static bool TrySplitAdministrativeIdentity( private static bool TrySplitAdministrativeIdentity(
string? value, string? value,
out string? employeeNumber, out string? employeeNumber,
@@ -176,6 +226,104 @@ public static class SguProfileParser
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate); return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
} }
private static string? NormalizeName(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
}
private static string? NormalizeSurname(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToSurnameCase(candidate);
}
private static string? NormalizeInstitutionalNumber(string? value)
{
string? candidate = value?.Trim();
return candidate is { Length: 6 } && candidate.All(char.IsAsciiDigit)
? candidate
: null;
}
private static string? BuildStudentJobTitle(string? value)
{
string? candidate = Limit(value, 256);
if (candidate is null)
{
return null;
}
candidate = candidate.Replace(
"COMPUTACIO NALES",
"COMPUTACIONALES",
StringComparison.OrdinalIgnoreCase);
string career = SpanishTextNormalizer.ToTitleCase(candidate);
const string degreePrefix = "Licenciatura en ";
if (career.StartsWith(degreePrefix, StringComparison.OrdinalIgnoreCase))
{
career = career[degreePrefix.Length..];
}
return TruncateAtWordBoundary($"Estudiante de {career}", 64);
}
private static string? BuildStreetAddress(
string? street,
string? neighborhood,
string? municipality,
string? city)
{
List<string> lines = [];
AddDistinct(lines, street);
AddDistinct(lines, neighborhood);
if (!string.Equals(municipality, city, StringComparison.OrdinalIgnoreCase))
{
AddDistinct(lines, municipality);
}
return Limit(string.Join("\r\n", lines), 1024);
}
private static void AddDistinct(List<string> values, string? candidate)
{
if (!string.IsNullOrWhiteSpace(candidate) &&
!values.Contains(candidate, StringComparer.OrdinalIgnoreCase))
{
values.Add(candidate);
}
}
private static string? NormalizePostalCode(string? value)
{
string? candidate = value?.Trim();
if (candidate is null ||
candidate.Length is < 4 or > 5 ||
!candidate.All(char.IsAsciiDigit))
{
return null;
}
return candidate.PadLeft(5, '0');
}
private static string? JoinNonEmpty(string separator, params string?[] values)
{
string result = string.Join(separator, values.Where(value => !string.IsNullOrWhiteSpace(value)));
return string.IsNullOrWhiteSpace(result) ? null : result;
}
private static string TruncateAtWordBoundary(string value, int maximumLength)
{
if (value.Length <= maximumLength)
{
return value;
}
int boundary = value.LastIndexOf(' ', maximumLength - 1, maximumLength);
return value[..(boundary > 0 ? boundary : maximumLength)].TrimEnd();
}
private static string? NormalizeSentence(string? value, int maximumLength) private static string? NormalizeSentence(string? value, int maximumLength)
{ {
string? candidate = Limit(value, maximumLength); string? candidate = Limit(value, maximumLength);
@@ -15,6 +15,16 @@ public static class SpanishTextNormalizer
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
public static string ToTitleCase(string value) public static string ToTitleCase(string value)
{
return ToTitleCase(value, lowercaseLeadingParticle: false);
}
public static string ToSurnameCase(string value)
{
return ToTitleCase(value, lowercaseLeadingParticle: true);
}
private static string ToTitleCase(string value, bool lowercaseLeadingParticle)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -23,7 +33,7 @@ public static class SpanishTextNormalizer
{ {
string lowercase = words[index].ToLower(SpanishCulture); string lowercase = words[index].ToLower(SpanishCulture);
string comparisonToken = lowercase.Trim('(', ')', '[', ']', '{', '}', ',', '.', ';', ':'); string comparisonToken = lowercase.Trim('(', ')', '[', ']', '{', '}', ',', '.', ';', ':');
words[index] = index > 0 && LowercaseParticles.Contains(comparisonToken) words[index] = (index > 0 || lowercaseLeadingParticle) && LowercaseParticles.Contains(comparisonToken)
? lowercase ? lowercase
: CapitalizeCompound(lowercase); : CapitalizeCompound(lowercase);
} }
+10 -2
View File
@@ -41,7 +41,12 @@ public sealed class BrokerOptions
throw new InvalidOperationException("The SGU profile response limit is outside the supported range."); throw new InvalidOperationException("The SGU profile response limit is outside the supported range.");
} }
foreach (string profilePath in new[] { Ntlm.AdministrativeProfilePath, Ntlm.MenuProfilePath }) foreach (string profilePath in new[]
{
Ntlm.AdministrativeProfilePath,
Ntlm.StudentProfilePath,
Ntlm.MenuProfilePath
})
{ {
if (string.IsNullOrWhiteSpace(profilePath)) if (string.IsNullOrWhiteSpace(profilePath))
{ {
@@ -112,13 +117,16 @@ public sealed class NtlmOptions
public string Domain { get; init; } = string.Empty; public string Domain { get; init; } = string.Empty;
public int TimeoutSeconds { get; init; } = 15; public int TimeoutSeconds { get; init; } = 30;
public int MaxRedirects { get; init; } = 5; public int MaxRedirects { get; init; } = 5;
public string AdministrativeProfilePath { get; init; } = public string AdministrativeProfilePath { get; init; } =
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx"; "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
public string StudentProfilePath { get; init; } =
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx"; public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx";
public int MaxProfileBytes { get; init; } = 512 * 1024; public int MaxProfileBytes { get; init; } = 512 * 1024;
@@ -128,10 +128,16 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
if (profile is not null) if (profile is not null)
{ {
SetOptionalProperty(user, "displayName", profile.DisplayName); SetOptionalProperty(user, "displayName", profile.DisplayName);
SetOptionalProperty(user, "givenName", profile.GivenName);
SetOptionalProperty(user, "sn", profile.Surname);
SetOptionalProperty(user, "mail", profile.Email); SetOptionalProperty(user, "mail", profile.Email);
SetOptionalProperty(user, "title", profile.JobTitle); SetOptionalProperty(user, "title", profile.JobTitle);
SetOptionalProperty(user, "department", profile.Department); SetOptionalProperty(user, "department", profile.Department);
SetOptionalProperty(user, "employeeType", profile.EmployeeType); SetOptionalProperty(user, "employeeType", profile.EmployeeType);
SetOptionalProperty(user, "streetAddress", profile.StreetAddress);
SetOptionalProperty(user, "l", profile.City);
SetOptionalProperty(user, "st", profile.State);
SetOptionalProperty(user, "postalCode", profile.PostalCode);
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal)) if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
{ {
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber); SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
@@ -133,9 +133,13 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
private Uri GetProfileUri(InstitutionalRole role) private Uri GetProfileUri(InstitutionalRole role)
{ {
Uri endpoint = new(options.Endpoint, UriKind.Absolute); Uri endpoint = new(options.Endpoint, UriKind.Absolute);
string path = role == InstitutionalRole.Administrative string path = role switch
? options.AdministrativeProfilePath {
: options.MenuProfilePath; InstitutionalRole.Administrative => options.AdministrativeProfilePath,
InstitutionalRole.Student => options.StudentProfilePath,
InstitutionalRole.Professor => options.MenuProfilePath,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
};
return new Uri(endpoint, path); return new Uri(endpoint, path);
} }
@@ -151,10 +155,17 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
response.Content, response.Content,
options.MaxProfileBytes, options.MaxProfileBytes,
timeoutToken).ConfigureAwait(false); timeoutToken).ConfigureAwait(false);
return identity.Role == InstitutionalRole.Administrative return identity.Role switch
? SguProfileParser.ParseAdministrative(html, identity.NumericId) ?? {
SguProfileParser.ParseMenu(html) InstitutionalRole.Administrative =>
: SguProfileParser.ParseMenu(html); SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html),
InstitutionalRole.Student =>
SguProfileParser.ParseStudent(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html),
InstitutionalRole.Professor => SguProfileParser.ParseMenu(html),
_ => null
};
} }
catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested)
{ {
+2 -1
View File
@@ -29,9 +29,10 @@
"Ntlm": { "Ntlm": {
"Endpoint": "https://sgu.ulsa.edu.mx/", "Endpoint": "https://sgu.ulsa.edu.mx/",
"Domain": "", "Domain": "",
"TimeoutSeconds": 15, "TimeoutSeconds": 30,
"MaxRedirects": 5, "MaxRedirects": 5,
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx", "AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
"MenuProfilePath": "/psulsa/menu.aspx", "MenuProfilePath": "/psulsa/menu.aspx",
"MaxProfileBytes": 524288, "MaxProfileBytes": 524288,
"AllowedRedirectHosts": [ "AllowedRedirectHosts": [
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
public string DomainNetbios { get; init; } = "LCI"; public string DomainNetbios { get; init; } = "LCI";
public int TimeoutSeconds { get; init; } = 20; public int TimeoutSeconds { get; init; } = 35;
public string ClientCertificateThumbprint { get; init; } = string.Empty; public string ClientCertificateThumbprint { get; init; } = string.Empty;
@@ -1,7 +1,7 @@
{ {
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate", "BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
"DomainNetbios": "LCI", "DomainNetbios": "LCI",
"TimeoutSeconds": 20, "TimeoutSeconds": 35,
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000", "ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000" "ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
} }
@@ -56,6 +56,97 @@ public sealed class SguProfileParserTests
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999")); Assert.Null(SguProfileParser.ParseAdministrative(html, "999999"));
} }
[Fact]
public void ParsesTheRequiredStudentIdentityCareerAndAddressFields()
{
const string html = """
<html><body>
<div id="ctl00_contenedor_HistorialAlumno1_div1">
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP">MARÍA DEL CARMEN</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblApPatAlumnoHP">DE LA FUENTE</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblApMatAlumnoHP">O'CONNOR</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
</span>
</div>
<span id="ctl00_contenedor_HistorialAlumno1_Header1_lblCarrera">
LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIO NALES
</span>
<div id="ctl00_contenedor_HistorialAlumno1_div2">
<span id="ctl00_contenedor_HistorialAlumno1_lblDomicilioAlumnoHP">
AVENIDA DE LA UNIVERSIDAD 123
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblColoniaAlumnoHP">
COLONIA DEL VALLE
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblEstadoAlumnoHP">
CIUDAD DE MÉXICO
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP">
CIUDAD DE MÉXICO
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP">
ALCALDÍA BENITO JUÁREZ
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP">8500</span>
</div>
</body></html>
""";
InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456");
Assert.NotNull(profile);
Assert.Equal("123456", profile.EmployeeNumber);
Assert.Equal("María del Carmen de la Fuente O'Connor", profile.DisplayName);
Assert.Equal("María del Carmen", profile.GivenName);
Assert.Equal("de la Fuente O'Connor", profile.Surname);
Assert.Equal("alumna@lasalle.mx", profile.Email);
Assert.Equal("Alumno", profile.EmployeeType);
Assert.Equal(
"Estudiante de Ingeniería Cibernética y Sistemas Computacionales",
profile.JobTitle);
Assert.Null(profile.Department);
Assert.Equal(
"Avenida de la Universidad 123\r\nColonia del Valle\r\nAlcaldía Benito Juárez",
profile.StreetAddress);
Assert.Equal("Ciudad de México", profile.City);
Assert.Equal("Ciudad de México", profile.State);
Assert.Equal("08500", profile.PostalCode);
}
[Fact]
public void RejectsStudentMetadataForADifferentStudentNumber()
{
const string html = """
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP">PERSONA INCORRECTA</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">incorrecta@lasalle.mx</span>
""";
Assert.Null(SguProfileParser.ParseStudent(html, "654321"));
}
[Fact]
public void UsesMunicipalityAsStudentCityWhenThePortalCityIsMissing()
{
const string html = """
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP">SAN PEDRO CHOLULA</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP">72760</span>
""";
InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456");
Assert.NotNull(profile);
Assert.Equal("San Pedro Cholula", profile.City);
Assert.Equal("San Pedro Cholula", profile.StreetAddress);
Assert.Equal("72760", profile.PostalCode);
}
[Fact] [Fact]
public void ParsesTheMenuNameAsAConservativeFallback() public void ParsesTheMenuNameAsAConservativeFallback()
{ {
@@ -10,7 +10,7 @@ public sealed class BrokerClientTests
[Fact] [Fact]
public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker() public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker()
{ {
Assert.Equal(20, new ProviderSettings().TimeoutSeconds); Assert.Equal(35, new ProviderSettings().TimeoutSeconds);
} }
[Fact] [Fact]