Compare commits

...
5 Commits
28 changed files with 1105 additions and 54 deletions
+1
View File
@@ -50,6 +50,7 @@ Operational documentation:
- [Broker location, health, timeout, and recovery](docs/broker-operations.md) - [Broker location, health, timeout, and recovery](docs/broker-operations.md)
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md) - [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
- [Required Credential Provider client enrollment](docs/client-enrollment.md) - [Required Credential Provider client enrollment](docs/client-enrollment.md)
- [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md)
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md) - [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
| Prefix | Role | Default OU | | Prefix | Role | Default OU |
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+18 -1
View File
@@ -86,7 +86,9 @@ being grouped below the anonymous **Other user** tile. Machine policy assigns
the SGU CLSID as the default provider, hides the last signed-in identity, and the SGU CLSID as the default provider, hides the last signed-in identity, and
disables local-user enumeration while retaining the built-in Microsoft password disables local-user enumeration while retaining the built-in Microsoft password
provider and its **Other user** recovery path. The computer GPO also applies provider and its **Other user** recovery path. The computer GPO also applies
Windows' native default account picture to named Windows accounts. It enumerates one Windows' native default account picture to named Windows accounts; client
enrollment installs the La Salle mascot bitmap in Windows' standard account-picture
location before that GPO takes effect. It enumerates one
`CPFT_TILE_IMAGE` and places the `CPFT_LARGE_TEXT` heading immediately after it `CPFT_TILE_IMAGE` and places the `CPFT_LARGE_TEXT` heading immediately after it
with `CPFS_DISPLAY_IN_SELECTED_TILE`. LogonUI owns field typography and vertical with `CPFS_DISPLAY_IN_SELECTED_TILE`. LogonUI owns field typography and vertical
tile order: on Windows 10 and 11, the account-name title used by **Other user** tile order: on Windows 10 and 11, the account-name title used by **Other user**
@@ -103,6 +105,21 @@ to `OU=Laboratorio`; it suppresses first-logon/privacy/diagnostic prompts,
disables location, and enforces always-on display, sleep, and hibernation disables location, and enforces always-on display, sleep, and hibernation
settings for managed clients. settings for managed clients.
The domain controller is also the source-initiated Windows Event Collector for
managed laboratory computers. Kerberos-authenticated WEF sends only selected
logon/logoff, failed-logon, reconnect/disconnect, and operating-system power
events to `ForwardedEvents`. Daily EVTX archives are retained for 183 days, and
a five-minute server-side inventory records WinRM reachability and AD last-logon
metadata. Session-duration reports correlate Windows logon IDs; no password or
SGU HTTP payload is included in this monitoring path.
Broker diagnostics use the dedicated `SGU Auth Broker` Windows log with stable
event IDs for authorization outcomes, SGU network/timeout failures, unexpected
profile HTML, partial enrichment, and AD synchronization warnings. The same
daily maintenance task archives that log for 183 days. Messages identify the
institutional user and role but never include passwords, password verifiers, or
raw SGU HTML.
Per-user synchronization is serialized inside the broker to prevent concurrent Per-user synchronization is serialized inside the broker to prevent concurrent
create/reset races. Production deployments should run the broker as a gMSA with create/reset races. Production deployments should run the broker as a gMSA with
delegated create-user, move-user, write-property, enable-account, and reset-password delegated create-user, move-user, write-property, enable-account, and reset-password
+118
View File
@@ -0,0 +1,118 @@
# Monitoreo y registros de uso
El dominio usa **Windows Event Forwarding (WEF)** y el servicio nativo
**Windows Event Collector (Wecsvc)**. No instala un agente de telemetría y no
registra contraseñas, contenido de escritorio ni páginas visitadas.
## Datos recopilados
La suscripción `SGU-Lab-Monitoring` recibe desde los equipos de
`OU=Laboratorio`:
- inicios de sesión correctos y fallidos (`4624` y `4625`);
- cierre de sesión, desconexión y reconexión (`4634`, `4647`, `4778`, `4779`);
- arranque, apagado, reinicio y apagado inesperado (`12`, `13`, `41`, `1074`,
`6005`, `6006`, `6008`).
Con el identificador de sesión de Windows se obtiene quién usó qué equipo,
hora de entrada, hora de salida y duración. Los eventos fallidos conservan el
estado de Windows, pero nunca la contraseña introducida.
El broker escribe un registro separado de Windows llamado `SGU Auth Broker`.
Cada solicitud válida queda correlacionada mediante usuario institucional,
rol, `TraceId`, resultado y tiempo total. Los Event ID estables distinguen:
- `1000` autorización completada; `1001` credenciales rechazadas; `1002`
servicio no disponible; `1003` solicitud inválida;
- `1100` SGU aceptó la autenticación; `1101` timeout; `1102` fallo de red/DNS;
- `1200` enriquecimiento completado y cantidad de campos; `1201` HTML sin los
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.
No se almacena HTML, contraseña, hash de contraseña ni contenido de la
respuesta SGU.
## Retención
`ForwardedEvents` tiene un límite de 512 MB. La tarea
`SGU-Monitoring-Retention` lo archiva diariamente en:
```text
C:\ProgramData\SGU\Monitoring\Archive
```
El mismo ciclo archiva `SGU Auth Broker` en `Archive\Broker`. Solo elimina
archivos `.evtx` cuya antigüedad supera **183 días**. La tarea
`SGU-Monitoring-Inventory` actualiza cada cinco minutos el inventario en:
```text
C:\ProgramData\SGU\Monitoring\Reports\machine-status.json
C:\ProgramData\SGU\Monitoring\Reports\machine-status.csv
```
`Encendida` significa que WinRM respondió en TCP 5985. `Apagada o inaccesible`
también puede significar que el equipo está arrancando, perdió la red o tiene
el puerto bloqueado; Active Directory por sí solo no conoce el estado eléctrico
instantáneo de un PC.
## Consultas
En el servidor, como administrador:
```powershell
# Estado actual de las máquinas
Get-Content 'C:\ProgramData\SGU\Monitoring\Reports\machine-status.json' -Raw |
ConvertFrom-Json | Format-Table ComputerName,Status,IPv4Address,LastDomainLogon
# Uso de los últimos seis meses
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' |
Format-Table User,Computer,StartedAt,EndedAt,DurationMinutes,Result
# Un usuario o una máquina
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' -UserName AL201428
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' -ComputerName LCI-W11-01
# Diagnóstico del broker; admite -UserName, -Level, -EventId y -Text
& 'C:\ProgramData\SGU\Monitoring\Get-SguBrokerLog.ps1' -UserName AL201428 |
Format-Table TimeCreated,Level,EventId,EventName,Message -Wrap
& 'C:\ProgramData\SGU\Monitoring\Get-SguBrokerLog.ps1' -Level Warning
# Exportación
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' `
-OutputCsv 'C:\ProgramData\SGU\Monitoring\Reports\usage.csv'
```
Para revisar la salud del colector:
```powershell
Get-Service Wecsvc
wecutil enum-subscription
wecutil get-subscriptionruntimestatus SGU-Lab-Monitoring
Get-WinEvent -LogName ForwardedEvents -MaxEvents 20
Get-WinEvent -LogName 'SGU Auth Broker' -MaxEvents 20
Get-ScheduledTask -TaskName 'SGU-Monitoring-*'
```
> **Windows Server 2025:** no consultes `ForwardedEvents` mediante
> `-FilterHashtable` ni XPath. Algunas compilaciones tienen una regresión que
> termina el servicio Windows Event Log (`wevtsvc.dll`, `0xc0000420`). El
> reporte incluido lee el canal sin consulta estructurada y aplica los filtros
> en memoria.
El bootstrap del servidor crea el colector, la suscripción y las tareas. El GPO
`SGU - Windows client experience` publica el Subscription Manager por FQDN. El
bootstrap y la reparación diaria del cliente habilitan las subcategorías de
auditoría y el acceso de `NETWORK SERVICE` al registro Security.
La suscripción usa `ContentFormat=Events`: conserva el XML original que emplea
el reporte y evita que cada cliente tenga que renderizar texto localizado antes
de enviarlo.
Referencias de Microsoft:
- <https://learn.microsoft.com/en-us/windows/win32/wec/setting-up-a-source-initiated-subscription>
- <https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wecutil>
- <https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4624>
- <https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4634>
+27 -1
View File
@@ -46,6 +46,8 @@ param(
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$serviceName = 'SGUAuthBroker' $serviceName = 'SGUAuthBroker'
$installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker' $installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker'
$brokerEventLogName = 'SGU Auth Broker'
$brokerEventSource = 'SGU.AuthBroker.Operational'
$normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' }) $normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' })
if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) { if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) {
throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.' throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.'
@@ -129,6 +131,14 @@ foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.jso
} }
$productionSettings = @{ $productionSettings = @{
Logging = @{
EventLog = @{
LogLevel = @{
Default = 'Information'
'Microsoft.AspNetCore' = 'Warning'
}
}
}
Kestrel = @{ Kestrel = @{
Endpoints = @{ Endpoints = @{
Https = @{ Https = @{
@@ -143,6 +153,9 @@ $productionSettings = @{
} }
} }
Broker = @{ Broker = @{
Diagnostics = @{
UseDedicatedEventLog = $true
}
Tls = @{ Tls = @{
AllowedClientThumbprints = $normalizedClientThumbprints AllowedClientThumbprints = $normalizedClientThumbprints
CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
@@ -198,6 +211,18 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
$settingsJson, $settingsJson,
$utf8WithoutBom) $utf8WithoutBom)
if ([Diagnostics.EventLog]::SourceExists($brokerEventSource)) {
$registeredLog = [Diagnostics.EventLog]::LogNameFromSourceName($brokerEventSource, '.')
if (-not $registeredLog.Equals($brokerEventLogName, [StringComparison]::OrdinalIgnoreCase)) {
throw "Event source $brokerEventSource is already registered to $registeredLog."
}
}
else {
New-EventLog -LogName $brokerEventLogName -Source $brokerEventSource
}
Limit-EventLog -LogName $brokerEventLogName -MaximumSize 268435456 `
-OverflowAction OverwriteAsNeeded
if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) { if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) {
New-Service -Name $serviceName ` New-Service -Name $serviceName `
-DisplayName 'SGU Authentication Broker' ` -DisplayName 'SGU Authentication Broker' `
@@ -249,4 +274,5 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
Start-Service -Name $serviceName Start-Service -Name $serviceName
} }
Get-Service -Name $serviceName | Select-Object Name, Status, StartType Get-Service -Name $serviceName | Select-Object Name, Status, StartType,
@{ Name = 'EventLog'; Expression = { $brokerEventLogName } }
+61
View File
@@ -0,0 +1,61 @@
[CmdletBinding(SupportsShouldProcess)]
param()
$ErrorActionPreference = 'Stop'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated Windows PowerShell session.'
}
# Use invariant audit subcategory GUIDs so this works on English and Spanish
# installations. Logon, logoff, and other logon/logoff events provide the
# session identifiers required to correlate usage centrally.
$auditSubcategories = @(
'{0CCE9215-69AE-11D9-BED3-505054503030}', # Logon
'{0CCE9216-69AE-11D9-BED3-505054503030}', # Logoff
'{0CCE921C-69AE-11D9-BED3-505054503030}' # Other Logon/Logoff Events
)
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable SGU session auditing and event forwarding prerequisites')) {
foreach ($subcategory in $auditSubcategories) {
& auditpol.exe /set "/subcategory:$subcategory" /success:enable /failure:enable | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "auditpol failed for subcategory $subcategory with exit code $LASTEXITCODE."
}
}
# Security events are read by the Windows Event Forwarding plug-in under
# NETWORK SERVICE. Resolve both principals by SID for localized Windows.
$eventLogReadersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-573')
$networkServiceSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-20')
$members = @(Get-LocalGroupMember -SID $eventLogReadersSid -ErrorAction SilentlyContinue)
$eventLogReaderMembershipChanged = $false
if ($members.SID.Value -notcontains $networkServiceSid.Value) {
$networkServiceAccount = $networkServiceSid.Translate([Security.Principal.NTAccount]).Value
Add-LocalGroupMember -SID $eventLogReadersSid -Member $networkServiceAccount
$eventLogReaderMembershipChanged = $true
}
Set-Service WinRM -StartupType Automatic
if ((Get-Service WinRM).Status -ne 'Running') {
Start-Service WinRM
}
elseif ($eventLogReaderMembershipChanged) {
Restart-Service WinRM -Force
}
& wevtutil.exe set-log Security /maxsize:268435456 /retention:false /autobackup:false
if ($LASTEXITCODE -ne 0) {
throw "wevtutil failed to configure the local Security log with exit code $LASTEXITCODE."
}
}
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
WinRM = (Get-Service WinRM).Status.ToString()
SecurityLogMaximumBytes = (Get-WinEvent -ListLog Security).MaximumSizeInBytes
AuditSubcategories = $auditSubcategories
EventForwardingPolicy = Test-Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\EventLog\EventForwarding\SubscriptionManager'
}
+3 -1
View File
@@ -39,7 +39,8 @@ foreach ($scriptName in @(
'Install-SguEnrollmentGuard.ps1', 'Install-SguEnrollmentGuard.ps1',
'Test-SguClientEnrollment.ps1', 'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1', 'Repair-SguClientEnrollment.ps1',
'Enable-LabRemoteAccess.ps1')) { 'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1')) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) { if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) {
throw "$scriptName must be beside Enroll-SguDomainClient.ps1." throw "$scriptName must be beside Enroll-SguDomainClient.ps1."
} }
@@ -96,6 +97,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
& (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') ` & (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') `
-RemoteDesktopPrincipal $RemoteDesktopPrincipal ` -RemoteDesktopPrincipal $RemoteDesktopPrincipal `
-EnableAdministrativeFirewallGroups | Out-Null -EnableAdministrativeFirewallGroups | Out-Null
& (Join-Path $PSScriptRoot 'Enable-SguClientMonitoring.ps1') | Out-Null
return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') ` return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
-RequireDomainJoined ` -RequireDomainJoined `
-RequireRemoteAccess ` -RequireRemoteAccess `
+81
View File
@@ -0,0 +1,81 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[datetime]$Since = (Get-Date).AddDays(-183),
[datetime]$Until = (Get-Date),
[string]$UserName,
[ValidateSet('Critical','Error','Warning','Information','Verbose')]
[string]$Level,
[int[]]$EventId,
[string]$Text,
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[string]$BrokerEventLogName = 'SGU Auth Broker',
[string]$OutputCsv
)
$ErrorActionPreference = 'Stop'
$events = [Collections.Generic.List[object]]::new()
$eventNames = @{
900 = 'BrokerStarted'
1000 = 'AuthenticationAuthorized'
1001 = 'AuthenticationRejected'
1002 = 'AuthenticationUnavailable'
1003 = 'AuthenticationInvalidRequest'
1100 = 'SguAuthenticationAccepted'
1101 = 'SguAuthenticationTimeout'
1102 = 'SguAuthenticationNetworkFailure'
1200 = 'ProfileEnrichmentCompleted'
1201 = 'ProfileHtmlUnexpected'
1202 = 'ProfileEnrichmentTimeout'
1203 = 'ProfileEnrichmentFailure'
1204 = 'ProfilePageUnavailable'
1300 = 'DirectorySynchronizationFailure'
1301 = 'DirectoryOptionalMetadataFailure'
1302 = 'DirectoryGroupMembershipFailure'
}
# Keep these reads unfiltered. Besides making archived and current logs behave
# identically, this avoids the Windows Server 2025 ForwardedEvents query defect.
if (Get-WinEvent -ListLog $BrokerEventLogName -ErrorAction SilentlyContinue) {
Get-WinEvent -LogName $BrokerEventLogName -ErrorAction SilentlyContinue |
Where-Object { $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
ForEach-Object { $events.Add($_) }
}
$brokerArchiveRoot = Join-Path $MonitoringRoot 'Archive\Broker'
Get-ChildItem -LiteralPath $brokerArchiveRoot -Filter '*.evtx' -File -ErrorAction SilentlyContinue |
Where-Object LastWriteTime -ge $Since.AddDays(-1) |
ForEach-Object {
try {
Get-WinEvent -Path $_.FullName -Oldest -ErrorAction Stop |
Where-Object { $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
ForEach-Object { $events.Add($_) }
}
catch {
Write-Warning "Could not read broker archive $($_.FullName): $($_.Exception.Message)"
}
}
$result = @($events | Where-Object {
(-not $UserName -or $_.Message -like "*$UserName*") -and
(-not $Level -or $_.LevelDisplayName -eq $Level) -and
(-not $EventId -or $_.Id -in $EventId) -and
(-not $Text -or $_.Message -like "*$Text*")
} | Sort-Object TimeCreated -Descending | ForEach-Object {
[pscustomobject]@{
TimeCreated = $_.TimeCreated
Level = $_.LevelDisplayName
EventId = $_.Id
EventName = $eventNames[[int]$_.Id]
Provider = $_.ProviderName
Message = $_.Message
}
})
if ($OutputCsv) {
$resolvedOutput = [IO.Path]::GetFullPath($OutputCsv)
New-Item -ItemType Directory -Path (Split-Path $resolvedOutput -Parent) -Force | Out-Null
$result | Export-Csv -LiteralPath $resolvedOutput -NoTypeInformation -Encoding UTF8
}
$result
+176
View File
@@ -0,0 +1,176 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[datetime]$Since = (Get-Date).AddDays(-183),
[datetime]$Until = (Get-Date),
[string]$UserName,
[string]$ComputerName,
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[string]$OutputCsv
)
$ErrorActionPreference = 'Stop'
$eventIds = @(4624,4625,4634,4647,4778,4779,6005,6006,6008)
$events = [Collections.Generic.List[object]]::new()
try {
# Windows Server 2025 can crash the Windows Event Log service when a
# structured query is evaluated against ForwardedEvents (wevtsvc.dll,
# exception 0xc0000420). Read the channel without a server-side query and
# apply every predicate in this process instead.
Get-WinEvent -LogName 'ForwardedEvents' -ErrorAction Stop |
Where-Object {
$_.Id -in $eventIds -and
$_.TimeCreated -ge $Since -and
$_.TimeCreated -le $Until
} |
ForEach-Object { $events.Add($_) }
}
catch [System.Exception] {
if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') {
Write-Verbose $_.Exception.Message
}
}
$archiveRoot = Join-Path $MonitoringRoot 'Archive'
Get-ChildItem -LiteralPath $archiveRoot -Filter '*.evtx' -File -ErrorAction SilentlyContinue |
Where-Object LastWriteTime -ge $Since.AddDays(-1) |
ForEach-Object {
try {
Get-WinEvent -Path $_.FullName -Oldest -ErrorAction Stop |
Where-Object { $_.Id -in $eventIds -and $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
ForEach-Object { $events.Add($_) }
}
catch {
Write-Warning "Could not read archive $($_.FullName): $($_.Exception.Message)"
}
}
function Get-EventData {
param([Parameter(Mandatory)]$EventRecord)
$xml = [xml]$EventRecord.ToXml()
$data = @{}
foreach ($item in @($xml.Event.EventData.Data)) {
if ($item.Name) {
$data[[string]$item.Name] = [string]$item.'#text'
}
}
[pscustomobject]@{
Computer = [string]$xml.Event.System.Computer
Data = $data
}
}
$openSessions = @{}
$rows = [Collections.Generic.List[object]]::new()
$ignoredUsers = @('ANONYMOUS LOGON','DWM-1','DWM-2','DWM-3','LOCAL SERVICE','NETWORK SERVICE','SYSTEM','UMFD-0','UMFD-1','UMFD-2','UMFD-3')
foreach ($eventRecord in @($events | Sort-Object TimeCreated,RecordId)) {
$parsed = Get-EventData -EventRecord $eventRecord
$machine = ($parsed.Computer -split '\.')[0].ToUpperInvariant()
$data = $parsed.Data
if ($eventRecord.Id -in 6005,6006,6008) {
foreach ($key in @($openSessions.Keys | Where-Object { $_ -like "$machine|*" })) {
$session = $openSessions[$key]
$rows.Add([pscustomobject]@{
User = $session.User
Computer = $machine
StartedAt = $session.StartedAt
EndedAt = $eventRecord.TimeCreated
Duration = $eventRecord.TimeCreated - $session.StartedAt
DurationMinutes = [math]::Round(($eventRecord.TimeCreated - $session.StartedAt).TotalMinutes, 2)
LogonType = $session.LogonType
Result = 'Interrumpida por apagado o reinicio'
FailureStatus = $null
})
$openSessions.Remove($key)
}
continue
}
if ($eventRecord.Id -eq 4625) {
$failedUser = [string]$data.TargetUserName
if ($failedUser -and $failedUser -notlike '*$' -and $failedUser.ToUpperInvariant() -notin $ignoredUsers) {
$rows.Add([pscustomobject]@{
User = if ($data.TargetDomainName) { "$($data.TargetDomainName)\$failedUser" } else { $failedUser }
Computer = $machine
StartedAt = $eventRecord.TimeCreated
EndedAt = $eventRecord.TimeCreated
Duration = [timespan]::Zero
DurationMinutes = 0
LogonType = [string]$data.LogonType
Result = 'Fallida'
FailureStatus = "$($data.Status)/$($data.SubStatus)"
})
}
continue
}
if ($eventRecord.Id -eq 4624) {
$logonType = [string]$data.LogonType
$targetUser = [string]$data.TargetUserName
if ($logonType -notin @('2','10','11') -or -not $targetUser -or $targetUser -like '*$' -or
$targetUser.ToUpperInvariant() -in $ignoredUsers) {
continue
}
$logonId = [string]$data.TargetLogonId
$key = "$machine|$logonId"
$openSessions[$key] = [pscustomobject]@{
User = if ($data.TargetDomainName) { "$($data.TargetDomainName)\$targetUser" } else { $targetUser }
StartedAt = $eventRecord.TimeCreated
LogonType = $logonType
}
continue
}
if ($eventRecord.Id -in 4634,4647) {
$logonId = if ($eventRecord.Id -eq 4634) { [string]$data.TargetLogonId } else { [string]$data.SubjectLogonId }
$key = "$machine|$logonId"
if ($openSessions.ContainsKey($key)) {
$session = $openSessions[$key]
$rows.Add([pscustomobject]@{
User = $session.User
Computer = $machine
StartedAt = $session.StartedAt
EndedAt = $eventRecord.TimeCreated
Duration = $eventRecord.TimeCreated - $session.StartedAt
DurationMinutes = [math]::Round(($eventRecord.TimeCreated - $session.StartedAt).TotalMinutes, 2)
LogonType = $session.LogonType
Result = 'Completada'
FailureStatus = $null
})
$openSessions.Remove($key)
}
}
}
foreach ($key in $openSessions.Keys) {
$session = $openSessions[$key]
$machine = ($key -split '\|', 2)[0]
$rows.Add([pscustomobject]@{
User = $session.User
Computer = $machine
StartedAt = $session.StartedAt
EndedAt = $null
Duration = $Until - $session.StartedAt
DurationMinutes = [math]::Round(($Until - $session.StartedAt).TotalMinutes, 2)
LogonType = $session.LogonType
Result = 'Sesión posiblemente activa'
FailureStatus = $null
})
}
$result = @($rows | Where-Object {
(-not $UserName -or $_.User -like "*$UserName*") -and
(-not $ComputerName -or $_.Computer -like "*$ComputerName*")
} | Sort-Object StartedAt -Descending)
if ($OutputCsv) {
$resolvedOutput = [IO.Path]::GetFullPath($OutputCsv)
New-Item -ItemType Directory -Path (Split-Path $resolvedOutput -Parent) -Force | Out-Null
$result | Export-Csv -LiteralPath $resolvedOutput -NoTypeInformation -Encoding UTF8
}
$result
+18 -2
View File
@@ -348,6 +348,10 @@ foreach ($requiredPath in @(
(Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'), (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'),
(Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'), (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'),
(Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'), (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'),
(Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1'),
(Join-Path $scriptsRoot 'Invoke-SguMonitoringMaintenance.ps1'),
(Join-Path $scriptsRoot 'Get-SguUsageReport.ps1'),
(Join-Path $scriptsRoot 'Get-SguBrokerLog.ps1'),
(Join-Path $brokerPublishPath 'SGU.AuthBroker.exe'))) { (Join-Path $brokerPublishPath 'SGU.AuthBroker.exe'))) {
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
throw "The server bootstrap package is incomplete: $requiredPath" throw "The server bootstrap package is incomplete: $requiredPath"
@@ -606,8 +610,15 @@ else {
-RemoteAddress $privateSubnet | Out-Null -RemoteAddress $privateSubnet | Out-Null
} }
$collectorFqdn = "$env:COMPUTERNAME.$DomainName"
& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') ` & (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') `
-TargetOuDn $laboratoryOuDn -DomainController $env:COMPUTERNAME | Out-Null -TargetOuDn $laboratoryOuDn `
-DomainController $env:COMPUTERNAME `
-EventCollectorFqdn $collectorFqdn | Out-Null
& (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1') `
-CollectorFqdn $collectorFqdn `
-ComputerOuDn $laboratoryOuDn `
-RetentionDays 183 | Out-Null
$userPolicyParameters = @{ $userPolicyParameters = @{
TargetOuDn = $usersOuDn TargetOuDn = $usersOuDn
DomainController = $env:COMPUTERNAME DomainController = $env:COMPUTERNAME
@@ -632,6 +643,9 @@ $validation = [ordered]@{
BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue) BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue)
WinRM = (Get-Service WinRM).Status.ToString() WinRM = (Get-Service WinRM).Status.ToString()
RemoteDesktop = (Get-Service TermService).Status.ToString() RemoteDesktop = (Get-Service TermService).Status.ToString()
EventCollector = (Get-Service Wecsvc).Status.ToString()
EventSubscription = @(& wecutil.exe enum-subscription) -contains 'SGU-Lab-Monitoring'
MonitoringRetentionDays = 183
PackageShare = "\\$env:COMPUTERNAME\Packages" PackageShare = "\\$env:COMPUTERNAME\Packages"
LaboratoryOu = $laboratoryOuDn LaboratoryOu = $laboratoryOuDn
UsersOu = $usersOuDn UsersOu = $usersOuDn
@@ -643,7 +657,9 @@ $validation = [ordered]@{
if ($validation.BrokerService -ne 'Running' -or if ($validation.BrokerService -ne 'Running' -or
-not $validation.BrokerPortListening -or -not $validation.BrokerPortListening -or
$validation.WinRM -ne 'Running' -or $validation.WinRM -ne 'Running' -or
$validation.RemoteDesktop -ne 'Running') { $validation.RemoteDesktop -ne 'Running' -or
$validation.EventCollector -ne 'Running' -or
-not $validation.EventSubscription) {
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.' throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
} }
+70
View File
@@ -35,6 +35,8 @@ $providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authent
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32" $classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
$defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' $interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
$accountPictureSourcePath = Join-Path $PublishPath 'branding\user.png'
$accountPictureDirectory = Join-Path $env:ProgramData 'Microsoft\User Account Pictures'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent() $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity) $principal = [Security.Principal.WindowsPrincipal]::new($identity)
@@ -56,6 +58,70 @@ function Test-DotNet10Runtime {
return $false return $false
} }
function Install-DefaultAccountPicture {
param([Parameter(Mandatory)][string]$SourcePath)
if (-not (Test-Path -LiteralPath $SourcePath -PathType Leaf)) {
return $false
}
Add-Type -AssemblyName System.Drawing
New-Item -ItemType Directory -Path $accountPictureDirectory -Force | Out-Null
function Save-AccountPicture {
param(
[Parameter(Mandatory)][Drawing.Image]$Image,
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][Drawing.Imaging.ImageFormat]$Format
)
$stream = [IO.MemoryStream]::new()
try {
$Image.Save($stream, $Format)
[IO.File]::WriteAllBytes($Path, $stream.ToArray())
}
finally {
$stream.Dispose()
}
}
$source = [Drawing.Image]::FromFile($SourcePath)
try {
foreach ($size in @(192, 48, 40, 32)) {
$bitmap = [Drawing.Bitmap]::new($size, $size)
try {
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.Clear([Drawing.Color]::Transparent)
$graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.DrawImage($source, [Drawing.Rectangle]::new(0, 0, $size, $size))
Save-AccountPicture -Image $bitmap `
-Path (Join-Path $accountPictureDirectory "user-$size.png") `
-Format ([Drawing.Imaging.ImageFormat]::Png)
}
finally {
$graphics.Dispose()
}
}
finally {
$bitmap.Dispose()
}
}
Save-AccountPicture -Image $source `
-Path (Join-Path $accountPictureDirectory 'user.png') `
-Format ([Drawing.Imaging.ImageFormat]::Png)
Save-AccountPicture -Image $source `
-Path (Join-Path $accountPictureDirectory 'user.bmp') `
-Format ([Drawing.Imaging.ImageFormat]::Bmp)
}
finally {
$source.Dispose()
}
return $true
}
if (-not (Test-DotNet10Runtime)) { if (-not (Test-DotNet10Runtime)) {
if (-not $InstallDotNetRuntime) { if (-not $InstallDotNetRuntime) {
throw 'Microsoft .NET 10 x64 runtime is required. Re-run with -InstallDotNetRuntime or install it first.' throw 'Microsoft .NET 10 x64 runtime is required. Re-run with -InstallDotNetRuntime or install it first.'
@@ -166,6 +232,10 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
[IO.File]::WriteAllText($completeMarker, $packageHash, [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllText($completeMarker, $packageHash, [Text.UTF8Encoding]::new($false))
} }
# The domain GPO selects the Windows default account picture. Install its
# branded bitmap during enrollment so no per-machine manual setup is needed.
Install-DefaultAccountPicture -SourcePath $accountPictureSourcePath | Out-Null
New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
$settingsJson = @{ $settingsJson = @{
BrokerEndpoint = $BrokerEndpoint BrokerEndpoint = $BrokerEndpoint
+147
View File
@@ -0,0 +1,147 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$CollectorFqdn = "$env:COMPUTERNAME.$env:USERDNSDOMAIN",
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[ValidateRange(30, 730)]
[int]$RetentionDays = 183
)
$ErrorActionPreference = 'Stop'
$subscriptionId = 'SGU-Lab-Monitoring'
$maintenanceScriptName = 'Invoke-SguMonitoringMaintenance.ps1'
$reportScriptName = 'Get-SguUsageReport.ps1'
$brokerReportScriptName = 'Get-SguBrokerLog.ps1'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated Windows PowerShell session on the domain event collector.'
}
Import-Module ActiveDirectory -ErrorAction Stop
Get-ADOrganizationalUnit -Identity $ComputerOuDn -ErrorAction Stop | Out-Null
foreach ($requiredScript in $maintenanceScriptName,$reportScriptName,$brokerReportScriptName) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $requiredScript) -PathType Leaf)) {
throw "$requiredScript must be beside Install-SguDomainMonitoring.ps1."
}
}
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install the SGU domain monitoring collector')) {
Set-Service EventLog -StartupType Automatic
if ((Get-Service EventLog).Status -ne 'Running') {
Start-Service EventLog
}
& wecutil.exe quick-config /quiet
if ($LASTEXITCODE -ne 0) {
throw "wecutil quick-config failed with exit code $LASTEXITCODE."
}
Set-Service Wecsvc -StartupType Automatic
Start-Service Wecsvc
& wevtutil.exe set-log ForwardedEvents /enabled:true /maxsize:536870912 /retention:false /autobackup:false
if ($LASTEXITCODE -ne 0) {
throw "wevtutil failed to configure ForwardedEvents with exit code $LASTEXITCODE."
}
$query = @'
<QueryList>
<Query Id="0">
<Select Path="Security">*[System[(EventID=4624 or EventID=4625 or EventID=4634 or EventID=4647 or EventID=4778 or EventID=4779)]]</Select>
<Select Path="System">*[System[(EventID=12 or EventID=13 or EventID=41 or EventID=1074 or EventID=6005 or EventID=6006 or EventID=6008)]]</Select>
</Query>
</QueryList>
'@
$escapedQuery = [Security.SecurityElement]::Escape($query)
$subscriptionXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription">
<SubscriptionId>$subscriptionId</SubscriptionId>
<SubscriptionType>SourceInitiated</SubscriptionType>
<Description>SGU interactive sessions, failures, reconnects, and workstation power state.</Description>
<Enabled>true</Enabled>
<Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri>
<ConfigurationMode>Custom</ConfigurationMode>
<Delivery Mode="Push">
<Batching><MaxItems>5</MaxItems><MaxLatencyTime>30000</MaxLatencyTime></Batching>
<PushSettings><Heartbeat Interval="60000"/></PushSettings>
</Delivery>
<Query>$escapedQuery</Query>
<ReadExistingEvents>false</ReadExistingEvents>
<TransportName>HTTP</TransportName>
<ContentFormat>Events</ContentFormat>
<Locale Language="es-MX"/>
<LogFile>ForwardedEvents</LogFile>
<AllowedSourceDomainComputers>O:NSG:NSD:(A;;GA;;;DC)(A;;GA;;;NS)</AllowedSourceDomainComputers>
</Subscription>
"@
New-Item -ItemType Directory -Path $MonitoringRoot -Force | Out-Null
$subscriptionPath = Join-Path $MonitoringRoot 'SGU-Lab-Monitoring.xml'
[IO.File]::WriteAllText($subscriptionPath, $subscriptionXml, [Text.UTF8Encoding]::new($true))
$existingSubscriptions = @(& wecutil.exe enum-subscription 2>$null)
if ($existingSubscriptions -contains $subscriptionId) {
& wecutil.exe delete-subscription $subscriptionId
if ($LASTEXITCODE -ne 0) {
throw "Could not replace the existing $subscriptionId subscription."
}
}
& wecutil.exe create-subscription $subscriptionPath
if ($LASTEXITCODE -ne 0) {
throw "Could not create the $subscriptionId subscription."
}
foreach ($scriptName in $maintenanceScriptName,$reportScriptName,$brokerReportScriptName) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $scriptName) `
-Destination (Join-Path $MonitoringRoot $scriptName) -Force
}
$configuration = [ordered]@{
CollectorFqdn = $CollectorFqdn
ComputerOuDn = $ComputerOuDn
RetentionDays = $RetentionDays
SubscriptionId = $subscriptionId
}
[IO.File]::WriteAllText(
(Join-Path $MonitoringRoot 'monitoring.json'),
($configuration | ConvertTo-Json),
[Text.UTF8Encoding]::new($false))
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$maintenanceScript = Join-Path $MonitoringRoot $maintenanceScriptName
$inventoryAction = New-ScheduledTaskAction -Execute $powerShell -Argument (
"-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$maintenanceScript`" " +
"-MonitoringRoot `"$MonitoringRoot`" -ComputerOuDn `"$ComputerOuDn`" -RetentionDays $RetentionDays -InventoryOnly")
$inventoryTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes 5) `
-RepetitionDuration (New-TimeSpan -Days 3650)
$taskSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 2 `
-RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName 'SGU-Monitoring-Inventory' -Action $inventoryAction `
-Trigger $inventoryTrigger -Settings $taskSettings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
$retentionAction = New-ScheduledTaskAction -Execute $powerShell -Argument (
"-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$maintenanceScript`" " +
"-MonitoringRoot `"$MonitoringRoot`" -ComputerOuDn `"$ComputerOuDn`" -RetentionDays $RetentionDays")
$retentionTrigger = New-ScheduledTaskTrigger -Daily -At '12:10 AM'
Register-ScheduledTask -TaskName 'SGU-Monitoring-Retention' -Action $retentionAction `
-Trigger $retentionTrigger -Settings $taskSettings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
& $maintenanceScript -MonitoringRoot $MonitoringRoot -ComputerOuDn $ComputerOuDn `
-RetentionDays $RetentionDays -InventoryOnly | Out-Null
}
[pscustomobject]@{
Collector = $CollectorFqdn
CollectorService = (Get-Service Wecsvc).Status.ToString()
SubscriptionId = $subscriptionId
SubscriptionEnabled = @(& wecutil.exe enum-subscription) -contains $subscriptionId
RetentionDays = $RetentionDays
InventoryTask = (Get-ScheduledTask -TaskName 'SGU-Monitoring-Inventory').State
RetentionTask = (Get-ScheduledTask -TaskName 'SGU-Monitoring-Retention').State
MachineStatusPath = Join-Path $MonitoringRoot 'Reports\machine-status.json'
UsageReportCommand = "& '$MonitoringRoot\$reportScriptName'"
BrokerLogCommand = "& '$MonitoringRoot\$brokerReportScriptName'"
}
+1
View File
@@ -28,6 +28,7 @@ $enrollmentRoot = Join-Path $env:ProgramData 'SGU\Enrollment'
$sourceScripts = @( $sourceScripts = @(
'Install-CredentialProvider.ps1', 'Install-CredentialProvider.ps1',
'Enable-LabRemoteAccess.ps1', 'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Test-SguClientEnrollment.ps1', 'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1' 'Repair-SguClientEnrollment.ps1'
) )
+111
View File
@@ -0,0 +1,111 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[ValidateRange(30, 730)]
[int]$RetentionDays = 183,
[string]$BrokerEventLogName = 'SGU Auth Broker',
[switch]$InventoryOnly
)
$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory -ErrorAction Stop
foreach ($serviceName in 'EventLog','Wecsvc') {
Set-Service -Name $serviceName -StartupType Automatic
if ((Get-Service $serviceName).Status -ne 'Running') {
Start-Service $serviceName
}
}
$archiveRoot = Join-Path $MonitoringRoot 'Archive'
$brokerArchiveRoot = Join-Path $archiveRoot 'Broker'
$reportRoot = Join-Path $MonitoringRoot 'Reports'
New-Item -ItemType Directory -Path $archiveRoot,$brokerArchiveRoot,$reportRoot -Force | Out-Null
function Test-TcpEndpoint {
param(
[Parameter(Mandatory)][string]$ComputerName,
[int]$Port = 5985,
[int]$TimeoutMilliseconds = 900
)
$client = [Net.Sockets.TcpClient]::new()
try {
$pending = $client.BeginConnect($ComputerName, $Port, $null, $null)
if (-not $pending.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
return $false
}
$client.EndConnect($pending)
return $true
}
catch {
return $false
}
finally {
$client.Dispose()
}
}
if (-not $InventoryOnly) {
$forwardedLog = Get-WinEvent -ListLog ForwardedEvents -ErrorAction Stop
if ($forwardedLog.RecordCount -gt 0) {
$archivePath = Join-Path $archiveRoot ("ForwardedEvents-{0:yyyyMMdd-HHmmss}.evtx" -f (Get-Date))
& wevtutil.exe clear-log ForwardedEvents "/backup:$archivePath"
if ($LASTEXITCODE -ne 0) {
throw "Could not archive ForwardedEvents; wevtutil returned exit code $LASTEXITCODE."
}
}
$brokerLog = Get-WinEvent -ListLog $BrokerEventLogName -ErrorAction SilentlyContinue
if ($brokerLog -and $brokerLog.RecordCount -gt 0) {
$brokerArchivePath = Join-Path $brokerArchiveRoot ("SguAuthBroker-{0:yyyyMMdd-HHmmss}.evtx" -f (Get-Date))
& wevtutil.exe clear-log $BrokerEventLogName "/backup:$brokerArchivePath"
if ($LASTEXITCODE -ne 0) {
throw "Could not archive $BrokerEventLogName; wevtutil returned exit code $LASTEXITCODE."
}
}
$cutoff = (Get-Date).AddDays(-$RetentionDays)
Get-ChildItem -LiteralPath $archiveRoot -Filter '*.evtx' -File -Recurse -ErrorAction SilentlyContinue |
Where-Object LastWriteTime -lt $cutoff |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force }
}
$computers = @(Get-ADComputer -SearchBase $ComputerOuDn -SearchScope Subtree -Filter * `
-Properties DNSHostName,IPv4Address,OperatingSystem,LastLogonDate,Enabled |
Sort-Object Name)
$inventory = @(foreach ($computer in $computers) {
$target = if ($computer.DNSHostName) { $computer.DNSHostName } else { $computer.Name }
$online = Test-TcpEndpoint -ComputerName $target
[pscustomobject]@{
ComputerName = $computer.Name
DNSHostName = $computer.DNSHostName
IPv4Address = $computer.IPv4Address
OperatingSystem = $computer.OperatingSystem
Enabled = [bool]$computer.Enabled
Status = if ($online) { 'Encendida' } else { 'Apagada o inaccesible' }
WinRMReachable = [bool]$online
LastDomainLogon = if ($computer.LastLogonDate) {
$computer.LastLogonDate.ToUniversalTime().ToString('o')
} else { $null }
CheckedAt = (Get-Date).ToUniversalTime().ToString('o')
}
})
$jsonPath = Join-Path $reportRoot 'machine-status.json'
$csvPath = Join-Path $reportRoot 'machine-status.csv'
[IO.File]::WriteAllText($jsonPath, (ConvertTo-Json -InputObject $inventory -Depth 4), [Text.UTF8Encoding]::new($false))
$inventory | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
[pscustomobject]@{
CheckedAt = (Get-Date).ToUniversalTime().ToString('o')
ComputerCount = @($inventory).Count
OnlineCount = @($inventory | Where-Object WinRMReachable).Count
OfflineCount = @($inventory | Where-Object { -not $_.WinRMReachable }).Count
RetentionDays = $RetentionDays
StatusJson = $jsonPath
StatusCsv = $csvPath
}
+7
View File
@@ -96,6 +96,7 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cm
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd') -Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
$clientScripts = @( $clientScripts = @(
'Enable-LabRemoteAccess.ps1', 'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Enroll-SguDomainClient.ps1', 'Enroll-SguDomainClient.ps1',
'Install-CredentialProvider.ps1', 'Install-CredentialProvider.ps1',
'Install-SguEnrollmentGuard.ps1', 'Install-SguEnrollmentGuard.ps1',
@@ -111,6 +112,8 @@ Copy-Item -Path (Join-Path $providerOutput '*') `
-Destination (New-Item -ItemType Directory ` -Destination (New-Item -ItemType Directory `
-Path (Join-Path $clientRoot 'payload\credential-provider') -Force).FullName ` -Path (Join-Path $clientRoot 'payload\credential-provider') -Force).FullName `
-Recurse -Force -Recurse -Force
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'assets\branding\lasalle-mascot-account.png') `
-Destination (Join-Path $clientRoot 'payload\credential-provider\branding\user.png')
Copy-RequiredFile -Source $runtimeInstaller.FullName ` Copy-RequiredFile -Source $runtimeInstaller.FullName `
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)") -Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
@@ -124,6 +127,10 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd
$serverScripts = @( $serverScripts = @(
'Deploy-AuthBroker.ps1', 'Deploy-AuthBroker.ps1',
'Enable-SguServerRemoteManagement.ps1', 'Enable-SguServerRemoteManagement.ps1',
'Get-SguUsageReport.ps1',
'Get-SguBrokerLog.ps1',
'Install-SguDomainMonitoring.ps1',
'Invoke-SguMonitoringMaintenance.ps1',
'New-LabCertificate.ps1', 'New-LabCertificate.ps1',
'Register-SguClientCertificate.ps1', 'Register-SguClientCertificate.ps1',
'Set-LabBrokerDns.ps1', 'Set-LabBrokerDns.ps1',
+1
View File
@@ -100,6 +100,7 @@ Bootstrap reproducible para el laboratorio SGU.
- **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema. - **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema.
- `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-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-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.
- 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. - Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`. Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`.
+2
View File
@@ -9,6 +9,7 @@ $enrollmentRoot = Split-Path $ConfigurationPath -Parent
$testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1' $testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1'
$installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1' $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1' $remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
$before = & $testScript $before = & $testScript
if (-not $before.IsValid) { if (-not $before.IsValid) {
@@ -32,6 +33,7 @@ if ($computer.PartOfDomain) {
& $remoteAccessScript ` & $remoteAccessScript `
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) ` -RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
-EnableAdministrativeFirewallGroups | Out-Null -EnableAdministrativeFirewallGroups | Out-Null
& $monitoringScript | Out-Null
} }
$verificationParams = @{} $verificationParams = @{}
+19 -2
View File
@@ -2,7 +2,8 @@
param( param(
[string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx', [string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[string]$GpoName = 'SGU - Windows client experience', [string]$GpoName = 'SGU - Windows client experience',
[string]$DomainController = $env:COMPUTERNAME [string]$DomainController = $env:COMPUTERNAME,
[string]$EventCollectorFqdn
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -24,6 +25,14 @@ if (-not $domainDn) {
throw 'TargetOuDn does not contain a domain distinguished name.' throw 'TargetOuDn does not contain a domain distinguished name.'
} }
$domainName = ($domainDn -replace ',DC=', '.') $domainName = ($domainDn -replace ',DC=', '.')
if (-not $EventCollectorFqdn) {
$collectorComputer = Get-ADComputer -Identity $DomainController -Properties DNSHostName `
-Server $DomainController -ErrorAction Stop
$EventCollectorFqdn = $collectorComputer.DNSHostName
}
if (-not $EventCollectorFqdn) {
throw 'Could not determine the event collector FQDN.'
}
$gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue $gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue
if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU Windows client policy GPO')) { if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU Windows client policy GPO')) {
@@ -64,6 +73,8 @@ $powerPolicyRoot = 'HKLM\Software\Policies\Microsoft\Power\PowerSettings'
$credentialProviderPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\System' $credentialProviderPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\System'
$interactiveLogonPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System' $interactiveLogonPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System'
$accountPicturePolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' $accountPicturePolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer'
$eventForwardingPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\EventForwarding\SubscriptionManager'
$auditPolicyKey = 'HKLM\System\CurrentControlSet\Control\Lsa'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}' $providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$policies = @( $policies = @(
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 }, @{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 },
@@ -83,7 +94,12 @@ $policies = @(
# Use Windows' native default account image for named user tiles. LogonUI # Use Windows' native default account image for named user tiles. LogonUI
# retains ownership of the anonymous Other user tile and its circular mask. # retains ownership of the anonymous Other user tile and its circular mask.
@{ Key = $accountPicturePolicyKey; Name = 'UseDefaultTile'; Type = 'DWord'; Value = 1 } @{ Key = $accountPicturePolicyKey; Name = 'UseDefaultTile'; Type = 'DWord'; Value = 1 },
# Source-initiated Windows Event Forwarding. Kerberos authenticates domain
# computers to the collector; no SGU password or reusable secret is logged.
@{ Key = $eventForwardingPolicyKey; Name = '1'; Type = 'String'; Value = "Server=http://${EventCollectorFqdn}:5985/wsman/SubscriptionManager/WEC,Refresh=300" },
@{ Key = $auditPolicyKey; Name = 'SCENoApplyLegacyAuditPolicy'; Type = 'DWord'; Value = 1 }
) )
$powerSettingIds = @( $powerSettingIds = @(
@@ -134,5 +150,6 @@ $linkEnabled = $link -and (
TargetOu = $TargetOuDn TargetOu = $TargetOuDn
LinkEnabled = [bool]$linkEnabled LinkEnabled = [bool]$linkEnabled
PolicyCount = $configuredPolicies.Count PolicyCount = $configuredPolicies.Count
EventCollector = $EventCollectorFqdn
Policies = [pscustomobject]$configuredPolicies Policies = [pscustomobject]$configuredPolicies
} }
+24
View File
@@ -0,0 +1,24 @@
namespace SGU.AuthBroker;
internal static class BrokerEventIds
{
internal static readonly EventId BrokerStarted = new(900, nameof(BrokerStarted));
internal static readonly EventId AuthenticationAuthorized = new(1000, nameof(AuthenticationAuthorized));
internal static readonly EventId AuthenticationRejected = new(1001, nameof(AuthenticationRejected));
internal static readonly EventId AuthenticationUnavailable = new(1002, nameof(AuthenticationUnavailable));
internal static readonly EventId AuthenticationInvalidRequest = new(1003, nameof(AuthenticationInvalidRequest));
internal static readonly EventId SguAuthenticationAccepted = new(1100, nameof(SguAuthenticationAccepted));
internal static readonly EventId SguAuthenticationTimeout = new(1101, nameof(SguAuthenticationTimeout));
internal static readonly EventId SguAuthenticationNetworkFailure = new(1102, nameof(SguAuthenticationNetworkFailure));
internal static readonly EventId ProfileEnrichmentCompleted = new(1200, nameof(ProfileEnrichmentCompleted));
internal static readonly EventId ProfileHtmlUnexpected = new(1201, nameof(ProfileHtmlUnexpected));
internal static readonly EventId ProfileEnrichmentTimeout = new(1202, nameof(ProfileEnrichmentTimeout));
internal static readonly EventId ProfileEnrichmentFailure = new(1203, nameof(ProfileEnrichmentFailure));
internal static readonly EventId ProfilePageUnavailable = new(1204, nameof(ProfilePageUnavailable));
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));
}
+68
View File
@@ -1,7 +1,9 @@
using System.Diagnostics;
using System.Net.Security; using System.Net.Security;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.AspNetCore.Server.Kestrel.Https;
using SGU.AuthBroker;
using SGU.AuthBroker.Contracts; using SGU.AuthBroker.Contracts;
using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Directory; using SGU.AuthBroker.Core.Directory;
@@ -10,6 +12,16 @@ using SGU.AuthBroker.Services;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker"); builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker");
if (builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false))
{
builder.Logging.ClearProviders();
builder.Logging.AddEventLog(settings =>
{
settings.LogName = "SGU Auth Broker";
settings.SourceName = "SGU.AuthBroker.Operational";
settings.Filter = (_, level) => level >= LogLevel.Information;
});
}
BrokerOptions brokerOptions = builder.Configuration BrokerOptions brokerOptions = builder.Configuration
.GetSection(BrokerOptions.SectionName) .GetSection(BrokerOptions.SectionName)
@@ -56,6 +68,12 @@ builder.Services.AddRateLimiter(options =>
}); });
WebApplication app = builder.Build(); WebApplication app = builder.Build();
ILogger auditLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("SGU.AuthBroker.Audit");
auditLogger.LogInformation(
BrokerEventIds.BrokerStarted,
"SGU Authentication Broker started with dedicated operational diagnostics enabled={DedicatedDiagnosticsEnabled}.",
builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false));
app.UseRateLimiter(); app.UseRateLimiter();
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
@@ -75,16 +93,58 @@ app.MapPost("/v1/authenticate", async (
{ {
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256) if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256)
{ {
auditLogger.LogInformation(
BrokerEventIds.AuthenticationInvalidRequest,
"Authentication request rejected before validation for {InstitutionalUser}: password was missing or outside the supported length.",
SafeUserName(request.Clave));
request.ReleasePasswordReference(); request.ReleasePasswordReference();
return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida.")); return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida."));
} }
Stopwatch elapsed = Stopwatch.StartNew();
try try
{ {
AuthenticationFlowResult result = await workflow AuthenticationFlowResult result = await workflow
.AuthenticateAsync(request.Clave, request.Password, cancellationToken) .AuthenticateAsync(request.Clave, request.Password, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
string institutionalUser = result.Identity?.UserName ?? SafeUserName(request.Clave);
switch (result.Outcome)
{
case AuthenticationFlowOutcome.Authorized:
auditLogger.LogInformation(
BrokerEventIds.AuthenticationAuthorized,
"Authentication completed for {InstitutionalUser} with role {Role} in {ElapsedMilliseconds} ms. AD created={Created}; moved={Moved}.",
institutionalUser,
result.Identity!.Role,
elapsed.ElapsedMilliseconds,
result.Directory!.Created,
result.Directory.Moved);
break;
case AuthenticationFlowOutcome.InvalidCredentials:
auditLogger.LogInformation(
BrokerEventIds.AuthenticationRejected,
"Authentication was rejected for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
institutionalUser,
result.ErrorCode,
elapsed.ElapsedMilliseconds);
break;
case AuthenticationFlowOutcome.Unavailable:
auditLogger.LogWarning(
BrokerEventIds.AuthenticationUnavailable,
"Authentication was unavailable for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
institutionalUser,
result.ErrorCode,
elapsed.ElapsedMilliseconds);
break;
default:
auditLogger.LogInformation(
BrokerEventIds.AuthenticationInvalidRequest,
"Authentication request had an invalid institutional user format after {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds);
break;
}
return result.Outcome switch return result.Outcome switch
{ {
AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse( AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse(
@@ -125,3 +185,11 @@ static IResult Unavailable(HttpContext context, string? errorCode)
static string NormalizeThumbprint(string value) => static string NormalizeThumbprint(string value) =>
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant(); value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
static string SafeUserName(string? value)
{
string candidate = value?.Trim().ToUpperInvariant() ?? string.Empty;
return candidate.Length is > 0 and <= 16 && candidate.All(char.IsAsciiLetterOrDigit)
? candidate
: "<invalid-format>";
}
@@ -7,7 +7,9 @@ using SGU.AuthBroker.Options;
namespace SGU.AuthBroker.Services; namespace SGU.AuthBroker.Services;
public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer public sealed class ActiveDirectorySynchronizer(
BrokerOptions options,
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
{ {
private const int AccountDisabled = 0x0002; private const int AccountDisabled = 0x0002;
private const int NormalAccount = 0x0200; private const int NormalAccount = 0x0200;
@@ -28,9 +30,26 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
await gate.WaitAsync(cancellationToken).ConfigureAwait(false); await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
return await Task.Run( try
() => Synchronize(identity, profile, password), {
cancellationToken).ConfigureAwait(false); return await Task.Run(
() => Synchronize(identity, profile, password),
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
BrokerEventIds.DirectorySynchronizationFailure,
exception,
"Active Directory synchronization failed for {InstitutionalUser} with role {Role}.",
identity.UserName,
identity.Role);
throw;
}
} }
finally finally
{ {
@@ -100,8 +119,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
user.Properties["pwdLastSet"].Value = -1; user.Properties["pwdLastSet"].Value = -1;
user.CommitChanges(); user.CommitChanges();
TryApplyProfile(user, identity, profile, options.DefaultCompany); TryApplyProfile(user, identity, profile, options.DefaultCompany, logger);
TryEnsureRemoteDesktopGroupMembership(user); TryEnsureRemoteDesktopGroupMembership(user, identity.UserName);
return new DirectorySyncResult( return new DirectorySyncResult(
options.DomainNetbios, options.DomainNetbios,
@@ -120,7 +139,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
DirectoryEntry user, DirectoryEntry user,
UserIdentity identity, UserIdentity identity,
InstitutionalProfile? profile, InstitutionalProfile? profile,
string defaultCompany) string defaultCompany,
ILogger logger)
{ {
try try
{ {
@@ -146,10 +166,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
user.CommitChanges(); user.CommitChanges();
} }
catch catch (Exception exception)
{ {
// Metadata is intentionally best-effort. User creation, password sync, // Metadata is intentionally best-effort. User creation, password sync,
// and account enablement have already committed successfully. // and account enablement have already committed successfully.
logger.LogWarning(
BrokerEventIds.DirectoryOptionalMetadataFailure,
exception,
"Optional Active Directory profile metadata could not be committed for {InstitutionalUser}; password synchronization remains completed.",
identity.UserName);
try try
{ {
user.RefreshCache(); user.RefreshCache();
@@ -170,7 +195,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
} }
} }
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user) private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
{ {
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn)) if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
{ {
@@ -194,10 +219,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
group.CommitChanges(); group.CommitChanges();
} }
} }
catch catch (Exception exception)
{ {
// Remote access is lab policy and must not invalidate a completed // Remote access is lab policy and must not invalidate a completed
// password synchronization if the optional group is unavailable. // password synchronization if the optional group is unavailable.
logger.LogWarning(
BrokerEventIds.DirectoryGroupMembershipFailure,
exception,
"Optional remote-desktop group membership could not be updated for {InstitutionalUser}; password synchronization remains completed.",
institutionalUser);
} }
} }
@@ -35,6 +35,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
string password, string password,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using IDisposable? logScope = logger.BeginScope(
"InstitutionalUser={InstitutionalUser}; InstitutionalRole={InstitutionalRole}",
identity.UserName,
identity.Role);
Uri authenticationUri = new( Uri authenticationUri = new(
new Uri(options.Endpoint, UriKind.Absolute), new Uri(options.Endpoint, UriKind.Absolute),
options.AuthenticationPath); options.AuthenticationPath);
@@ -155,6 +159,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
} }
logger.LogInformation( logger.LogInformation(
BrokerEventIds.SguAuthenticationAccepted,
"SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.", "SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds); elapsed.ElapsedMilliseconds);
return (null, continuationUri); return (null, continuationUri);
@@ -184,6 +189,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
logger.LogWarning( logger.LogWarning(
BrokerEventIds.SguAuthenticationTimeout,
"SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.", "SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds); elapsed.ElapsedMilliseconds);
return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null); return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null);
@@ -191,6 +197,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
catch (HttpRequestException exception) catch (HttpRequestException exception)
{ {
logger.LogWarning( logger.LogWarning(
BrokerEventIds.SguAuthenticationNetworkFailure,
exception, exception,
"SGU NTLM authentication failed after {ElapsedMilliseconds} ms.", "SGU NTLM authentication failed after {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds); elapsed.ElapsedMilliseconds);
@@ -347,6 +354,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
if (profile is null) if (profile is null)
{ {
logger.LogWarning( logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.", "SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.",
identity.Role, identity.Role,
elapsed.ElapsedMilliseconds); elapsed.ElapsedMilliseconds);
@@ -354,8 +362,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
else else
{ {
logger.LogInformation( logger.LogInformation(
"SGU profile enrichment completed for role {Role} in {ElapsedMilliseconds} ms.", BrokerEventIds.ProfileEnrichmentCompleted,
"SGU profile enrichment completed for role {Role} with {ProfileFieldCount} supported fields in {ElapsedMilliseconds} ms.",
identity.Role, identity.Role,
CountProfileFields(profile),
elapsed.ElapsedMilliseconds); elapsed.ElapsedMilliseconds);
} }
@@ -382,6 +392,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
logger.LogWarning( logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.", "SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
identity.Role, identity.Role,
elapsed.ElapsedMilliseconds); elapsed.ElapsedMilliseconds);
@@ -389,6 +400,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
catch (Exception exception) catch (Exception exception)
{ {
logger.LogWarning( logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception, exception,
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.", "SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
identity.Role, identity.Role,
@@ -423,11 +435,33 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
GetProfileUri(path), GetProfileUri(path),
allowedHosts, allowedHosts,
timeoutToken).ConfigureAwait(false); timeoutToken).ConfigureAwait(false);
profile = profile.Overlay(html is null ? null : parser(html)); if (html is null)
{
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
path,
role);
continue;
}
InstitutionalProfile? pageProfile = parser(html);
if (pageProfile is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
path,
role);
continue;
}
profile = profile.Overlay(pageProfile);
} }
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{ {
logger.LogWarning( logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.", "SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
role, role,
elapsed.ElapsedMilliseconds); elapsed.ElapsedMilliseconds);
@@ -436,6 +470,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
catch (Exception exception) catch (Exception exception)
{ {
logger.LogWarning( logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception, exception,
"An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.", "An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
role, role,
@@ -487,6 +522,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
} }
logger.LogWarning( logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} returned HTTP {StatusCode}.", "Optional SGU profile page {Path} returned HTTP {StatusCode}.",
requestedUri.AbsolutePath, requestedUri.AbsolutePath,
statusCode); statusCode);
@@ -494,6 +530,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
} }
logger.LogWarning( logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} exceeded the redirect limit.", "Optional SGU profile page {Path} exceeded the redirect limit.",
requestedUri.AbsolutePath); requestedUri.AbsolutePath);
return null; return null;
@@ -550,6 +587,23 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
right.AbsolutePath.TrimEnd('/'), right.AbsolutePath.TrimEnd('/'),
StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
private static int CountProfileFields(InstitutionalProfile profile) =>
new[]
{
profile.EmployeeNumber,
profile.DisplayName,
profile.GivenName,
profile.Surname,
profile.Email,
profile.EmployeeType,
profile.JobTitle,
profile.Department,
profile.StreetAddress,
profile.City,
profile.State,
profile.PostalCode
}.Count(value => !string.IsNullOrWhiteSpace(value));
private static async Task DrainResponseAsync( private static async Task DrainResponseAsync(
HttpResponseMessage response, HttpResponseMessage response,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -604,17 +658,31 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
response.Content, response.Content,
options.MaxProfileBytes, options.MaxProfileBytes,
timeoutToken).ConfigureAwait(false); timeoutToken).ConfigureAwait(false);
return identity.Role switch InstitutionalProfile? profile;
switch (identity.Role)
{ {
InstitutionalRole.Administrative => case InstitutionalRole.Administrative:
SguProfileParser.ParseAdministrative(html, identity.NumericId) ?? profile = SguProfileParser.ParseAdministrative(html, identity.NumericId);
SguProfileParser.ParseMenu(html), break;
InstitutionalRole.Student => case InstitutionalRole.Student:
SguProfileParser.ParseStudent(html, identity.NumericId) ?? profile = SguProfileParser.ParseStudent(html, identity.NumericId);
SguProfileParser.ParseMenu(html), break;
InstitutionalRole.Professor => SguProfileParser.ParseMenu(html), case InstitutionalRole.Professor:
_ => null return SguProfileParser.ParseMenu(html);
}; default:
return null;
}
if (profile is not null)
{
return profile;
}
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"The primary SGU profile HTML did not contain the supported field IDs for role {Role}; attempting the menu-name fallback.",
identity.Role);
return SguProfileParser.ParseMenu(html);
} }
private static async Task<string> ReadLimitedStringAsync( private static async Task<string> ReadLimitedStringAsync(
+14 -17
View File
@@ -1,35 +1,32 @@
using System.Drawing; using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging; using System.Drawing.Imaging;
using System.Drawing.Drawing2D;
namespace SGU.CredentialProvider; namespace SGU.CredentialProvider;
internal static class ProviderTileIcon internal static class ProviderTileIcon
{ {
public const int Size = 72; // LogonUI enlarges the dedicated-tile artwork. Supply a dense source image
// so the mascot remains crisp at the large sign-in surface.
public const int Size = 256;
private const string MascotResourceName = "SGU.CredentialProvider.Branding.LaSalleMascot.png";
public static Bitmap Create() public static Bitmap Create()
{ {
Bitmap bitmap = new(Size, Size, PixelFormat.Format32bppArgb); Bitmap bitmap = new(Size, Size, PixelFormat.Format32bppArgb);
using Graphics graphics = Graphics.FromImage(bitmap); using Graphics graphics = Graphics.FromImage(bitmap);
graphics.SmoothingMode = SmoothingMode.AntiAlias; graphics.CompositingQuality = CompositingQuality.HighQuality;
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
graphics.Clear(Color.Transparent); graphics.Clear(Color.Transparent);
using SolidBrush background = new(Color.FromArgb(0, 83, 155)); using Stream sourceStream = typeof(ProviderTileIcon).Assembly.GetManifestResourceStream(MascotResourceName)
graphics.FillEllipse(background, 1, 1, Size - 2, Size - 2); ?? throw new InvalidOperationException($"The branded Credential Provider logo '{MascotResourceName}' is unavailable.");
using Bitmap mascot = new(sourceStream);
using Pen key = new(Color.White, 5.5f) using GraphicsPath circularMask = new();
{ circularMask.AddEllipse(0, 0, Size, Size);
StartCap = LineCap.Round, graphics.SetClip(circularMask);
EndCap = LineCap.Round, graphics.DrawImage(mascot, new Rectangle(0, 0, Size, Size));
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; return bitmap;
} }
@@ -23,6 +23,11 @@
<ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" /> <ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\..\assets\branding\lasalle-mexico-provider.png"
LogicalName="SGU.CredentialProvider.Branding.LaSalleMascot.png" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute"> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>SGU.CredentialProvider.Tests</_Parameter1> <_Parameter1>SGU.CredentialProvider.Tests</_Parameter1>
@@ -7,7 +7,7 @@ namespace SGU.CredentialProvider.Tests;
public sealed class ProviderTileIconTests public sealed class ProviderTileIconTests
{ {
[Fact] [Fact]
public void ProviderPublishesASeventyTwoPixelLogoForSignInOptions() public void ProviderPublishesAHighResolutionLogoForSignInOptions()
{ {
SguCredentialProvider provider = new(); SguCredentialProvider provider = new();
@@ -20,22 +20,27 @@ public sealed class ProviderTileIconTests
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, 0).A); Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, 0).A);
Assert.Equal(0, logo.Bitmap.GetPixel(0, ProviderTileIcon.Size - 1).A); Assert.Equal(0, logo.Bitmap.GetPixel(0, ProviderTileIcon.Size - 1).A);
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, ProviderTileIcon.Size - 1).A); Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, ProviderTileIcon.Size - 1).A);
Assert.Equal( int redPixels = 0;
Color.FromArgb(0, 83, 155).ToArgb(), int navyPixels = 0;
logo.Bitmap.GetPixel(6, ProviderTileIcon.Size / 2).ToArgb());
int lightPixels = 0;
for (int x = 0; x < logo.Bitmap.Width; x++) for (int x = 0; x < logo.Bitmap.Width; x++)
{ {
for (int y = 0; y < logo.Bitmap.Height; y++) for (int y = 0; y < logo.Bitmap.Height; y++)
{ {
if (logo.Bitmap.GetPixel(x, y).GetBrightness() > 0.7f) Color pixel = logo.Bitmap.GetPixel(x, y);
if (pixel.R > 160 && pixel.G < 100 && pixel.B < 100)
{ {
lightPixels++; redPixels++;
}
if (pixel.B > pixel.R && pixel.B > pixel.G && pixel.R < 70)
{
navyPixels++;
} }
} }
} }
Assert.InRange(lightPixels, 200, 2_000); Assert.InRange(redPixels, 1_000, 30_000);
Assert.InRange(navyPixels, 1_000, 50_000);
} }
[Fact] [Fact]