Add student SGU profile synchronization
This commit is contained in:
@@ -3,16 +3,28 @@ namespace SGU.AuthBroker.Core.Profiles;
|
||||
public sealed record InstitutionalProfile(
|
||||
string? EmployeeNumber = null,
|
||||
string? DisplayName = null,
|
||||
string? GivenName = null,
|
||||
string? Surname = null,
|
||||
string? Email = null,
|
||||
string? EmployeeType = 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 =>
|
||||
EmployeeNumber is not null ||
|
||||
DisplayName is not null ||
|
||||
GivenName is not null ||
|
||||
Surname is not null ||
|
||||
Email is not null ||
|
||||
EmployeeType 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 DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
|
||||
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)
|
||||
{
|
||||
@@ -44,6 +56,44 @@ public static class SguProfileParser
|
||||
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(
|
||||
string? value,
|
||||
out string? employeeNumber,
|
||||
@@ -176,6 +226,104 @@ public static class SguProfileParser
|
||||
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)
|
||||
{
|
||||
string? candidate = Limit(value, maximumLength);
|
||||
|
||||
@@ -15,6 +15,16 @@ public static class SpanishTextNormalizer
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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);
|
||||
|
||||
@@ -23,7 +33,7 @@ public static class SpanishTextNormalizer
|
||||
{
|
||||
string lowercase = words[index].ToLower(SpanishCulture);
|
||||
string comparisonToken = lowercase.Trim('(', ')', '[', ']', '{', '}', ',', '.', ';', ':');
|
||||
words[index] = index > 0 && LowercaseParticles.Contains(comparisonToken)
|
||||
words[index] = (index > 0 || lowercaseLeadingParticle) && LowercaseParticles.Contains(comparisonToken)
|
||||
? lowercase
|
||||
: CapitalizeCompound(lowercase);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,12 @@ public sealed class BrokerOptions
|
||||
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))
|
||||
{
|
||||
@@ -112,13 +117,16 @@ public sealed class NtlmOptions
|
||||
|
||||
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 string AdministrativeProfilePath { get; init; } =
|
||||
"/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 int MaxProfileBytes { get; init; } = 512 * 1024;
|
||||
|
||||
@@ -128,10 +128,16 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
||||
if (profile is not null)
|
||||
{
|
||||
SetOptionalProperty(user, "displayName", profile.DisplayName);
|
||||
SetOptionalProperty(user, "givenName", profile.GivenName);
|
||||
SetOptionalProperty(user, "sn", profile.Surname);
|
||||
SetOptionalProperty(user, "mail", profile.Email);
|
||||
SetOptionalProperty(user, "title", profile.JobTitle);
|
||||
SetOptionalProperty(user, "department", profile.Department);
|
||||
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))
|
||||
{
|
||||
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
|
||||
|
||||
@@ -133,9 +133,13 @@ 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;
|
||||
string path = role switch
|
||||
{
|
||||
InstitutionalRole.Administrative => options.AdministrativeProfilePath,
|
||||
InstitutionalRole.Student => options.StudentProfilePath,
|
||||
InstitutionalRole.Professor => options.MenuProfilePath,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
|
||||
};
|
||||
return new Uri(endpoint, path);
|
||||
}
|
||||
|
||||
@@ -151,10 +155,17 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
|
||||
response.Content,
|
||||
options.MaxProfileBytes,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
return identity.Role == InstitutionalRole.Administrative
|
||||
? SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
|
||||
SguProfileParser.ParseMenu(html)
|
||||
: SguProfileParser.ParseMenu(html);
|
||||
return identity.Role switch
|
||||
{
|
||||
InstitutionalRole.Administrative =>
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
"Ntlm": {
|
||||
"Endpoint": "https://sgu.ulsa.edu.mx/",
|
||||
"Domain": "",
|
||||
"TimeoutSeconds": 15,
|
||||
"TimeoutSeconds": 30,
|
||||
"MaxRedirects": 5,
|
||||
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
|
||||
"MenuProfilePath": "/psulsa/menu.aspx",
|
||||
"MaxProfileBytes": 524288,
|
||||
"AllowedRedirectHosts": [
|
||||
|
||||
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
|
||||
"DomainNetbios": "LCI",
|
||||
"TimeoutSeconds": 20,
|
||||
"TimeoutSeconds": 35,
|
||||
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
|
||||
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user