From f3afd62993d7b9990a738049d59b9072b9e8e34d Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Tue, 1 Sep 2026 11:20:06 -0600 Subject: [PATCH] Add branded default provider and enforced enrollment --- README.md | 1 + docs/architecture.md | 7 + docs/client-enrollment.md | 102 ++++++++++++ docs/lab-runbook.md | 7 +- docs/windows-client-onboarding.md | 9 + scripts/Deploy-AuthBroker.ps1 | 3 + scripts/Enroll-SguDomainClient.ps1 | 127 +++++++++++++++ scripts/Install-CredentialProvider.ps1 | 26 ++- scripts/Install-SguEnrollmentGuard.ps1 | 134 +++++++++++++++ scripts/Register-SguClientCertificate.ps1 | 67 ++++++++ scripts/Repair-SguClientEnrollment.ps1 | 47 ++++++ scripts/Test-SguClientEnrollment.ps1 | 154 ++++++++++++++++++ scripts/Uninstall-CredentialProvider.ps1 | 23 +++ src/SGU.AuthBroker/Options/BrokerOptions.cs | 7 + .../Services/ActiveDirectorySynchronizer.cs | 28 ++-- src/SGU.AuthBroker/appsettings.json | 1 + src/SGU.CredentialProvider/ControlKeys.cs | 2 + .../ProviderTileIcon.cs | 26 +++ .../SguCredentialProvider.cs | 9 +- .../SguCredentialTile.cs | 1 + .../Program.cs | 7 + .../ProviderTileIconTests.cs | 16 ++ 22 files changed, 787 insertions(+), 17 deletions(-) create mode 100644 docs/client-enrollment.md create mode 100644 scripts/Enroll-SguDomainClient.ps1 create mode 100644 scripts/Install-SguEnrollmentGuard.ps1 create mode 100644 scripts/Register-SguClientCertificate.ps1 create mode 100644 scripts/Repair-SguClientEnrollment.ps1 create mode 100644 scripts/Test-SguClientEnrollment.ps1 diff --git a/README.md b/README.md index 5d61bb1..2fb7df0 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/architecture.md b/docs/architecture.md index 71021cf..57e2d6a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/client-enrollment.md b/docs/client-enrollment.md new file mode 100644 index 0000000..64e5fce --- /dev/null +++ b/docs/client-enrollment.md @@ -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: + +- +- diff --git a/docs/lab-runbook.md b/docs/lab-runbook.md index 77b20d8..282f7ed 100644 --- a/docs/lab-runbook.md +++ b/docs/lab-runbook.md @@ -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 diff --git a/docs/windows-client-onboarding.md b/docs/windows-client-onboarding.md index eb9be41..8d907aa 100644 --- a/docs/windows-client-onboarding.md +++ b/docs/windows-client-onboarding.md @@ -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. diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 index 9814b4d..97479d9 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -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 } } diff --git a/scripts/Enroll-SguDomainClient.ps1 b/scripts/Enroll-SguDomainClient.ps1 new file mode 100644 index 0000000..0fc1f38 --- /dev/null +++ b/scripts/Enroll-SguDomainClient.ps1 @@ -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 +} diff --git a/scripts/Install-CredentialProvider.ps1 b/scripts/Install-CredentialProvider.ps1 index 7642b4a..4df349c 100644 --- a/scripts/Install-CredentialProvider.ps1 +++ b/scripts/Install-CredentialProvider.ps1 @@ -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 } diff --git a/scripts/Install-SguEnrollmentGuard.ps1 b/scripts/Install-SguEnrollmentGuard.ps1 new file mode 100644 index 0000000..e26edbc --- /dev/null +++ b/scripts/Install-SguEnrollmentGuard.ps1 @@ -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' +} diff --git a/scripts/Register-SguClientCertificate.ps1 b/scripts/Register-SguClientCertificate.ps1 new file mode 100644 index 0000000..6a345c9 --- /dev/null +++ b/scripts/Register-SguClientCertificate.ps1 @@ -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 +} diff --git a/scripts/Repair-SguClientEnrollment.ps1 b/scripts/Repair-SguClientEnrollment.ps1 new file mode 100644 index 0000000..efe0c5e --- /dev/null +++ b/scripts/Repair-SguClientEnrollment.ps1 @@ -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 ' ')" +} diff --git a/scripts/Test-SguClientEnrollment.ps1 b/scripts/Test-SguClientEnrollment.ps1 new file mode 100644 index 0000000..7680255 --- /dev/null +++ b/scripts/Test-SguClientEnrollment.ps1 @@ -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 ' ')" +} diff --git a/scripts/Uninstall-CredentialProvider.ps1 b/scripts/Uninstall-CredentialProvider.ps1 index 48aea3f..c8afd7a 100644 --- a/scripts/Uninstall-CredentialProvider.ps1 +++ b/scripts/Uninstall-CredentialProvider.ps1 @@ -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.' diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs index e551b8c..3845fbc 100644 --- a/src/SGU.AuthBroker/Options/BrokerOptions.cs +++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs @@ -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 diff --git a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs index 4c3114b..a4053f6 100644 --- a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs +++ b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs @@ -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,23 +119,23 @@ 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, "displayName", profile.DisplayName); - SetOptionalProperty(user, "mail", profile.Email); - SetOptionalProperty(user, "title", profile.JobTitle); - SetOptionalProperty(user, "department", profile.Department); - SetOptionalProperty(user, "employeeType", profile.EmployeeType); - if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal)) + SetOptionalProperty(user, "company", defaultCompany); + if (profile is not null) { - SetOptionalProperty(user, "employeeID", profile.EmployeeNumber); + SetOptionalProperty(user, "displayName", profile.DisplayName); + SetOptionalProperty(user, "mail", profile.Email); + SetOptionalProperty(user, "title", profile.JobTitle); + SetOptionalProperty(user, "department", profile.Department); + SetOptionalProperty(user, "employeeType", profile.EmployeeType); + if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal)) + { + SetOptionalProperty(user, "employeeID", profile.EmployeeNumber); + } } user.CommitChanges(); diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json index 004a4b1..4c4af0c 100644 --- a/src/SGU.AuthBroker/appsettings.json +++ b/src/SGU.AuthBroker/appsettings.json @@ -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 } } diff --git a/src/SGU.CredentialProvider/ControlKeys.cs b/src/SGU.CredentialProvider/ControlKeys.cs index b2ebe77..7330f29 100644 --- a/src/SGU.CredentialProvider/ControlKeys.cs +++ b/src/SGU.CredentialProvider/ControlKeys.cs @@ -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"; diff --git a/src/SGU.CredentialProvider/ProviderTileIcon.cs b/src/SGU.CredentialProvider/ProviderTileIcon.cs index 68cc1d0..edb2678 100644 --- a/src/SGU.CredentialProvider/ProviderTileIcon.cs +++ b/src/SGU.CredentialProvider/ProviderTileIcon.cs @@ -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; + } } diff --git a/src/SGU.CredentialProvider/SguCredentialProvider.cs b/src/SGU.CredentialProvider/SguCredentialProvider.cs index 0e5bf71..1c57608 100644 --- a/src/SGU.CredentialProvider/SguCredentialProvider.cs +++ b/src/SGU.CredentialProvider/SguCredentialProvider.cs @@ -14,11 +14,18 @@ public sealed class SguCredentialProvider : CredentialProviderBase public override IEnumerable 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."); diff --git a/src/SGU.CredentialProvider/SguCredentialTile.cs b/src/SGU.CredentialProvider/SguCredentialTile.cs index 6cc950e..a7f47af 100644 --- a/src/SGU.CredentialProvider/SguCredentialTile.cs +++ b/src/SGU.CredentialProvider/SguCredentialTile.cs @@ -13,6 +13,7 @@ internal sealed class SguCredentialTile : CredentialTile2 public SguCredentialTile(CredentialProviderBase credentialProvider) : base(credentialProvider) { + GenericTileDisplayMode = GenericTileDisplayMode.DisplayAsDedicatedTile; } public SguCredentialTile(CredentialProviderBase credentialProvider, CredentialProviderUser user) diff --git a/tests/SGU.CredentialProvider.SmokeProbe/Program.cs b/tests/SGU.CredentialProvider.SmokeProbe/Program.cs index 9fc246d..891a53b 100644 --- a/tests/SGU.CredentialProvider.SmokeProbe/Program.cs +++ b/tests/SGU.CredentialProvider.SmokeProbe/Program.cs @@ -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 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 diff --git a/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs b/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs index c5d792c..dd3318f 100644 --- a/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs +++ b/tests/SGU.CredentialProvider.Tests/ProviderTileIconTests.cs @@ -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()); + LargeLabelControl heading = Assert.Single(controls.OfType()); + SguCredentialTile tile = Assert.IsType(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); + } }