Improve SGU logon resilience and client UX
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user