Add branded default provider and enforced enrollment

This commit is contained in:
2026-09-01 11:20:06 -06:00
parent da01343985
commit f3afd62993
22 changed files with 787 additions and 17 deletions
+1
View File
@@ -35,6 +35,7 @@ Operational documentation:
- [Broker location, health, timeout, and recovery](docs/broker-operations.md)
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
- [Required Credential Provider client enrollment](docs/client-enrollment.md)
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
| Prefix | Role | Default OU |
+7
View File
@@ -61,6 +61,13 @@ still contain the Unicode replacement character are not written to AD.
When `RemoteDesktopGroupDn` is configured, the broker also adds each successfully
synchronized SGU user to that dedicated AD security group. The laboratory
Windows client maps the group into its local **Remote Desktop Users** group.
Every synchronized user also receives `Universidad La Salle` in the AD
`company` attribute unless deployment configuration supplies another default.
The generic SGU credential is rendered as a dedicated branded tile instead of
being grouped below the anonymous **Other user** tile. Machine policy assigns
the SGU CLSID as the default provider while retaining the built-in Microsoft
password provider.
The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`,
and `Administrativos` are direct child OUs beneath it.
+102
View File
@@ -0,0 +1,102 @@
# Enrolamiento obligatorio de clientes SGU
El flujo administrado instala y valida el Credential Provider **antes** de
ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta
el runtime, un certificado, el registro COM, la directiva predeterminada o la
salud mTLS del broker, el script rechaza la unión.
Una cuenta administrativa todavía puede modificar Windows fuera de este flujo.
Por eso el enrolamiento también instala la tarea
`SGU-CredentialProvider-EnrollmentGuard`, ejecutada como `SYSTEM` al iniciar y
diariamente. La tarea conserva un paquete local protegido, repara el registro o
los binarios y vuelve a habilitar RDP/WinRM en equipos unidos al dominio. No
guarda contraseñas.
## 1. Preparar el nuevo Windows Enterprise
En la VM nueva, antes de unirla al dominio:
1. copiar `artifacts\credential-provider` y todos los scripts de `scripts`;
2. copiar el instalador offline más reciente de .NET 10 x64;
3. crear el certificado cliente no exportable:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\New-LabCertificate.ps1 `
-Role CredentialProviderClient
```
Copiar únicamente el `.cer` público al broker y registrarlo:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\Register-SguClientCertificate.ps1 `
-CertificatePath C:\SGUDeploy\new-client.cer
```
Exportar únicamente el certificado público actual del broker e importarlo en
el cliente con `Import-LabPeerCertificate.ps1`. Nunca copiar claves privadas.
## 2. Instalar, comprobar y unir en una sola operación
Ejecutar como administrador local dentro de la VM nueva. La credencial del
dominio se solicita de forma segura si no se proporciona como objeto
`PSCredential`:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\Enroll-SguDomainClient.ps1 `
-PublishPath C:\SGUDeploy\credential-provider `
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
-DotNetRuntimeInstallerPath C:\SGUDeploy\dotnet-runtime-10-win-x64.exe `
-NewComputerName LAB-W11-001
```
Orden de la transacción:
1. instala .NET y los binarios versionados;
2. registra COM y configura SGU como proveedor predeterminado;
3. instala el guard de autorreparación;
4. exige health mTLS del broker y ejecuta las comprobaciones locales;
5. configura DNS del dominio;
6. sólo entonces ejecuta `Add-Computer` y reinicia;
7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
La directiva de Windows **Assign a default credential provider** selecciona SGU
por defecto. No se instala ningún filtro: el proveedor de contraseña de
Microsoft permanece disponible para cuentas creadas manualmente y recuperación.
## 3. Verificación y reparación
```powershell
.\Test-SguClientEnrollment.ps1 `
-RequireDomainJoined `
-RequireRemoteAccess `
-RequireBrokerHealth `
-Enforce
Get-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard
Start-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard
```
Un resultado válido exige simultáneamente binario y registro COM, configuración,
certificados, .NET 10, proveedor SGU predeterminado y proveedor de contraseña de
Microsoft preservado. El script de reparación se encuentra en
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
administradores.
## 4. Experiencia de LogonUI
El proveedor usa un mosaico genérico dedicado, no una credencial asociada al SID
anónimo de **Other user**. Windows puede mostrar ahora la imagen institucional y
la etiqueta `Universidad La Salle · Acceso SGU`, además del encabezado
`Acceso institucional SGU`. El orden visual exacto de los iconos de opciones lo
decide Windows; lo administrable y exigido es cuál aparece seleccionado por
defecto.
Referencias de Microsoft:
- <https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-credentialproviders#defaultcredentialprovider>
- <https://learn.microsoft.com/en-us/windows/win32/api/credentialprovider/nf-credentialprovider-icredentialprovidercredential2-getusersid>
+6 -1
View File
@@ -18,7 +18,8 @@ put an institutional password on a command line or in a script file.
Set-Location C:\Users\alex\Documents\projects\SGU-CredentialProvider
dotnet restore .\SGU-CredentialProvider.sln
dotnet build .\SGU-CredentialProvider.sln -c Release --no-restore
dotnet test .\SGU-CredentialProvider.sln -c Release --no-build --no-restore
dotnet test --project .\tests\SGU.AuthBroker.Core.Tests\SGU.AuthBroker.Core.Tests.csproj -c Release
dotnet test --project .\tests\SGU.CredentialProvider.Tests\SGU.CredentialProvider.Tests.csproj -c Release
.\scripts\Publish-Lab.ps1
```
@@ -111,6 +112,10 @@ OU on Windows Server. Use separate authorized test accounts for `DO`, `AL`, and
## 5. Install the Credential Provider
For a new client, prefer the provider-first transaction in
[`client-enrollment.md`](client-enrollment.md). The direct installation below is
kept for repair and existing domain members.
On Windows 10:
```powershell
+9
View File
@@ -21,6 +21,11 @@ Resolve-DnsName -Type SRV _ldap._tcp.dc._msdcs.lci.lasalle.mx
## 2. Unir el equipo al dominio
Para equipos SGU nuevos, no ejecutar `Add-Computer` directamente. Usar el flujo
obligatorio de [`client-enrollment.md`](client-enrollment.md), que instala y
valida primero el Credential Provider. Los comandos siguientes quedan como guía
de recuperación o para equipos que no pertenecen al alcance SGU.
Abrir Windows PowerShell como administrador. El prompt solicita la contraseña
de forma interactiva y evita ponerla en el historial:
@@ -85,6 +90,10 @@ contenido. De esta forma se puede preparar una actualización aunque
`LogonUI.exe` todavía tenga cargada la DLL anterior; el reinicio obligatorio
activa la nueva versión.
El instalador también asigna SGU como proveedor predeterminado mediante la
directiva de equipo de Windows. No modifica ni oculta el proveedor de contraseña
de Microsoft.
Bloquear el equipo, abrir **Sign-in options** y elegir el icono azul con llave
del acceso SGU. El proveedor de contraseña de Microsoft debe permanecer visible.
+3
View File
@@ -23,6 +23,8 @@ param(
[string]$DomainNetbios = 'LCI',
[string]$UpnSuffix = 'lci.lasalle.mx',
[string]$RemoteDesktopGroupDn = '',
[ValidateLength(1, 64)]
[string]$DefaultCompany = 'Universidad La Salle',
[ValidateRange(10, 60)]
[int]$NtlmTimeoutSeconds = 15,
[switch]$CreateMissingOus,
@@ -152,6 +154,7 @@ $productionSettings = @{
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
DefaultCompany = $DefaultCompany
CreateMissingOus = [bool]$CreateMissingOus
}
}
+127
View File
@@ -0,0 +1,127 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$PublishPath,
[Parameter(Mandatory)]
[ValidatePattern('^https://')]
[string]$BrokerEndpoint,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
[string]$ClientCertificateThumbprint,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
[string]$ServerCertificateThumbprint,
[PSCredential]$DomainCredential,
[string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI',
[string]$NewComputerName,
[string]$NetworkInterfaceAlias = 'Ethernet',
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath,
[switch]$SkipRestart
)
$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.'
}
foreach ($scriptName in @(
'Install-CredentialProvider.ps1',
'Install-SguEnrollmentGuard.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1',
'Enable-LabRemoteAccess.ps1')) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) {
throw "$scriptName must be beside Enroll-SguDomainClient.ps1."
}
}
$computer = Get-CimInstance Win32_ComputerSystem
if ($computer.PartOfDomain -and $computer.Domain -ne $DomainName) {
throw "The computer is already joined to the unexpected domain $($computer.Domain)."
}
$installParams = @{
PublishPath = $PublishPath
BrokerEndpoint = $BrokerEndpoint
ClientCertificateThumbprint = $ClientCertificateThumbprint
ServerCertificateThumbprint = $ServerCertificateThumbprint
DomainNetbios = $DomainNetbios
TimeoutSeconds = 20
}
if ($DotNetRuntimeInstallerPath) {
$installParams.InstallDotNetRuntime = $true
$installParams.DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath
}
$guardParams = @{
PublishPath = $PublishPath
BrokerEndpoint = $BrokerEndpoint
ClientCertificateThumbprint = $ClientCertificateThumbprint
ServerCertificateThumbprint = $ServerCertificateThumbprint
DomainNetbios = $DomainNetbios
TimeoutSeconds = 20
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath
}
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before joining the domain')) {
& (Join-Path $PSScriptRoot 'Install-CredentialProvider.ps1') @installParams | Out-Null
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
$preJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
-RequireBrokerHealth
if (-not $preJoin.IsValid) {
throw "Domain join refused because SGU enrollment is invalid: $($preJoin.Issues -join ' ')"
}
if ($computer.PartOfDomain) {
& (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') `
-RemoteDesktopPrincipal $RemoteDesktopPrincipal `
-EnableAdministrativeFirewallGroups | Out-Null
return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
-RequireDomainJoined `
-RequireRemoteAccess `
-RemoteDesktopPrincipal $RemoteDesktopPrincipal
}
Set-DnsClientServerAddress `
-InterfaceAlias $NetworkInterfaceAlias `
-ServerAddresses $DomainDnsServerAddresses
Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null
if (-not $DomainCredential) {
$DomainCredential = Get-Credential `
-UserName "$DomainNetbios\Administrator" `
-Message "Credential permitted to join this computer to $DomainName"
}
$joinParams = @{
DomainName = $DomainName
Credential = $DomainCredential
Force = $true
}
if ($NewComputerName) {
$joinParams.NewName = $NewComputerName
}
Add-Computer @joinParams
if (-not $SkipRestart) {
Restart-Computer -Force
}
}
[pscustomobject]@{
ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
DomainName = $DomainName
ProviderValidatedBeforeJoin = $true
RestartRequired = [bool]$SkipRestart
}
+25 -1
View File
@@ -20,6 +20,8 @@ param(
[ValidateRange(2, 60)]
[int]$TimeoutSeconds = 20,
[switch]$DoNotSetAsDefaultCredentialProvider,
[switch]$InstallDotNetRuntime,
[string]$DotNetRuntimeInstallerPath
@@ -31,6 +33,7 @@ $installRoot = Join-Path $env:ProgramFiles 'SGU\CredentialProvider'
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
$defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
@@ -175,7 +178,27 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
New-ItemProperty -Path $classRegistryPath -Name ThreadingModel -Value Both -PropertyType String -Force | Out-Null
New-Item -Path $providerRegistryPath -Force | Out-Null
Set-Item -Path $providerRegistryPath -Value 'SGU Institutional Login'
Set-Item -Path $providerRegistryPath -Value 'Universidad La Salle · Acceso SGU'
if (-not $DoNotSetAsDefaultCredentialProvider) {
New-Item -Path $defaultProviderPolicyPath -Force | Out-Null
New-ItemProperty -Path $defaultProviderPolicyPath `
-Name DefaultCredentialProvider `
-Value $providerClassId `
-PropertyType String `
-Force | Out-Null
}
}
$defaultProviderConfigured = $false
try {
$defaultProviderConfigured = (Get-ItemPropertyValue `
-LiteralPath $defaultProviderPolicyPath `
-Name DefaultCredentialProvider `
-ErrorAction Stop) -eq $providerClassId
}
catch {
# An explicitly opted-out installation has no default-provider policy.
}
[pscustomobject]@{
@@ -183,5 +206,6 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
InstallPath = $installPath
SettingsPath = $settingsPath
Registered = Test-Path -LiteralPath $providerRegistryPath
DefaultProviderConfigured = $defaultProviderConfigured
SystemPasswordProviderPreserved = $true
}
+134
View File
@@ -0,0 +1,134 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$PublishPath,
[Parameter(Mandatory)]
[ValidatePattern('^https://')]
[string]$BrokerEndpoint,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
[string]$ClientCertificateThumbprint,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
[string]$ServerCertificateThumbprint,
[string]$DomainNetbios = 'LCI',
[ValidateRange(2, 60)]
[int]$TimeoutSeconds = 20,
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath
)
$ErrorActionPreference = 'Stop'
$taskName = 'SGU-CredentialProvider-EnrollmentGuard'
$enrollmentRoot = Join-Path $env:ProgramData 'SGU\Enrollment'
$sourceScripts = @(
'Install-CredentialProvider.ps1',
'Enable-LabRemoteAccess.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.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.'
}
foreach ($scriptName in $sourceScripts) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) {
throw "$scriptName must be beside Install-SguEnrollmentGuard.ps1."
}
}
$requiredProviderFile = Join-Path $PublishPath 'SGU.CredentialProvider.comhost.dll'
if (-not (Test-Path -LiteralPath $requiredProviderFile -PathType Leaf)) {
throw 'PublishPath does not contain the Credential Provider package.'
}
if ($DotNetRuntimeInstallerPath -and
-not (Test-Path -LiteralPath $DotNetRuntimeInstallerPath -PathType Leaf)) {
throw 'DotNetRuntimeInstallerPath does not exist.'
}
if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair guard')) {
New-Item -ItemType Directory -Path $enrollmentRoot -Force | Out-Null
$packageId = '{0}-{1}' -f (Get-Date -Format 'yyyyMMddHHmmss'), ([Guid]::NewGuid().ToString('N').Substring(0, 8))
$guardPublishPath = Join-Path $enrollmentRoot "packages\$packageId"
New-Item -ItemType Directory -Path $guardPublishPath -Force | Out-Null
Copy-Item -Path (Join-Path $PublishPath '*') -Destination $guardPublishPath -Recurse -Force
foreach ($scriptName in $sourceScripts) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $scriptName) `
-Destination (Join-Path $enrollmentRoot $scriptName) -Force
}
$guardRuntimeInstaller = $null
if ($DotNetRuntimeInstallerPath) {
$runtimeDirectory = Join-Path $enrollmentRoot 'prerequisites'
New-Item -ItemType Directory -Path $runtimeDirectory -Force | Out-Null
$guardRuntimeInstaller = Join-Path $runtimeDirectory (Split-Path $DotNetRuntimeInstallerPath -Leaf)
Copy-Item -LiteralPath $DotNetRuntimeInstallerPath -Destination $guardRuntimeInstaller -Force
}
$guardConfiguration = [ordered]@{
PublishPath = $guardPublishPath
BrokerEndpoint = $BrokerEndpoint
ClientCertificateThumbprint = ($ClientCertificateThumbprint -replace ' ', '')
ServerCertificateThumbprint = ($ServerCertificateThumbprint -replace ' ', '')
DomainNetbios = $DomainNetbios
TimeoutSeconds = $TimeoutSeconds
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
DotNetRuntimeInstallerPath = $guardRuntimeInstaller
}
$configurationPath = Join-Path $enrollmentRoot 'enrollment.json'
[IO.File]::WriteAllText(
$configurationPath,
($guardConfiguration | ConvertTo-Json),
[Text.UTF8Encoding]::new($false))
$acl = Get-Acl -LiteralPath $enrollmentRoot
$acl.SetAccessRuleProtection($true, $false)
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
Set-Acl -LiteralPath $enrollmentRoot -AclObject $acl
$repairScript = Join-Path $enrollmentRoot 'Repair-SguClientEnrollment.ps1'
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$action = New-ScheduledTaskAction -Execute $powerShell `
-Argument "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$repairScript`""
$startupTrigger = New-ScheduledTaskTrigger -AtStartup
# Give domain networking and Group Policy time to initialize before the
# repair script resolves domain principals and validates remote access.
$startupTrigger.Delay = 'PT1M'
$triggers = @(
$startupTrigger,
(New-ScheduledTaskTrigger -Daily -At '3:00 AM')
)
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $taskName `
-Action $action `
-Trigger $triggers `
-Settings $settings `
-User 'SYSTEM' `
-RunLevel Highest `
-Force | Out-Null
& $repairScript -ConfigurationPath $configurationPath | Out-Null
}
[pscustomobject]@{
EnrollmentRoot = $enrollmentRoot
TaskName = $taskName
TaskState = (Get-ScheduledTask -TaskName $taskName).State
ConfigurationPath = Join-Path $enrollmentRoot 'enrollment.json'
}
+67
View File
@@ -0,0 +1,67 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$CertificatePath,
[string]$BrokerConfigurationPath = 'C:\Program Files\SGU\AuthBroker\appsettings.Production.json'
)
$ErrorActionPreference = 'Stop'
$serviceName = 'SGUAuthBroker'
$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 PowerShell session on the broker server.'
}
if (-not (Test-Path -LiteralPath $CertificatePath -PathType Leaf)) {
throw 'CertificatePath does not exist.'
}
if (-not (Test-Path -LiteralPath $BrokerConfigurationPath -PathType Leaf)) {
throw 'The broker production configuration does not exist.'
}
$candidate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($CertificatePath)
if ($candidate.NotBefore -gt (Get-Date) -or $candidate.NotAfter -lt (Get-Date)) {
throw 'The client certificate is outside its validity period.'
}
$clientAuthenticationOid = '1.3.6.1.5.5.7.3.2'
$ekuExtension = $candidate.Extensions |
Where-Object { $_.Oid.Value -eq '2.5.29.37' } |
Select-Object -First 1
if (-not $ekuExtension -or
$ekuExtension.EnhancedKeyUsages.Value -notcontains $clientAuthenticationOid) {
throw 'The certificate is not valid for TLS client authentication.'
}
if ($PSCmdlet.ShouldProcess($candidate.Thumbprint, 'Trust and allow the SGU client certificate')) {
$trustedCertificate = Import-Certificate `
-FilePath $CertificatePath `
-CertStoreLocation Cert:\LocalMachine\Root |
Select-Object -First 1
$configuration = Get-Content -LiteralPath $BrokerConfigurationPath -Raw | ConvertFrom-Json
$allowed = @($configuration.Broker.Tls.AllowedClientThumbprints |
ForEach-Object { $_ -replace ' ', '' })
if ($allowed -notcontains $trustedCertificate.Thumbprint) {
$configuration.Broker.Tls.AllowedClientThumbprints = @($allowed + $trustedCertificate.Thumbprint)
$backupPath = "$BrokerConfigurationPath.before-$($trustedCertificate.Thumbprint.Substring(0, 12)).bak"
Copy-Item -LiteralPath $BrokerConfigurationPath -Destination $backupPath -Force
[IO.File]::WriteAllText(
$BrokerConfigurationPath,
($configuration | ConvertTo-Json -Depth 8),
[Text.UTF8Encoding]::new($false))
}
Restart-Service -Name $serviceName -Force
(Get-Service -Name $serviceName).WaitForStatus(
[System.ServiceProcess.ServiceControllerStatus]::Running,
[TimeSpan]::FromSeconds(20))
}
[pscustomobject]@{
Subject = $candidate.Subject
Thumbprint = $candidate.Thumbprint
NotAfter = $candidate.NotAfter
Allowed = @((Get-Content -LiteralPath $BrokerConfigurationPath -Raw | ConvertFrom-Json).Broker.Tls.AllowedClientThumbprints) -contains $candidate.Thumbprint
ServiceStatus = (Get-Service -Name $serviceName).Status
}
+47
View File
@@ -0,0 +1,47 @@
[CmdletBinding()]
param(
[string]$ConfigurationPath = "$env:ProgramData\SGU\Enrollment\enrollment.json"
)
$ErrorActionPreference = 'Stop'
$configuration = Get-Content -LiteralPath $ConfigurationPath -Raw | ConvertFrom-Json
$enrollmentRoot = Split-Path $ConfigurationPath -Parent
$testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1'
$installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
$before = & $testScript
if (-not $before.IsValid) {
$installParams = @{
PublishPath = [string]$configuration.PublishPath
BrokerEndpoint = [string]$configuration.BrokerEndpoint
ClientCertificateThumbprint = [string]$configuration.ClientCertificateThumbprint
ServerCertificateThumbprint = [string]$configuration.ServerCertificateThumbprint
DomainNetbios = [string]$configuration.DomainNetbios
TimeoutSeconds = [int]$configuration.TimeoutSeconds
}
if ($configuration.DotNetRuntimeInstallerPath) {
$installParams.InstallDotNetRuntime = $true
$installParams.DotNetRuntimeInstallerPath = [string]$configuration.DotNetRuntimeInstallerPath
}
& $installScript @installParams | Out-Null
}
$computer = Get-CimInstance Win32_ComputerSystem
if ($computer.PartOfDomain) {
& $remoteAccessScript `
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
-EnableAdministrativeFirewallGroups | Out-Null
}
$verificationParams = @{}
if ($computer.PartOfDomain) {
$verificationParams.RequireDomainJoined = $true
$verificationParams.RequireRemoteAccess = $true
$verificationParams.RemoteDesktopPrincipal = [string]$configuration.RemoteDesktopPrincipal
}
$after = & $testScript @verificationParams
$after
if (-not $after.IsValid) {
throw "SGU enrollment repair did not reach a valid state: $($after.Issues -join ' ')"
}
+154
View File
@@ -0,0 +1,154 @@
[CmdletBinding()]
param(
[switch]$RequireDomainJoined,
[switch]$RequireRemoteAccess,
[switch]$RequireBrokerHealth,
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[switch]$Enforce
)
$ErrorActionPreference = 'Stop'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$passwordProviderClassId = '{60B78E88-EAD8-445C-9CFD-0B87F74EA6CD}'
$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
$passwordProviderRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$passwordProviderClassId"
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
$defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
$issues = [Collections.Generic.List[string]]::new()
$computer = Get-CimInstance Win32_ComputerSystem
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
$issues.Add('The computer is not joined to a domain.')
}
$providerRegistered = Test-Path -LiteralPath $providerRegistryPath
if (-not $providerRegistered) {
$issues.Add('The SGU Credential Provider registration is missing.')
}
$registeredDll = $null
if (Test-Path -LiteralPath $classRegistryPath) {
$registeredDll = (Get-Item -LiteralPath $classRegistryPath).GetValue('')
}
$providerBinaryPresent = $registeredDll -and (Test-Path -LiteralPath $registeredDll -PathType Leaf)
if (-not $providerBinaryPresent) {
$issues.Add('The registered SGU COM binary is missing.')
}
$configuredDefault = $null
try {
$configuredDefault = Get-ItemPropertyValue `
-LiteralPath $defaultProviderPolicyPath `
-Name DefaultCredentialProvider `
-ErrorAction Stop
}
catch {
# Report the missing or unreadable policy as a failed enrollment check.
}
$defaultProviderConfigured = $configuredDefault -eq $providerClassId
if (-not $defaultProviderConfigured) {
$issues.Add('The SGU provider is not assigned as the machine default credential provider.')
}
$passwordProviderPreserved = Test-Path -LiteralPath $passwordProviderRegistryPath
if (-not $passwordProviderPreserved) {
$issues.Add('The built-in Microsoft password provider registration is missing.')
}
$settings = $null
try {
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
}
catch {
$issues.Add('The SGU provider settings file is missing or invalid.')
}
$clientCertificatePresent = $false
$serverCertificateTrusted = $false
$brokerHealth = $null
if ($settings) {
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object Thumbprint -eq $settings.ClientCertificateThumbprint |
Select-Object -First 1
$clientCertificatePresent = $clientCertificate -and $clientCertificate.HasPrivateKey
if (-not $clientCertificatePresent) {
$issues.Add('The client mTLS certificate with private key is missing.')
}
$serverCertificate = Get-ChildItem Cert:\LocalMachine\Root,Cert:\LocalMachine\CA |
Where-Object Thumbprint -eq $settings.ServerCertificateThumbprint |
Select-Object -First 1
$serverCertificateTrusted = [bool]$serverCertificate
if (-not $serverCertificateTrusted) {
$issues.Add('The broker certificate is not trusted by LocalMachine.')
}
if ($RequireBrokerHealth -and $clientCertificatePresent) {
try {
$healthUri = ([Uri]$settings.BrokerEndpoint).GetLeftPart([UriPartial]::Authority) + '/health/live'
$health = Invoke-RestMethod -Uri $healthUri -Certificate $clientCertificate -TimeoutSec 10
$brokerHealth = [string]$health.status
if ($brokerHealth -ne 'ok') {
$issues.Add('The broker health endpoint did not return ok.')
}
}
catch {
$issues.Add("The broker health check failed: $($_.Exception.Message)")
}
}
}
$dotNetRuntimePresent = $false
$dotnetCandidates = @(
(Get-Command dotnet -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
(Join-Path $env:ProgramFiles 'dotnet\dotnet.exe')
) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -Unique
foreach ($dotnet in $dotnetCandidates) {
if (& $dotnet --list-runtimes | Select-String '^Microsoft\.NETCore\.App 10\.') {
$dotNetRuntimePresent = $true
break
}
}
if (-not $dotNetRuntimePresent) {
$issues.Add('The Microsoft .NET 10 x64 runtime is missing.')
}
$remoteAccessReady = $null
if ($RequireRemoteAccess) {
$remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555')
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1]
$rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue)
$remoteAccessReady =
(Get-Service TermService).Status -eq 'Running' -and
(Get-Service WinRM).Status -eq 'Running' -and
(Get-ItemPropertyValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections) -eq 0 -and
$rdpMembers.Name -contains $RemoteDesktopPrincipal
if (-not $remoteAccessReady) {
$issues.Add('RDP/WinRM or the authorized domain group is not fully configured.')
}
}
$result = [pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain
DomainJoined = [bool]$computer.PartOfDomain
ProviderRegistered = $providerRegistered
ProviderBinary = $registeredDll
ProviderBinaryPresent = [bool]$providerBinaryPresent
DefaultProviderConfigured = $defaultProviderConfigured
PasswordProviderPreserved = $passwordProviderPreserved
SettingsPresent = [bool]$settings
ClientCertificatePresent = [bool]$clientCertificatePresent
ServerCertificateTrusted = $serverCertificateTrusted
DotNetRuntimePresent = $dotNetRuntimePresent
BrokerHealth = $brokerHealth
RemoteAccessReady = $remoteAccessReady
IsValid = $issues.Count -eq 0
Issues = $issues.ToArray()
}
$result
if ($Enforce -and -not $result.IsValid) {
throw "SGU client enrollment is invalid: $($issues -join ' ')"
}
+23
View File
@@ -8,14 +8,37 @@ $providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$installPath = Join-Path $env:ProgramFiles 'SGU\CredentialProvider'
$providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId"
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId"
$defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
$enrollmentTaskName = 'SGU-CredentialProvider-EnrollmentGuard'
$enrollmentPath = Join-Path $env:ProgramData 'SGU\Enrollment'
if ($PSCmdlet.ShouldProcess($providerClassId, 'Unregister the SGU Credential Provider')) {
if (Get-ScheduledTask -TaskName $enrollmentTaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $enrollmentTaskName -Confirm:$false
}
Remove-Item -LiteralPath $providerRegistryPath -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $classRegistryPath -Recurse -Force -ErrorAction SilentlyContinue
$configuredDefault = $null
try {
$configuredDefault = Get-ItemPropertyValue `
-LiteralPath $defaultProviderPolicyPath `
-Name DefaultCredentialProvider `
-ErrorAction Stop
}
catch {
# Nothing to remove when the policy is absent.
}
if ($configuredDefault -eq $providerClassId) {
Remove-ItemProperty -LiteralPath $defaultProviderPolicyPath `
-Name DefaultCredentialProvider -Force
}
}
if ($RemoveFiles -and $PSCmdlet.ShouldProcess($installPath, 'Remove Credential Provider files')) {
Remove-Item -LiteralPath $installPath -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $enrollmentPath -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Output 'The built-in Windows password Credential Provider was not changed.'
@@ -85,6 +85,11 @@ public sealed class BrokerOptions
{
throw new InvalidOperationException("RemoteDesktopGroupDn must identify a group beneath BaseDn.");
}
if (string.IsNullOrWhiteSpace(Directory.DefaultCompany) || Directory.DefaultCompany.Length > 64)
{
throw new InvalidOperationException("DefaultCompany is required and must not exceed 64 characters.");
}
}
private static bool IsCertificateThumbprint(string value)
@@ -139,6 +144,8 @@ public sealed class ActiveDirectoryOptions
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
public string DefaultCompany { get; init; } = "Universidad La Salle";
public bool CreateMissingOus { get; init; }
public string GetOuDn(InstitutionalRole role) => role switch
@@ -100,7 +100,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
user.Properties["pwdLastSet"].Value = -1;
user.CommitChanges();
TryApplyProfile(user, identity, profile);
TryApplyProfile(user, identity, profile, options.DefaultCompany);
TryEnsureRemoteDesktopGroupMembership(user);
return new DirectorySyncResult(
@@ -119,14 +119,13 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
private static void TryApplyProfile(
DirectoryEntry user,
UserIdentity identity,
InstitutionalProfile? profile)
InstitutionalProfile? profile,
string defaultCompany)
{
if (profile is null)
{
return;
}
try
{
SetOptionalProperty(user, "company", defaultCompany);
if (profile is not null)
{
SetOptionalProperty(user, "displayName", profile.DisplayName);
SetOptionalProperty(user, "mail", profile.Email);
@@ -137,6 +136,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
{
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
}
}
user.CommitChanges();
}
+1
View File
@@ -47,6 +47,7 @@
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"RemoteDesktopGroupDn": "",
"DefaultCompany": "Universidad La Salle",
"CreateMissingOus": false
}
}
@@ -4,6 +4,8 @@ internal static class ControlKeys
{
public const string ProviderLabel = "ProviderLabel";
public const string ProviderLogo = "ProviderLogo";
public const string UserTile = "UserTile";
public const string Heading = "Heading";
public const string InformationLabel = "InformationLabel";
public const string UserName = "UserName";
public const string Password = "Password";
@@ -7,6 +7,7 @@ namespace SGU.CredentialProvider;
internal static class ProviderTileIcon
{
public const int Size = 72;
public const int UserTileSize = 128;
public static Bitmap Create()
{
@@ -30,4 +31,29 @@ internal static class ProviderTileIcon
return bitmap;
}
public static Bitmap CreateUserTile()
{
Bitmap bitmap = new(UserTileSize, UserTileSize, PixelFormat.Format32bppArgb);
using Graphics graphics = Graphics.FromImage(bitmap);
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
graphics.Clear(Color.FromArgb(0, 83, 155));
using Pen border = new(Color.FromArgb(190, 221, 241), 5f);
using Pen key = new(Color.White, 9f)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
graphics.DrawEllipse(border, 7, 7, 113, 113);
graphics.DrawEllipse(key, 25, 25, 43, 43);
graphics.DrawLine(key, 62, 62, 99, 99);
graphics.DrawLine(key, 82, 82, 95, 69);
graphics.DrawLine(key, 93, 93, 106, 80);
return bitmap;
}
}
@@ -14,11 +14,18 @@ public sealed class SguCredentialProvider : CredentialProviderBase
public override IEnumerable<ControlBase> GetControls(UsageScenario cpus)
{
yield return new CredentialProviderLabelControl(ControlKeys.ProviderLabel, "Acceso institucional SGU");
yield return new CredentialProviderLabelControl(
ControlKeys.ProviderLabel,
"Universidad La Salle · Acceso SGU");
yield return new CredentialProviderLogoControl(
ControlKeys.ProviderLogo,
"Acceso institucional SGU",
ProviderTileIcon.Create());
yield return new UserTileControl(
ControlKeys.UserTile,
"Universidad La Salle",
ProviderTileIcon.CreateUserTile());
yield return new LargeLabelControl(ControlKeys.Heading, "Acceso institucional SGU");
yield return new SmallLabelControl(
ControlKeys.InformationLabel,
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.");
@@ -13,6 +13,7 @@ internal sealed class SguCredentialTile : CredentialTile2
public SguCredentialTile(CredentialProviderBase credentialProvider)
: base(credentialProvider)
{
GenericTileDisplayMode = GenericTileDisplayMode.DisplayAsDedicatedTile;
}
public SguCredentialTile(CredentialProviderBase credentialProvider, CredentialProviderUser user)
@@ -14,7 +14,9 @@ internal static class Program
private static readonly string[] ExpectedLabels =
[
"Universidad La Salle · Acceso SGU",
"Acceso institucional SGU",
"Universidad La Salle",
"Acceso institucional SGU",
"Usa tu clave institucional (DO, AL o AD + 6 dígitos) y contraseña.",
"Clave institucional",
@@ -55,6 +57,7 @@ internal static class Program
List<string> labels = [];
bool providerLogoPresent = false;
bool userTilePresent = false;
for (uint index = 0; index < fieldCount; index++)
{
ThrowIfFailed(provider.GetFieldDescriptorAt(index, out IntPtr descriptorPointer), "GetFieldDescriptorAt");
@@ -69,6 +72,8 @@ internal static class Program
labels.Add(descriptor.Label ?? string.Empty);
providerLogoPresent |= descriptor.FieldType == FieldType.TileImage &&
descriptor.FieldTypeGuid == ProviderLogoFieldType;
userTilePresent |= descriptor.FieldType == FieldType.TileImage &&
descriptor.FieldTypeGuid == Guid.Empty;
}
finally
{
@@ -87,6 +92,7 @@ internal static class Program
credentialCount == 1 &&
credential != IntPtr.Zero &&
providerLogoPresent &&
userTilePresent &&
labels.SequenceEqual(ExpectedLabels, StringComparer.Ordinal);
if (mode != "enumeration" && passed)
@@ -103,6 +109,7 @@ internal static class Program
fieldCount,
labels,
providerLogoPresent,
userTilePresent,
credentialCount,
defaultIndex,
autoLogon = autoLogon != 0
@@ -31,4 +31,20 @@ public sealed class ProviderTileIconTests
Assert.InRange(lightPixels, 200, 2_000);
}
[Fact]
public void ProviderPublishesABrandedDedicatedUserTileAndHeading()
{
SguCredentialProvider provider = new();
ControlBase[] controls = provider.GetControls(UsageScenario.Logon).ToArray();
UserTileControl userTile = Assert.Single(controls.OfType<UserTileControl>());
LargeLabelControl heading = Assert.Single(controls.OfType<LargeLabelControl>());
SguCredentialTile tile = Assert.IsType<SguCredentialTile>(provider.CreateGenericTile());
Assert.Equal(ProviderTileIcon.UserTileSize, userTile.Bitmap.Width);
Assert.Equal(ProviderTileIcon.UserTileSize, userTile.Bitmap.Height);
Assert.Equal("Acceso institucional SGU", heading.Label);
Assert.Equal(GenericTileDisplayMode.DisplayAsDedicatedTile, tile.GenericTileDisplayMode);
}
}