Compare commits

...
4 Commits
20 changed files with 542 additions and 35 deletions
+16 -3
View File
@@ -27,8 +27,8 @@ accepted it. The broker then makes separately bounded, best-effort profile GETs.
It uses the administrative incident overview for `AD`, the student information
page for `AL`, and the portal menu as a conservative base for `DO`. After the
incident page confirms an `AD` employee number, two additional GETs in the same
in-memory session read the structured name from `datos/personales.aspx` and the
address from `datos/ubicacion.aspx`. Docentes request
in-memory session read the structured name and selected sex from
`datos/personales.aspx` and the address from `datos/ubicacion.aspx`. Docentes request
`nomina/consultanomina.aspx` for a matching employee number, email, employee
type and job title, then attempt the same two shared staff modules without
requiring any optional route to exist. A supplemental
@@ -61,7 +61,9 @@ passes the submitted password directly to ADSI `SetPassword`.
When the authenticated HTML exposes recognized stable IDs, the broker also
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
`postalCode` attributes. Administrative and student numbers must match the six
`postalCode` attributes. The SGU sex value is normalized to `Male`/`Female` and
written as the managed `SGU-Gender:` line in the built-in `info` attribute while
preserving unrelated notes. Administrative and student numbers must match the six
numeric digits of the requested identity before any role-specific metadata is
trusted. Administrative personal and location pages are accepted only after
that incident-page match. Docente payroll metadata must match the requested
@@ -72,6 +74,14 @@ 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.
Every synchronization also enforces one idempotent security-group membership
from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. This happens synchronously
inside the broker before the institutional password is written to AD. A missing
or inaccessible role group therefore fails provisioning instead of leaving a
new usable account without its authorization classification. Existing accounts
are repaired automatically on their next successful SGU authentication.
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
titles are normalized with Spanish-aware casing; particles such as `de`, `del`
@@ -110,6 +120,9 @@ settings for managed clients.
That computer GPO also owns the base lock-screen image and a per-logon command
for the personalized desktop wallpaper. The client-side renderer reads the
managed `SGU-Gender: Male|Female` line from the user's built-in `info` attribute
(without requiring an irreversible AD schema extension). It uses neutral Spanish
when that optional enrichment is unavailable. The renderer also reads the
authenticated user's `displayName` plus the computer object's `location` and
immediate parent OU, then composes those values over the bundled dark-blue
background with the bundled Indivisa fonts. Missing directory attributes degrade
+1
View File
@@ -98,6 +98,7 @@ Get-Service SGUAuthBroker
Get-NetTCPConnection -LocalPort 8443 -State Listen
sc.exe qfailure SGUAuthBroker
Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
Get-ADGroup -Filter 'SamAccountName -like "SGU-*"' -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
```
## 4. Broker preflight from Windows 10
+2 -1
View File
@@ -29,7 +29,8 @@ rol, `TraceId`, resultado y tiempo total. Los Event ID estables distinguen:
IDs admitidos; `1202` timeout; `1203` excepción; `1204` página opcional no
disponible;
- `1300` fallo de sincronización AD; `1301` metadatos opcionales no aplicados;
`1302` membresía RDP opcional no aplicada.
`1302` membresía RDP opcional no aplicada; `1303` cuenta agregada a su grupo
institucional de Alumnos, Administrativos o Docentes.
No se almacena HTML, contraseña, hash de contraseña ni contenido de la
respuesta SGU.
+8 -2
View File
@@ -16,14 +16,20 @@ El generador consulta Active Directory con la identidad ya autenticada y sin
guardar credenciales. Obtiene:
- `displayName` del usuario; si falta, utiliza `sAMAccountName`.
- La línea administrada `SGU-Gender: Male|Female` del atributo `info`; el Auth
Broker la obtiene del SGU y conserva cualquier otra nota que ya exista.
- `location` del objeto de equipo.
- La OU padre inmediata a partir de `distinguishedName`.
El texto secundario sigue estas reglas:
El saludo usa `Bienvenido/ubicado` para `Male` y `Bienvenida/ubicada` para
`Female`. Cuando el enriquecimiento no produjo este dato, utiliza la redacción
neutral `Te damos la bienvenida` y `Ubicación:`. El texto secundario sigue estas
reglas:
1. Con `location` y OU: `Estás ubicado en la Sala de Inmersión del Centro de Experiencia Digital.`
2. Con sólo uno de los datos: muestra únicamente el dato disponible.
3. Sin ambos: `Bienvenido al Laboratorio de Cómputo de Ingeniería.`
3. Sin ambos: el texto adaptado `Bienvenido/Bienvenida al Laboratorio...`; sin
sexo disponible, la forma neutral `Acceso al Laboratorio de Cómputo de Ingeniería.`
La ausencia de AD, de un atributo o de una tipografía nunca bloquea la sesión.
Los errores de generación se registran en
+56 -2
View File
@@ -31,6 +31,9 @@ param(
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
[string]$DomainNetbios = 'LCI',
[string]$UpnSuffix = 'lci.lasalle.mx',
[string]$ProfessorGroupDn = '',
[string]$StudentGroupDn = '',
[string]$AdministrativeGroupDn = '',
[string]$RemoteDesktopGroupDn = '',
[ValidateLength(1, 64)]
[string]$DefaultCompany = 'La Salle',
@@ -72,10 +75,20 @@ if (-not $serverCertificate.Verify()) {
throw 'The HTTPS server certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
}
if ($CreateMissingOus) {
Import-Module ActiveDirectory -ErrorAction Stop
$usersOuName = 'Usuarios-SGU'
$usersOuDn = "OU=$usersOuName,$BaseDn"
if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) {
$ProfessorGroupDn = "CN=SGU-Docentes,$usersOuDn"
}
if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) {
$StudentGroupDn = "CN=SGU-Alumnos,$usersOuDn"
}
if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) {
$AdministrativeGroupDn = "CN=SGU-Administrativos,$usersOuDn"
}
if ($CreateMissingOus) {
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$usersOuName)" -SearchBase $BaseDn -SearchScope OneLevel -Server $LdapHost -ErrorAction SilentlyContinue)) {
New-ADOrganizationalUnit -Name $usersOuName -Path $BaseDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
}
@@ -117,8 +130,46 @@ if ($CreateMissingOus) {
}
}
$roleGroupDefinitions = @(
[pscustomobject]@{ Role = 'Professor'; Dn = $ProfessorGroupDn; Description = 'SGU accounts with the DO institutional prefix.' }
[pscustomobject]@{ Role = 'Student'; Dn = $StudentGroupDn; Description = 'SGU accounts with the AL institutional prefix.' }
[pscustomobject]@{ Role = 'Administrative'; Dn = $AdministrativeGroupDn; Description = 'SGU accounts with the AD institutional prefix.' }
)
foreach ($definition in $roleGroupDefinitions) {
if (-not $definition.Dn.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
throw "$($definition.Role)GroupDn must identify a security group beneath BaseDn."
}
try {
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {
$roleGroup = $null
}
if (-not $roleGroup -and $CreateMissingOus) {
$groupDnMatch = [regex]::Match($definition.Dn, '^CN=(?<Name>[^,]+),(?<Path>.+)$', [Text.RegularExpressions.RegexOptions]::IgnoreCase)
if (-not $groupDnMatch.Success) {
throw "$($definition.Role)GroupDn must start with a simple CN component."
}
$groupName = $groupDnMatch.Groups['Name'].Value
if ($groupName.Length -gt 20) {
throw "$($definition.Role) group name exceeds the 20-character sAMAccountName limit."
}
New-ADGroup -Name $groupName -SamAccountName $groupName `
-GroupCategory Security -GroupScope Global `
-Path $groupDnMatch.Groups['Path'].Value `
-Description $definition.Description -Server $LdapHost | Out-Null
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
}
if (-not $roleGroup) {
throw "The required $($definition.Role) security group does not exist: $($definition.Dn)"
}
if ($roleGroup.GroupCategory -ne 'Security') {
throw "$($definition.Role)GroupDn must identify a security group."
}
}
if ($RemoteDesktopGroupDn) {
Import-Module ActiveDirectory -ErrorAction Stop
$remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop
if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or
-not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
@@ -186,6 +237,9 @@ $productionSettings = @{
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
ProfessorGroupDn = $ProfessorGroupDn
StudentGroupDn = $StudentGroupDn
AdministrativeGroupDn = $AdministrativeGroupDn
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
DefaultCompany = $DefaultCompany
CreateMissingOus = [bool]$CreateMissingOus
+1
View File
@@ -32,6 +32,7 @@ $eventNames = @{
1300 = 'DirectorySynchronizationFailure'
1301 = 'DirectoryOptionalMetadataFailure'
1302 = 'DirectoryGroupMembershipFailure'
1303 = 'DirectoryRoleGroupMembershipAdded'
}
# Keep these reads unfiltered. Besides making archived and current logs behave
+19 -5
View File
@@ -27,7 +27,9 @@ foreach ($assetPath in $assetPaths) {
}
$token = $env:GITEA_TOKEN
if (-not $token) {
$authorizationScheme = 'token'
$authorizationParameter = $token
if (-not $authorizationParameter) {
$credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n"
$credentialOutput = $credentialInput | & git credential fill
if ($LASTEXITCODE -ne 0) {
@@ -40,17 +42,25 @@ if (-not $token) {
$credentialValues[$parts[0]] = $parts[1]
}
}
$token = $credentialValues.password
if ($credentialValues.username -and $credentialValues.password) {
$authorizationScheme = 'Basic'
$basicCredential = '{0}:{1}' -f $credentialValues.username,$credentialValues.password
$authorizationParameter = [Convert]::ToBase64String(
[Text.Encoding]::UTF8.GetBytes($basicCredential))
$basicCredential = $null
}
if (-not $token) {
throw 'No Gitea token is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
}
if (-not $authorizationParameter) {
throw 'No Gitea credential is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
}
Add-Type -AssemblyName System.Net.Http
$handler = [Net.Http.HttpClientHandler]::new()
$client = [Net.Http.HttpClient]::new($handler)
$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/')
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('token', $token)
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new(
$authorizationScheme,
$authorizationParameter)
$client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0')
function Invoke-GiteaJson {
@@ -102,6 +112,8 @@ Bootstrap reproducible para el laboratorio SGU.
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque.
- `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea estos grupos de seguridad de forma idempotente.
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
@@ -154,6 +166,8 @@ Las contraseñas se solicitan de forma interactiva y no se escriben en archivos
}
finally {
$token = $null
$authorizationParameter = $null
$credentialValues = $null
$client.Dispose()
$handler.Dispose()
}
+59 -6
View File
@@ -8,6 +8,8 @@ param(
[string]$ComputerName = $env:COMPUTERNAME,
[string]$Location,
[string]$OrganizationalUnit,
[ValidateSet('Male', 'Female')]
[string]$Gender,
[ValidateRange(640, 16384)]
[int]$CanvasWidth,
[ValidateRange(480, 16384)]
@@ -67,6 +69,22 @@ function Get-ImmediateOrganizationalUnit {
return $null
}
function Get-SguGenderFromInfo {
param([string]$Info)
if (-not $Info) {
return $null
}
foreach ($line in $Info -split '\r?\n') {
if ($line -match '^\s*SGU-Gender:\s*(Male|Female)\s*$') {
return [Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase(
$Matches[1].ToLowerInvariant())
}
}
return $null
}
function Get-DirectoryWelcomeMetadata {
param(
[Parameter(Mandatory)][string]$UserName,
@@ -93,6 +111,7 @@ function Get-DirectoryWelcomeMetadata {
$userSearcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName={0}))' -f `
(ConvertTo-LdapFilterValue -Value $UserName)
[void]$userSearcher.PropertiesToLoad.Add('displayName')
[void]$userSearcher.PropertiesToLoad.Add('info')
$userResult = $userSearcher.FindOne()
$directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) {
[string]$userResult.Properties['displayname'][0]
@@ -100,6 +119,12 @@ function Get-DirectoryWelcomeMetadata {
else {
$null
}
$directoryGender = if ($userResult -and $userResult.Properties['info'].Count) {
Get-SguGenderFromInfo -Info ([string]$userResult.Properties['info'][0])
}
else {
$null
}
}
finally {
$userSearcher.Dispose()
@@ -136,6 +161,7 @@ function Get-DirectoryWelcomeMetadata {
[pscustomobject]@{
DisplayName = $directoryDisplayName
Gender = $directoryGender
Location = $directoryLocation
OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn
}
@@ -156,11 +182,20 @@ function Get-SpanishArticle {
function Get-WelcomeLocationText {
param(
[string]$Room,
[string]$OuName
[string]$OuName,
[string]$Gender
)
$located = 'Est{0}s ubicado en' -f [char]0x00E1
$engineeringLab = 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED
$located = switch ($Gender) {
'Male' { 'Est{0}s ubicado en' -f [char]0x00E1 }
'Female' { 'Est{0}s ubicada en' -f [char]0x00E1 }
default { 'Ubicaci{0}n:' -f [char]0x00F3 }
}
$engineeringLab = switch ($Gender) {
'Male' { 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
'Female' { 'Bienvenida al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
default { 'Acceso al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
}
$Room = if ($Room) { $Room.Trim() } else { $null }
$OuName = if ($OuName) { $OuName.Trim() } else { $null }
@@ -184,6 +219,16 @@ function Get-WelcomeLocationText {
return $engineeringLab
}
function Get-WelcomeHeading {
param([string]$Gender)
switch ($Gender) {
'Male' { return 'Bienvenido,' }
'Female' { return 'Bienvenida,' }
default { return 'Te damos la bienvenida,' }
}
}
function Get-AvailableFontFamily {
param(
[Parameter(Mandatory)][string[]]$Candidates,
@@ -270,10 +315,15 @@ if (-not $DisplayName) {
if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) {
$Location = $metadata.Location
}
$genderWasProvided = $PSBoundParameters.ContainsKey('Gender')
if (-not $genderWasProvided -and $metadata) {
$Gender = $metadata.Gender
}
$welcomeHeading = Get-WelcomeHeading -Gender $Gender
if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit') -and $metadata) {
$OrganizationalUnit = $metadata.OrganizationalUnit
}
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit -Gender $Gender
if (-not $CanvasWidth -or -not $CanvasHeight) {
try {
@@ -366,7 +416,7 @@ try {
$format.Trimming = [Drawing.StringTrimming]::EllipsisWord
try {
$graphics.FillRectangle($panelBrush, $panelX, $panelY, $panelWidth, $panelHeight)
Draw-CenteredText -Graphics $graphics -Text 'Bienvenido,' -Font $welcomeFont `
Draw-CenteredText -Graphics $graphics -Text $welcomeHeading -Font $welcomeFont `
-Brush $accentBrush -Bounds ([Drawing.RectangleF]::new($panelX, $panelY + 24*$scale, $panelWidth, 50*$scale)) -Format $format
Draw-CenteredText -Graphics $graphics -Text $DisplayName -Font $nameFont `
-Brush $whiteBrush -Bounds ([Drawing.RectangleF]::new($panelX + 30*$scale, $panelY + 64*$scale, $panelWidth - 60*$scale, 105*$scale)) -Format $format
@@ -433,12 +483,15 @@ namespace Sgu {
}
}
Write-WelcomeLog -Message ("OK computer={0}; location={1}; ou={2}; output={3}" -f $ComputerName,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath)
$genderLogValue = if ($Gender) { $Gender } else { 'Neutral' }
Write-WelcomeLog -Message ("OK computer={0}; gender={1}; location={2}; ou={3}; output={4}" -f $ComputerName,$genderLogValue,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath)
[pscustomobject]@{
DisplayName = $DisplayName
ComputerName = $ComputerName
Location = $Location
OrganizationalUnit = $OrganizationalUnit
Gender = $Gender
WelcomeHeading = $welcomeHeading
LocationText = $locationText
OutputPath = $OutputPath
Applied = -not $SkipApply
+35 -7
View File
@@ -53,6 +53,7 @@ computer_name=${computer_name^^}
location=''
distinguished_name=''
organizational_unit=''
gender=''
read_ldif_value() {
local attribute=$1
@@ -108,9 +109,18 @@ if [[ -n $DOMAIN_CONTROLLER && -n $BASE_DN ]] &&
user_result=$(ldapsearch -LLL -N -o ldif-wrap=no -Y GSSAPI \
-H "ldap://${ldap_server}" -b "$BASE_DN" \
"(&(objectCategory=person)(objectClass=user)(sAMAccountName=${account_name}))" \
displayName 2>/dev/null || true)
displayName info 2>/dev/null || true)
directory_display_name=$(read_ldif_value displayName "$user_result")
[[ -n $directory_display_name ]] && display_name=$directory_display_name
directory_info=$(read_ldif_value info "$user_result")
gender=$(printf '%s\n' "$directory_info" | awk -F: '
tolower($1) ~ /^[[:space:]]*sgu-gender[[:space:]]*$/ {
value=tolower($2); gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
if (value == "male") print "Male"
else if (value == "female") print "Female"
exit
}
')
fi
else
log_message 'WARN AD metadata query skipped because Kerberos or LDAP session data was unavailable.'
@@ -136,6 +146,24 @@ with_article() {
fi
}
case "$gender" in
Male)
welcome_text='Bienvenido,'
located_text='Estás ubicado en'
engineering_lab_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
;;
Female)
welcome_text='Bienvenida,'
located_text='Estás ubicada en'
engineering_lab_text='Bienvenida al Laboratorio de Cómputo de Ingeniería.'
;;
*)
welcome_text='Te damos la bienvenida,'
located_text='Ubicación:'
engineering_lab_text='Acceso al Laboratorio de Cómputo de Ingeniería.'
;;
esac
if [[ -n $location && -n $organizational_unit ]]; then
room_phrase=$(with_article "$location")
ou_article=$(article_for "$organizational_unit")
@@ -146,13 +174,13 @@ if [[ -n $location && -n $organizational_unit ]]; then
else
ou_phrase="de ${organizational_unit}"
fi
location_text="Estás ubicado en ${room_phrase} ${ou_phrase}."
location_text="${located_text} ${room_phrase} ${ou_phrase}."
elif [[ -n $location ]]; then
location_text="Estás ubicado en $(with_article "$location")."
location_text="${located_text} $(with_article "$location")."
elif [[ -n $organizational_unit ]]; then
location_text="Estás ubicado en $(with_article "$organizational_unit")."
location_text="${located_text} $(with_article "$organizational_unit")."
else
location_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
location_text=$engineering_lab_text
fi
width=1600
@@ -210,7 +238,7 @@ if ! "${image_command[@]}" "$BASE_IMAGE" \
-gravity center \
-font "$sans_font" -weight 700 -style Normal -pointsize "$welcome_size" \
-fill '#D3E2FF' -stroke 'rgba(0,0,0,0.48)' -strokewidth 1 \
-annotate "+0-$(( 92 * scale / 100 ))" 'Bienvenido,' \
-annotate "+0-$(( 92 * scale / 100 ))" "$welcome_text" \
-font "$serif_font" -weight 700 -style Italic -pointsize "$name_size" \
-fill white -annotate "+0-$(( 22 * scale / 100 ))" "$display_name" \
-font "$sans_font" -weight 400 -style Normal -pointsize "$location_size" \
@@ -242,7 +270,7 @@ if [[ $applied == false ]] && command -v xfconf-query >/dev/null 2>&1; then
fi
if [[ $applied == true ]]; then
log_message "OK computer=${computer_name}; location=$([[ -n $location ]] && printf true || printf false); ou=$([[ -n $organizational_unit ]] && printf true || printf false); output=${output_path}"
log_message "OK computer=${computer_name}; gender=${gender:-Neutral}; location=$([[ -n $location ]] && printf true || printf false); ou=$([[ -n $organizational_unit ]] && printf true || printf false); output=${output_path}"
else
log_message 'WARN Wallpaper rendered, but no supported desktop background API was found.'
fi
@@ -0,0 +1,7 @@
namespace SGU.AuthBroker.Core.Profiles;
public enum InstitutionalGender
{
Male,
Female
}
@@ -12,7 +12,8 @@ public sealed record InstitutionalProfile(
string? StreetAddress = null,
string? City = null,
string? State = null,
string? PostalCode = null)
string? PostalCode = null,
InstitutionalGender? Gender = null)
{
public bool HasValues =>
EmployeeNumber is not null ||
@@ -26,7 +27,8 @@ public sealed record InstitutionalProfile(
StreetAddress is not null ||
City is not null ||
State is not null ||
PostalCode is not null;
PostalCode is not null ||
Gender is not null;
public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
values is null
@@ -44,6 +46,7 @@ public sealed record InstitutionalProfile(
StreetAddress = values.StreetAddress ?? StreetAddress,
City = values.City ?? City,
State = values.State ?? State,
PostalCode = values.PostalCode ?? PostalCode
PostalCode = values.PostalCode ?? PostalCode,
Gender = values.Gender ?? Gender
};
}
@@ -14,6 +14,8 @@ public static class SguProfileParser
private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
private const string AdministrativeGenderId = "ctl00_contenedor_ddlsexo";
private const string AdministrativeGenderName = "ctl00$contenedor$ddlsexo";
private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
@@ -34,6 +36,7 @@ public static class SguProfileParser
private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
private const string StudentGenderId = "ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP";
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
=> ParseStaffHeader(html, expectedEmployeeNumber);
@@ -91,7 +94,11 @@ public static class SguProfileParser
InstitutionalProfile profile = new(
DisplayName: displayName,
GivenName: givenName,
Surname: surname);
Surname: surname,
Gender: ParseStaffGender(ExtractSelectedOptionValue(
html,
AdministrativeGenderId,
AdministrativeGenderName)));
return profile.HasValues ? profile : null;
}
@@ -151,7 +158,8 @@ public static class SguProfileParser
StreetAddress: streetAddress,
City: city ?? municipality,
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128),
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)));
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)),
Gender: ParseStudentGender(ExtractSpanText(html, StudentGenderId)));
return profile.HasValues ? profile : null;
}
@@ -292,11 +300,68 @@ public static class SguProfileParser
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null;
}
private static string? ExtractSelectedOptionValue(string html, string id, string name)
{
string? openingTag = FindOpeningTag(html, "select", id) ??
FindOpeningTagByAttribute(html, "select", "name", name);
if (openingTag is null)
{
return null;
}
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
int contentStart = openingTagIndex + openingTag.Length;
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
if (openingTagIndex < 0 || contentEnd < 0)
{
return null;
}
string? selectedValue = ExtractAttributeValue(openingTag, "value");
string optionsHtml = html[contentStart..contentEnd];
int searchFrom = 0;
while (searchFrom < optionsHtml.Length)
{
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
if (optionStart < 0)
{
break;
}
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
if (optionTagEnd < 0)
{
break;
}
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
string? optionValue = ExtractAttributeValue(optionTag, "value");
if (optionValue is not null &&
(HasAttribute(optionTag, "selected") ||
(selectedValue is not null &&
string.Equals(optionValue, selectedValue, StringComparison.Ordinal))))
{
return NormalizeText(optionValue);
}
searchFrom = optionTagEnd + 1;
}
return null;
}
private static string? FindOpeningTag(string html, string tagName, string id)
=> FindOpeningTagByAttribute(html, tagName, "id", id);
private static string? FindOpeningTagByAttribute(
string html,
string tagName,
string attributeName,
string attributeValue)
{
foreach (char quote in new[] { '"', '\'' })
{
string marker = $"id={quote}{id}{quote}";
string marker = $"{attributeName}={quote}{attributeValue}{quote}";
int searchFrom = 0;
while (searchFrom < html.Length)
{
@@ -324,6 +389,20 @@ public static class SguProfileParser
return null;
}
private static InstitutionalGender? ParseStaffGender(string? value) => value?.Trim() switch
{
"1" => InstitutionalGender.Male,
"2" => InstitutionalGender.Female,
_ => null
};
private static InstitutionalGender? ParseStudentGender(string? value) => value?.Trim().ToUpperInvariant() switch
{
"M" => InstitutionalGender.Male,
"F" => InstitutionalGender.Female,
_ => null
};
private static string? ExtractAttributeValue(string openingTag, string attributeName)
{
foreach (char quote in new[] { '"', '\'' })
+1
View File
@@ -21,4 +21,5 @@ internal static class BrokerEventIds
internal static readonly EventId DirectorySynchronizationFailure = new(1300, nameof(DirectorySynchronizationFailure));
internal static readonly EventId DirectoryOptionalMetadataFailure = new(1301, nameof(DirectoryOptionalMetadataFailure));
internal static readonly EventId DirectoryGroupMembershipFailure = new(1302, nameof(DirectoryGroupMembershipFailure));
internal static readonly EventId DirectoryRoleGroupMembershipAdded = new(1303, nameof(DirectoryRoleGroupMembershipAdded));
}
@@ -87,6 +87,14 @@ public sealed class BrokerOptions
{
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
}
string groupDn = Directory.GetGroupDn(role);
if (string.IsNullOrWhiteSpace(groupDn) ||
!groupDn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase) ||
!groupDn.EndsWith($",{Directory.BaseDn}", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"The security-group mapping for {role} must identify a group beneath BaseDn.");
}
}
if (!string.IsNullOrWhiteSpace(Directory.RemoteDesktopGroupDn) &&
@@ -168,6 +176,12 @@ public sealed class ActiveDirectoryOptions
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string ProfessorGroupDn { get; init; } = "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string StudentGroupDn { get; init; } = "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string AdministrativeGroupDn { get; init; } = "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
public string DefaultCompany { get; init; } = "La Salle";
@@ -181,4 +195,12 @@ public sealed class ActiveDirectoryOptions
InstitutionalRole.Administrative => AdministrativeOuDn,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
};
public string GetGroupDn(InstitutionalRole role) => role switch
{
InstitutionalRole.Professor => ProfessorGroupDn,
InstitutionalRole.Student => StudentGroupDn,
InstitutionalRole.Administrative => AdministrativeGroupDn,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
};
}
@@ -11,6 +11,8 @@ public sealed class ActiveDirectorySynchronizer(
BrokerOptions options,
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
{
private const string GenderMetadataPrefix = "SGU-Gender:";
private const int InfoAttributeMaximumLength = 1024;
private const int AccountDisabled = 0x0002;
private const int NormalAccount = 0x0200;
private static readonly AuthenticationTypes BindFlags =
@@ -109,6 +111,12 @@ public sealed class ActiveDirectorySynchronizer(
user.CommitChanges();
}
// Role membership is part of account provisioning, not optional
// enrichment. Do it before changing the password so a missing or
// inaccessible authorization group cannot leave a newly usable
// account without its required classification.
EnsureRoleGroupMembership(user, identity);
// The exact institutional password received by the broker is passed to AD.
// It is not derived, transformed, written to disk, or included in logs.
user.Invoke("SetPassword", [password]);
@@ -158,6 +166,7 @@ public sealed class ActiveDirectorySynchronizer(
SetOptionalProperty(user, "l", profile.City);
SetOptionalProperty(user, "st", profile.State);
SetOptionalProperty(user, "postalCode", profile.PostalCode);
SetGenderMetadata(user, profile.Gender, identity.UserName, logger);
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
{
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
@@ -195,6 +204,83 @@ public sealed class ActiveDirectorySynchronizer(
}
}
private static void SetGenderMetadata(
DirectoryEntry entry,
InstitutionalGender? gender,
string institutionalUser,
ILogger logger)
{
if (gender is null)
{
return;
}
string existing = Convert.ToString(entry.Properties["info"].Value) ?? string.Empty;
string? updated = MergeGenderMetadata(existing, gender);
if (updated is null)
{
logger.LogWarning(
BrokerEventIds.DirectoryOptionalMetadataFailure,
"Gender metadata was not written for {InstitutionalUser} because the Active Directory info attribute has no remaining capacity.",
institutionalUser);
return;
}
entry.Properties["info"].Value = updated;
}
internal static string? MergeGenderMetadata(
string? existing,
InstitutionalGender? gender)
{
if (gender is null)
{
return null;
}
string managedLine = $"{GenderMetadataPrefix} {gender}";
string normalizedExisting = (existing ?? string.Empty)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
string[] preservedLines = string.IsNullOrEmpty(normalizedExisting)
? []
: normalizedExisting
.Split('\n')
.Where(line => !line.TrimStart().StartsWith(
GenderMetadataPrefix,
StringComparison.OrdinalIgnoreCase))
.ToArray();
string updated = string.Join("\r\n", preservedLines.Append(managedLine));
return updated.Length <= InfoAttributeMaximumLength ? updated : null;
}
private void EnsureRoleGroupMembership(DirectoryEntry user, UserIdentity identity)
{
user.RefreshCache(["distinguishedName"]);
string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value);
if (string.IsNullOrWhiteSpace(userDn))
{
throw new InvalidOperationException($"Active Directory did not return a distinguished name for {identity.UserName}.");
}
string groupDn = options.GetGroupDn(identity.Role);
using DirectoryEntry group = Bind(groupDn);
_ = group.NativeObject;
if (group.Properties["member"].Contains(userDn))
{
return;
}
group.Properties["member"].Add(userDn);
group.CommitChanges();
logger.LogInformation(
BrokerEventIds.DirectoryRoleGroupMembershipAdded,
"Added {InstitutionalUser} with role {Role} to Active Directory security group {GroupDn}.",
identity.UserName,
identity.Role,
groupDn);
}
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
{
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
+3
View File
@@ -50,6 +50,9 @@
"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",
"ProfessorGroupDn": "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentGroupDn": "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"RemoteDesktopGroupDn": "",
"DefaultCompany": "La Salle",
"CreateMissingOus": false
@@ -133,6 +133,64 @@ public sealed class SguProfileParserTests
Assert.Null(profile.Email);
}
[Theory]
[InlineData("ctl00_contenedor_ddlsexo", "ctl00$contenedor$ddlsexo", "1", InstitutionalGender.Male)]
[InlineData("alternate-id", "ctl00$contenedor$ddlsexo", "2", InstitutionalGender.Female)]
public void ParsesStaffGenderFromTheSelectedPersonalDataOption(
string id,
string name,
string selectedValue,
InstitutionalGender expected)
{
string html = $"""
<select id="{id}" name="{name}">
<option value="">Seleccione...</option>
<option value="1"{(selectedValue == "1" ? " selected=\"selected\"" : string.Empty)}>Masculino</option>
<option value="2"{(selectedValue == "2" ? " selected=\"selected\"" : string.Empty)}>Femenino</option>
</select>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
Assert.NotNull(profile);
Assert.Equal(expected, profile.Gender);
}
[Theory]
[InlineData("M", InstitutionalGender.Male)]
[InlineData("f", InstitutionalGender.Female)]
public void ParsesStudentGenderFromTheInformationSpan(
string source,
InstitutionalGender expected)
{
string html = $"""
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">{source}</span>
""";
InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456");
Assert.NotNull(profile);
Assert.Equal(expected, profile.Gender);
}
[Fact]
public void IgnoresUnknownGenderValuesWithoutFailingProfileParsing()
{
const string html = """
<input id="ctl00_contenedor_txtNombre" value="PERSONA" />
<select id="ctl00_contenedor_ddlsexo">
<option selected="selected" value="9">SIN CLASIFICAR</option>
</select>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
Assert.NotNull(profile);
Assert.Equal("Persona", profile.DisplayName);
Assert.Null(profile.Gender);
}
[Fact]
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
{
@@ -182,7 +240,8 @@ public sealed class SguProfileParserTests
InstitutionalProfile personal = new(
DisplayName: "María del Carmen de la Fuente",
GivenName: "María del Carmen",
Surname: "de la Fuente");
Surname: "de la Fuente",
Gender: InstitutionalGender.Female);
InstitutionalProfile location = new(
StreetAddress: "Calle Uno 10",
City: "Ciudad de México",
@@ -200,6 +259,7 @@ public sealed class SguProfileParserTests
Assert.Equal("Ingeniería", combined.Department);
Assert.Equal("Calle Uno 10", combined.StreetAddress);
Assert.Equal("01000", combined.PostalCode);
Assert.Equal(InstitutionalGender.Female, combined.Gender);
}
[Fact]
@@ -215,6 +275,7 @@ public sealed class SguProfileParserTests
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">F</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
</span>
@@ -262,6 +323,7 @@ public sealed class SguProfileParserTests
Assert.Equal("Ciudad de México", profile.City);
Assert.Equal("Ciudad de México", profile.State);
Assert.Equal("08500", profile.PostalCode);
Assert.Equal(InstitutionalGender.Female, profile.Gender);
}
[Fact]
@@ -0,0 +1,34 @@
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Services;
using Xunit;
namespace SGU.AuthBroker.Tests;
public sealed class ActiveDirectorySynchronizerTests
{
[Fact]
public void GenderMetadataPreservesUnmanagedNotesAndReplacesItsManagedLine()
{
const string existing = " Responsable de laboratorio \r\n\r\nSGU-Gender: Male\r\nTurno vespertino";
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
existing,
InstitutionalGender.Female);
Assert.Equal(
" Responsable de laboratorio \r\n\r\nTurno vespertino\r\nSGU-Gender: Female",
updated);
}
[Fact]
public void GenderMetadataDoesNotTruncateAnExistingFullNotesField()
{
string existing = new('x', 1024);
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
existing,
InstitutionalGender.Male);
Assert.Null(updated);
}
}
@@ -1,4 +1,5 @@
using SGU.AuthBroker.Options;
using SGU.AuthBroker.Core.Identity;
using Xunit;
namespace SGU.AuthBroker.Tests;
@@ -28,4 +29,31 @@ public sealed class BrokerOptionsTests
Assert.Contains("thumbprint", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(InstitutionalRole.Student, "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
[InlineData(InstitutionalRole.Administrative, "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
[InlineData(InstitutionalRole.Professor, "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
public void DefaultRoleGroupMappingsMatchInstitutionalPrefixes(InstitutionalRole role, string expectedGroupDn)
{
ActiveDirectoryOptions options = new();
Assert.Equal(expectedGroupDn, options.GetGroupDn(role));
}
[Fact]
public void ValidateRejectsARoleGroupOutsideTheConfiguredDirectoryBase()
{
BrokerOptions options = new()
{
Directory = new ActiveDirectoryOptions
{
StudentGroupDn = "CN=SGU-Alumnos,DC=example,DC=invalid"
}
};
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(options.Validate);
Assert.Contains("security-group", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using Microsoft.Extensions.Logging.Abstractions;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Options;
using SGU.AuthBroker.Services;
using Xunit;
@@ -132,6 +133,10 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
<select name="ctl00$contenedor$ddlsexo">
<option value="1">Masculino</option>
<option selected="selected" value="2">Femenino</option>
</select>
"""),
Response(
HttpStatusCode.OK,
@@ -168,6 +173,7 @@ public sealed class NtlmCredentialValidatorTests
Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(InstitutionalGender.Female, result.Profile.Gender);
Assert.Equal(
[
"/psulsa/",
@@ -224,6 +230,10 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
<select id="ctl00_contenedor_ddlsexo">
<option selected="selected" value="1">Masculino</option>
<option value="2">Femenino</option>
</select>
"""),
Response(
HttpStatusCode.OK,
@@ -262,6 +272,7 @@ public sealed class NtlmCredentialValidatorTests
Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(InstitutionalGender.Male, result.Profile.Gender);
Assert.Equal(
[
"/psulsa/",