Add student SGU profile synchronization
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user