Improve SGU logon resilience and client UX

This commit is contained in:
2026-09-01 10:37:40 -06:00
parent 3d0897316d
commit da01343985
27 changed files with 869 additions and 37 deletions
@@ -0,0 +1,105 @@
using System.Text;
using System.Text.RegularExpressions;
namespace SGU.AuthBroker.Core.Profiles;
public static partial class SguHtmlDecoder
{
private const int MetaScanBytes = 8 * 1024;
static SguHtmlDecoder()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public static string Decode(ReadOnlySpan<byte> bytes, string? declaredCharset = null)
{
if (bytes.IsEmpty)
{
return string.Empty;
}
foreach (string charset in GetCandidateCharsets(bytes, declaredCharset))
{
try
{
Encoding baseEncoding = Encoding.GetEncoding(charset);
Encoding strictEncoding = Encoding.GetEncoding(
baseEncoding.CodePage,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
return strictEncoding.GetString(bytes);
}
catch (DecoderFallbackException)
{
// Some SGU responses declare UTF-8 but contain Windows-1252 bytes.
}
catch (ArgumentException)
{
// Ignore unknown declarations and continue with content detection.
}
}
return Encoding.GetEncoding(1252).GetString(bytes);
}
private static IEnumerable<string> GetCandidateCharsets(
ReadOnlySpan<byte> bytes,
string? declaredCharset)
{
List<string> candidates = [];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
AddCandidate(candidates, seen, DetectBom(bytes));
AddCandidate(candidates, seen, declaredCharset);
int scanLength = Math.Min(bytes.Length, MetaScanBytes);
string header = Encoding.Latin1.GetString(bytes[..scanLength]);
Match metaCharset = CharsetPattern().Match(header);
if (metaCharset.Success)
{
AddCandidate(candidates, seen, metaCharset.Groups["charset"].Value);
}
AddCandidate(candidates, seen, "utf-8");
AddCandidate(candidates, seen, "windows-1252");
return candidates;
}
private static void AddCandidate(
List<string> candidates,
HashSet<string> seen,
string? charset)
{
string? normalized = charset?.Trim().Trim('"', '\'');
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
{
candidates.Add(normalized);
}
}
private static string? DetectBom(ReadOnlySpan<byte> bytes)
{
if (bytes.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }))
{
return "utf-8";
}
if (bytes.StartsWith(new byte[] { 0xFF, 0xFE }))
{
return "utf-16";
}
if (bytes.StartsWith(new byte[] { 0xFE, 0xFF }))
{
return "unicodeFFFE";
}
return null;
}
[GeneratedRegex(
"charset\\s*=\\s*[\\\"']?\\s*(?<charset>[A-Za-z0-9._-]+)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex CharsetPattern();
}
@@ -27,11 +27,11 @@ public static class SguProfileParser
InstitutionalProfile profile = new(
EmployeeNumber: employeeNumber,
DisplayName: Limit(displayName, 256),
DisplayName: NormalizeTitle(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));
EmployeeType: NormalizeSentence(ExtractSpanText(html, EmployeeTypeId), 256),
JobTitle: NormalizeTitle(ExtractSpanText(html, JobTitleId), 64),
Department: NormalizeTitle(ExtractSpanText(html, DepartmentId), 64));
return profile.HasValues ? profile : null;
}
@@ -40,7 +40,7 @@ public static class SguProfileParser
ArgumentNullException.ThrowIfNull(html);
InstitutionalProfile profile = new(
DisplayName: Limit(ExtractSpanText(html, MenuNameId), 256));
DisplayName: NormalizeTitle(ExtractSpanText(html, MenuNameId), 256));
return profile.HasValues ? profile : null;
}
@@ -167,13 +167,27 @@ public static class SguProfileParser
return null;
}
return address.Address;
return address.Address.ToLowerInvariant();
}
private static string? NormalizeTitle(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
}
private static string? NormalizeSentence(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToSentenceCase(candidate);
}
private static string? Limit(string? value, int maximumLength)
{
string? candidate = value?.Trim();
return string.IsNullOrEmpty(candidate) || candidate.Length > maximumLength
return string.IsNullOrEmpty(candidate) ||
candidate.Length > maximumLength ||
candidate.Contains('\uFFFD')
? null
: candidate;
}
@@ -0,0 +1,78 @@
using System.Globalization;
using System.Text;
namespace SGU.AuthBroker.Core.Profiles;
public static class SpanishTextNormalizer
{
private static readonly CultureInfo SpanishCulture = CultureInfo.GetCultureInfo("es-MX");
private static readonly HashSet<string> LowercaseParticles = new(
[
"a", "al", "da", "das", "de", "del", "do", "dos", "e", "el",
"la", "las", "los", "o", "u", "van", "von", "y"
],
StringComparer.OrdinalIgnoreCase);
public static string ToTitleCase(string value)
{
ArgumentNullException.ThrowIfNull(value);
string[] words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
for (int index = 0; index < words.Length; index++)
{
string lowercase = words[index].ToLower(SpanishCulture);
string comparisonToken = lowercase.Trim('(', ')', '[', ']', '{', '}', ',', '.', ';', ':');
words[index] = index > 0 && LowercaseParticles.Contains(comparisonToken)
? lowercase
: CapitalizeCompound(lowercase);
}
return string.Join(' ', words);
}
public static string ToSentenceCase(string value)
{
ArgumentNullException.ThrowIfNull(value);
string lowercase = value.ToLower(SpanishCulture);
StringBuilder result = new(lowercase);
for (int index = 0; index < result.Length; index++)
{
if (!char.IsLetter(result[index]))
{
continue;
}
result[index] = char.ToUpper(result[index], SpanishCulture);
break;
}
return result.ToString();
}
private static string CapitalizeCompound(string value)
{
StringBuilder result = new(value.Length);
bool capitalizeNextLetter = true;
foreach (char character in value)
{
if (capitalizeNextLetter && char.IsLetter(character))
{
result.Append(char.ToUpper(character, SpanishCulture));
capitalizeNextLetter = false;
}
else
{
result.Append(character);
}
if (character is '-' or '\'' or '\u2019')
{
capitalizeNextLetter = true;
}
}
return result.ToString();
}
}
@@ -78,6 +78,13 @@ public sealed class BrokerOptions
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
}
}
if (!string.IsNullOrWhiteSpace(Directory.RemoteDesktopGroupDn) &&
(!Directory.RemoteDesktopGroupDn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase) ||
!Directory.RemoteDesktopGroupDn.EndsWith($",{Directory.BaseDn}", StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException("RemoteDesktopGroupDn must identify a group beneath BaseDn.");
}
}
private static bool IsCertificateThumbprint(string value)
@@ -130,6 +137,8 @@ public sealed class ActiveDirectoryOptions
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
public bool CreateMissingOus { get; init; }
public string GetOuDn(InstitutionalRole role) => role switch
@@ -101,6 +101,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
user.CommitChanges();
TryApplyProfile(user, identity, profile);
TryEnsureRemoteDesktopGroupMembership(user);
return new DirectorySyncResult(
options.DomainNetbios,
@@ -163,6 +164,37 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
}
}
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user)
{
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
{
return;
}
try
{
user.RefreshCache(["distinguishedName"]);
string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value);
if (string.IsNullOrWhiteSpace(userDn))
{
return;
}
using DirectoryEntry group = Bind(options.RemoteDesktopGroupDn);
_ = group.NativeObject;
if (!group.Properties["member"].Contains(userDn))
{
group.Properties["member"].Add(userDn);
group.CommitChanges();
}
}
catch
{
// Remote access is lab policy and must not invalidate a completed
// password synchronization if the optional group is unavailable.
}
}
private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
{
try
@@ -1,5 +1,4 @@
using System.Net;
using System.Text;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
@@ -202,20 +201,9 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
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));
return SguHtmlDecoder.Decode(
buffer.GetBuffer().AsSpan(0, checked((int)buffer.Length)),
content.Headers.ContentType?.CharSet);
}
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
+1
View File
@@ -46,6 +46,7 @@
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"RemoteDesktopGroupDn": "",
"CreateMissingOus": false
}
}
@@ -3,6 +3,7 @@ namespace SGU.CredentialProvider;
internal static class ControlKeys
{
public const string ProviderLabel = "ProviderLabel";
public const string ProviderLogo = "ProviderLogo";
public const string InformationLabel = "InformationLabel";
public const string UserName = "UserName";
public const string Password = "Password";
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
public string DomainNetbios { get; init; } = "LCI";
public int TimeoutSeconds { get; init; } = 6;
public int TimeoutSeconds { get; init; } = 20;
public string ClientCertificateThumbprint { get; init; } = string.Empty;
@@ -54,7 +54,7 @@ internal sealed class ProviderSettings
throw new InvalidOperationException("BrokerEndpoint must target /v1/authenticate.");
}
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 30)
if (string.IsNullOrWhiteSpace(DomainNetbios) || TimeoutSeconds is < 2 or > 60)
{
throw new InvalidOperationException("DomainNetbios or TimeoutSeconds is invalid.");
}
@@ -0,0 +1,33 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
namespace SGU.CredentialProvider;
internal static class ProviderTileIcon
{
public const int Size = 72;
public static Bitmap Create()
{
Bitmap bitmap = new(Size, Size, PixelFormat.Format32bppArgb);
using Graphics graphics = Graphics.FromImage(bitmap);
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
graphics.Clear(Color.FromArgb(0, 83, 155));
using Pen key = new(Color.White, 5.5f)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
graphics.DrawEllipse(key, 14, 14, 25, 25);
graphics.DrawLine(key, 35, 35, 57, 57);
graphics.DrawLine(key, 47, 47, 55, 39);
graphics.DrawLine(key, 53, 53, 61, 45);
return bitmap;
}
}
@@ -15,6 +15,10 @@ public sealed class SguCredentialProvider : CredentialProviderBase
public override IEnumerable<ControlBase> GetControls(UsageScenario cpus)
{
yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU");
yield return new CredentialProviderLogoControl(
ControlKeys.ProviderLogo,
"Acceso institucional SGU",
ProviderTileIcon.Create());
yield return new SmallLabelControl(
ControlKeys.InformationLabel,
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.");
@@ -1,7 +1,7 @@
{
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
"DomainNetbios": "LCI",
"TimeoutSeconds": 6,
"TimeoutSeconds": 20,
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
}