Compare commits

...
9 Commits
21 changed files with 475 additions and 91 deletions
+3
View File
@@ -8,6 +8,9 @@ The repository starts from the current
source and adds an SGU-specific provider, an mTLS-protected broker, Active
Directory synchronization, deployment scripts, and tests.
Ready-to-run bootstrap packages are published on the
[releases page](https://github.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
## Authentication contract
1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password.
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

+4 -1
View File
@@ -85,7 +85,10 @@ 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, hides the last signed-in identity, and
disables local-user enumeration while retaining the built-in Microsoft password
provider and its **Other user** recovery path. It enumerates one
provider and its **Other user** recovery path. The computer GPO also applies
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
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**
+12 -5
View File
@@ -52,10 +52,17 @@ El proceso crea o configura de forma idempotente:
- GPO de experiencia del equipo y restricciones de sesión SGU;
- certificado de servidor no exportable y broker mTLS en TCP 8443;
- recurso `\\SERVIDOR\Packages`, con lectura para Domain Computers;
- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas sólo en el
perfil Domain;
- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas limitadas a
la subred privada indicada, incluso si Windows tarda en reconocer el perfil
Domain después de la promoción;
- pantalla, suspensión e hibernación en Nunca.
En un servidor con dos NIC, el bootstrap desactiva el registro DNS de la NIC de
Internet y obliga al servicio DNS a escuchar y publicar únicamente la IP fija
privada. También vuelve a iniciar brevemente esa NIC privada si Windows Server
2025 todavía la clasifica como Public al terminar la promoción. La salida HTTPS
continúa por la NIC que tenga el gateway predeterminado.
El broker arranca con una lista de clientes vacía. Eso no abre el servicio: mTLS
rechaza todos los certificados hasta que el primer cliente registra el suyo.
Los archivos opcionales colocados en `payload\server-content\Packages` al crear
@@ -129,8 +136,8 @@ validaciones.
Desde el repositorio y con el SDK fijado en `global.json`:
```powershell
.\scripts\New-SguBootstrapPackages.ps1 -Version 0.1.0
.\scripts\Publish-GiteaRelease.ps1 -Version 0.1.0
.\scripts\New-SguBootstrapPackages.ps1 -Version 0.1.1
.\scripts\Publish-GiteaRelease.ps1 -Version 0.1.1
```
El segundo comando usa `GITEA_TOKEN` sólo en memoria o, si no está definido,
@@ -139,7 +146,7 @@ la línea de comandos. Para empaquetar recursos institucionales adicionales:
```powershell
.\scripts\New-SguBootstrapPackages.ps1 `
-Version 0.1.0 `
-Version 0.1.1 `
-ServerContentPath C:\Preparacion\Packages
```
+29 -3
View File
@@ -36,6 +36,9 @@ param(
[int]$NtlmTimeoutSeconds = 20,
[ValidateRange(2, 90)]
[int]$ProfileTimeoutSeconds = 90,
[ValidateNotNullOrEmpty()]
[string[]]$FirewallRemoteAddress = @('LocalSubnet'),
[ipaddress]$FirewallLocalAddress,
[switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab
)
@@ -215,9 +218,32 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
throw 'Could not enable recovery for non-crash SGUAuthBroker failures.'
}
if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 8443 -Profile Domain | Out-Null
$firewallRule = Get-NetFirewallRule `
-DisplayName 'SGU Authentication Broker (mTLS)' `
-ErrorAction SilentlyContinue
if (-not $firewallRule) {
$firewallParameters = @{
DisplayName = 'SGU Authentication Broker (mTLS)'
Direction = 'Inbound'
Action = 'Allow'
Protocol = 'TCP'
LocalPort = 8443
Profile = 'Any'
RemoteAddress = $FirewallRemoteAddress
}
if ($FirewallLocalAddress) {
$firewallParameters.LocalAddress = $FirewallLocalAddress.IPAddressToString
}
$firewallRule = New-NetFirewallRule @firewallParameters
}
else {
$firewallRule | Set-NetFirewallRule -Enabled True -Profile Any
$addressParameters = @{ RemoteAddress = $FirewallRemoteAddress }
if ($FirewallLocalAddress) {
$addressParameters.LocalAddress = $FirewallLocalAddress.IPAddressToString
}
$firewallRule | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter @addressParameters | Out-Null
}
Start-Service -Name $serviceName
+16 -8
View File
@@ -20,6 +20,20 @@ $remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1]
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesktopPrincipal access")) {
function Invoke-PowerCfgBestEffort {
param([Parameter(Mandatory)][string[]]$Arguments)
# Start-Process keeps powercfg's policy-override diagnostic on its own
# stderr stream. In PowerShell 7, directly invoking that native command
# turns stderr into a terminating ErrorRecord under $ErrorActionPreference
# = 'Stop', which previously aborted this unrelated remediation work.
$process = Start-Process -FilePath "$env:SystemRoot\System32\powercfg.exe" `
-ArgumentList $Arguments -Wait -PassThru -WindowStyle Hidden
if ($process.ExitCode -ne 0) {
Write-Warning "powercfg $($Arguments -join ' ') returned exit code $($process.ExitCode); continuing enrollment repair."
}
}
foreach ($powerChange in @(
@('monitor-timeout-ac', '0'),
@('monitor-timeout-dc', '0'),
@@ -27,15 +41,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesk
@('standby-timeout-dc', '0'),
@('hibernate-timeout-ac', '0'),
@('hibernate-timeout-dc', '0'))) {
& powercfg.exe /change $powerChange[0] $powerChange[1]
if ($LASTEXITCODE -ne 0) {
throw "powercfg /change $($powerChange[0]) failed with exit code $LASTEXITCODE."
}
}
& powercfg.exe /hibernate off
if ($LASTEXITCODE -ne 0) {
throw "powercfg /hibernate off failed with exit code $LASTEXITCODE."
Invoke-PowerCfgBestEffort -Arguments @('/change', $powerChange[0], $powerChange[1])
}
Invoke-PowerCfgBestEffort -Arguments @('/hibernate', 'off')
Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
-Name fDenyTSConnections -Type DWord -Value 0
+23 -10
View File
@@ -1,5 +1,8 @@
[CmdletBinding(SupportsShouldProcess)]
param()
param(
[ValidateNotNullOrEmpty()]
[string[]]$AllowedRemoteAddress = @('LocalSubnet')
)
$ErrorActionPreference = 'Stop'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
@@ -38,9 +41,12 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
Set-Service -Name TermService -StartupType Automatic
Start-Service -Name TermService
Get-NetFirewallRule -Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' `
-ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$remoteDesktopRules = @(Get-NetFirewallRule `
-Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' `
-ErrorAction SilentlyContinue)
$remoteDesktopRules | Set-NetFirewallRule -Enabled True -Profile Any
$remoteDesktopRules | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
$enableRemoting = Start-Process `
-FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
@@ -57,9 +63,12 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
Set-Service -Name WinRM -StartupType Automatic
Start-Service -Name WinRM
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' `
-ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$winRmRules = @(Get-NetFirewallRule `
-Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' `
-ErrorAction SilentlyContinue)
$winRmRules | Set-NetFirewallRule -Enabled True -Profile Any
$winRmRules | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP-PUBLIC' -ErrorAction SilentlyContinue |
Disable-NetFirewallRule
@@ -74,8 +83,11 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
'WMI-WINMGMT-In-TCP',
'WMI-ASYNC-In-TCP'
)
Get-NetFirewallRule -Name $administrativeRules -ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$enabledAdministrativeRules = @(Get-NetFirewallRule `
-Name $administrativeRules -ErrorAction SilentlyContinue)
$enabledAdministrativeRules | Set-NetFirewallRule -Enabled True -Profile Any
$enabledAdministrativeRules | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
}
[pscustomobject]@{
@@ -88,7 +100,8 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
-Name UserAuthentication) -eq 1
TermService = (Get-Service TermService).Status
WinRM = (Get-Service WinRM).Status
FirewallProfile = 'Domain'
FirewallProfile = 'Any'
AllowedRemoteAddress = $AllowedRemoteAddress
AdministrativeAccessOnly = $true
AlwaysOnPowerPolicyApplied = $true
}
+157 -12
View File
@@ -171,8 +171,10 @@ function Ensure-OrganizationalUnit {
)
$distinguishedName = "OU=$Name,$Path"
$existing = Get-ADOrganizationalUnit -Identity $distinguishedName -Server $Server `
-ErrorAction SilentlyContinue
$escapedName = $Name.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29')
$existing = Get-ADOrganizationalUnit -LDAPFilter "(ou=$escapedName)" `
-SearchBase $Path -SearchScope OneLevel -Server $Server -ErrorAction Stop |
Select-Object -First 1
if (-not $existing) {
New-ADOrganizationalUnit -Name $Name -Path $Path `
-ProtectedFromAccidentalDeletion $true -Server $Server | Out-Null
@@ -180,6 +182,57 @@ function Ensure-OrganizationalUnit {
return $distinguishedName
}
function Wait-ActiveDirectoryReady {
param(
[Parameter(Mandatory)][string]$ExpectedBaseDn,
[ValidateRange(1, 120)][int]$Attempts = 36,
[ValidateRange(1, 30)][int]$DelaySeconds = 5
)
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
$rootDse = Get-ADRootDSE -Server localhost -ErrorAction Stop
if ($rootDse.DefaultNamingContext -eq $ExpectedBaseDn) {
return
}
}
catch {
if ($attempt -eq $Attempts) {
throw
}
}
Start-Sleep -Seconds $DelaySeconds
}
throw "Active Directory did not publish $ExpectedBaseDn before the readiness timeout."
}
function Wait-DnsZoneReady {
param(
[Parameter(Mandatory)][string]$ZoneName,
[Parameter(Mandatory)][ipaddress]$DnsServer,
[ValidateRange(1, 120)][int]$Attempts = 30,
[ValidateRange(1, 30)][int]$DelaySeconds = 2
)
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
$soa = @(Resolve-DnsName $ZoneName -Type SOA -DnsOnly `
-Server $DnsServer.IPAddressToString -ErrorAction Stop |
Where-Object Type -eq SOA)
if ($soa.Count -gt 0) {
return
}
}
catch {
if ($attempt -eq $Attempts) {
throw
}
}
Start-Sleep -Seconds $DelaySeconds
}
throw "DNS did not load the $ZoneName zone before the readiness timeout."
}
function Set-PackageShare {
param(
[Parameter(Mandatory)][string]$Path,
@@ -234,6 +287,10 @@ function Set-PackageShare {
}
Assert-Administrator
trap {
Write-BootstrapLog ("ERROR: " + $_.Exception.Message)
throw
}
$operatingSystem = Get-CimInstance Win32_OperatingSystem
if ([int]$operatingSystem.ProductType -eq 1) {
throw 'The domain controller bootstrap requires Windows Server, not a Windows client edition.'
@@ -379,24 +436,75 @@ if (-not $computer.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreC
}
Write-BootstrapLog 'Finalizing Active Directory, DNS, policies, broker, shares, and remote management.'
Import-Module ActiveDirectory -ErrorAction Stop
$domain = Get-ADDomain -Identity $DomainName -Server $env:COMPUTERNAME
$laboratoryOuDn = Ensure-OrganizationalUnit -Name 'Laboratorio' -Path $baseDn -Server $env:COMPUTERNAME
$usersOuDn = Ensure-OrganizationalUnit -Name 'Usuarios-SGU' -Path $baseDn -Server $env:COMPUTERNAME
# Once the machine is a DC, every active adapter must query the local DNS
# service. Only the private domain adapter may publish its address in the AD
# zone; otherwise clients can receive the DHCP/NAT address of the Internet NIC.
Get-NetAdapter | Where-Object Status -eq 'Up' | ForEach-Object {
Set-DnsClientServerAddress -InterfaceIndex $_.ifIndex `
-ServerAddresses $ServerIPv4Address.IPAddressToString
Set-DnsClient -InterfaceIndex $_.ifIndex `
-RegisterThisConnectionsAddress:($_.Name -eq $NetworkInterfaceAlias)
}
Clear-DnsClientCache
Register-DnsClient
Import-Module ActiveDirectory -ErrorAction Stop
Wait-ActiveDirectoryReady -ExpectedBaseDn $baseDn
# A newly promoted Windows Server 2025 DC can retain the Public firewall
# profile because network identification ran before local DNS and LDAP were
# ready. A private-adapter bounce triggers the supported domain-detection path.
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
-ErrorAction SilentlyContinue
if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') {
Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile."
Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false
for ($attempt = 1; $attempt -le 15; $attempt++) {
Start-Sleep -Seconds 2
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
-ErrorAction SilentlyContinue
if ($domainProfile -and $domainProfile.NetworkCategory -eq 'DomainAuthenticated') {
break
}
}
}
# Apply the single-address DNS listener only after any adapter refresh. That
# avoids transient DNS socket errors while the private address is momentarily
# unavailable, while still preventing the Internet/NAT address from being
# published once finalization completes.
New-ItemProperty `
-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\DNS\Parameters' `
-Name PublishAddresses `
-PropertyType String `
-Value $ServerIPv4Address.IPAddressToString `
-Force | Out-Null
$dnsServerSetting = Get-DnsServerSetting -All -WarningAction SilentlyContinue
$dnsServerSetting.ListeningIPAddress = @($ServerIPv4Address)
Set-DnsServerSetting -InputObject $dnsServerSetting -WarningAction SilentlyContinue | Out-Null
Restart-Service DNS -Force
Wait-DnsZoneReady -ZoneName $DomainName -DnsServer $ServerIPv4Address
$adServer = 'localhost'
$domain = Get-ADDomain -Identity $DomainName -Server $adServer
$laboratoryOuDn = Ensure-OrganizationalUnit -Name 'Laboratorio' -Path $baseDn -Server $adServer
$usersOuDn = Ensure-OrganizationalUnit -Name 'Usuarios-SGU' -Path $baseDn -Server $adServer
foreach ($ouName in @('Docentes', 'Alumnos', 'Administrativos')) {
Ensure-OrganizationalUnit -Name $ouName -Path $usersOuDn -Server $env:COMPUTERNAME | Out-Null
Ensure-OrganizationalUnit -Name $ouName -Path $usersOuDn -Server $adServer | Out-Null
}
$remoteDesktopGroupName = 'SG-Laboratorio-Usuarios-RDP'
$remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME `
$remoteDesktopGroup = Get-ADGroup -LDAPFilter "(sAMAccountName=$remoteDesktopGroupName)" `
-SearchBase $baseDn -SearchScope Subtree -Server $adServer `
-ErrorAction SilentlyContinue
if (-not $remoteDesktopGroup) {
New-ADGroup -Name $remoteDesktopGroupName -SamAccountName $remoteDesktopGroupName `
-GroupCategory Security -GroupScope Global -Path $laboratoryOuDn `
-Description 'SGU users permitted to use Remote Desktop on laboratory clients.' `
-Server $env:COMPUTERNAME | Out-Null
$remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME
-Server $adServer | Out-Null
$remoteDesktopGroup = Get-ADGroup -LDAPFilter "(sAMAccountName=$remoteDesktopGroupName)" `
-SearchBase $laboratoryOuDn -SearchScope OneLevel -Server $adServer
}
& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') `
@@ -444,16 +552,30 @@ if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) {
-PublishPath $brokerPublishPath `
-ServerCertificateSubject $brokerDnsName `
-AllowedClientThumbprints $allowedClientThumbprints `
-LdapHost $env:COMPUTERNAME `
-LdapHost $adServer `
-BaseDn $baseDn `
-DomainNetbios $DomainNetbios `
-UpnSuffix $DomainName `
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
-DefaultCompany 'La Salle' `
-FirewallLocalAddress $ServerIPv4Address `
-FirewallRemoteAddress "$($ServerIPv4Address.IPAddressToString)/$PrefixLength" `
-CreateMissingOus `
-DisableCertificateRevocationCheckForLab | Out-Null
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') | Out-Null
# Remove stale A records registered by any non-domain/NAT adapter before its
# dynamic DNS registration was disabled.
$hostRecords = @(Get-DnsServerResourceRecord -ZoneName $DomainName `
-Name $env:COMPUTERNAME -RRType A -ErrorAction SilentlyContinue)
foreach ($hostRecord in $hostRecords) {
if ($hostRecord.RecordData.IPv4Address.IPAddressToString -ne $ServerIPv4Address.IPAddressToString) {
Remove-DnsServerResourceRecord -ZoneName $DomainName -InputObject $hostRecord -Force
}
}
$privateSubnet = "$($ServerIPv4Address.IPAddressToString)/$PrefixLength"
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
-AllowedRemoteAddress $privateSubnet | Out-Null
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
if (Test-Path -LiteralPath $contentPath -PathType Container) {
@@ -463,6 +585,27 @@ if (Test-Path -LiteralPath $contentPath -PathType Container) {
Set-PackageShare -Path $PackageSharePath -NetbiosName $DomainNetbios `
-DomainSid $domain.DomainSID.Value
$packageFirewallRule = Get-NetFirewallRule -DisplayName 'SGU Bootstrap Packages (SMB)' `
-ErrorAction SilentlyContinue
if (-not $packageFirewallRule) {
New-NetFirewallRule `
-DisplayName 'SGU Bootstrap Packages (SMB)' `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 445 `
-LocalAddress $ServerIPv4Address.IPAddressToString `
-RemoteAddress $privateSubnet `
-Profile Any | Out-Null
}
else {
$packageFirewallRule | Set-NetFirewallRule -Enabled True -Profile Any
$packageFirewallRule | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter `
-LocalAddress $ServerIPv4Address.IPAddressToString `
-RemoteAddress $privateSubnet | Out-Null
}
& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') `
-TargetOuDn $laboratoryOuDn -DomainController $env:COMPUTERNAME | Out-Null
$userPolicyParameters = @{
@@ -493,6 +636,8 @@ $validation = [ordered]@{
LaboratoryOu = $laboratoryOuDn
UsersOu = $usersOuDn
RemoteDesktopGroup = $remoteDesktopGroup.DistinguishedName
DomainNetworkProfile = [string](Get-NetConnectionProfile `
-InterfaceAlias $NetworkInterfaceAlias -ErrorAction SilentlyContinue).NetworkCategory
}
if ($validation.BrokerService -ne 'Running' -or
+76 -2
View File
@@ -35,6 +35,8 @@ $providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authent
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
$defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\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()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
@@ -56,6 +58,70 @@ function Test-DotNet10Runtime {
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 $InstallDotNetRuntime) {
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))
}
# 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
$settingsJson = @{
BrokerEndpoint = $BrokerEndpoint
@@ -179,10 +249,14 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
$acl = Get-Acl -LiteralPath (Split-Path $settingsPath -Parent)
$acl.SetAccessRuleProtection($true, $false)
# Resolve built-in identities by SID instead of localized display names.
# "BUILTIN\Administrators" is not resolvable on every non-English client.
$systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$administratorsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
Set-Acl -LiteralPath (Split-Path $settingsPath -Parent) -AclObject $acl
New-Item -Path $classRegistryPath -Force | Out-Null
+9 -2
View File
@@ -71,8 +71,12 @@ if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair
$runtimeDirectory = Join-Path $enrollmentRoot 'prerequisites'
New-Item -ItemType Directory -Path $runtimeDirectory -Force | Out-Null
$guardRuntimeInstaller = Join-Path $runtimeDirectory (Split-Path $DotNetRuntimeInstallerPath -Leaf)
$sourceRuntimeInstaller = [IO.Path]::GetFullPath($DotNetRuntimeInstallerPath)
$destinationRuntimeInstaller = [IO.Path]::GetFullPath($guardRuntimeInstaller)
if (-not $sourceRuntimeInstaller.Equals($destinationRuntimeInstaller, [StringComparison]::OrdinalIgnoreCase)) {
Copy-Item -LiteralPath $DotNetRuntimeInstallerPath -Destination $guardRuntimeInstaller -Force
}
}
$guardConfiguration = [ordered]@{
PublishPath = $guardPublishPath
@@ -92,10 +96,13 @@ if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair
$acl = Get-Acl -LiteralPath $enrollmentRoot
$acl.SetAccessRuleProtection($true, $false)
# Well-known SIDs are invariant across localized Windows installations.
$systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$administratorsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
Set-Acl -LiteralPath $enrollmentRoot -AclObject $acl
$repairScript = Join-Path $enrollmentRoot 'Repair-SguClientEnrollment.ps1'
+2
View File
@@ -111,6 +111,8 @@ Copy-Item -Path (Join-Path $providerOutput '*') `
-Destination (New-Item -ItemType Directory `
-Path (Join-Path $clientRoot 'payload\credential-provider') -Force).FullName `
-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 `
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
+1
View File
@@ -97,6 +97,7 @@ try {
$releaseNotes = @"
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.
- `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.
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
+57 -10
View File
@@ -8,18 +8,65 @@ param(
)
$ErrorActionPreference = 'Stop'
$existing = Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName -RRType A -ErrorAction SilentlyContinue
if ($existing) {
$current = @($existing.RecordData.IPv4Address.IPAddressToString)
if ($current.Count -ne 1 -or $current[0] -ne $IPv4Address.IPAddressToString) {
# The fixed lab address is an explicit bootstrap input and may change
# when the server is rebuilt. Replace only this exact A record set.
$existing | Remove-DnsServerResourceRecord -ZoneName $ZoneName -Force
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
$dnsReady = $false
for ($attempt = 1; $attempt -le 30; $attempt++) {
try {
$soa = @(Resolve-DnsName $ZoneName -Type SOA -DnsOnly -Server localhost `
-ErrorAction Stop | Where-Object Type -eq SOA)
if ($soa.Count -gt 0) {
$dnsReady = $true
break
}
}
else {
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
catch {
# An AD-integrated zone can take a few seconds to load after DNS starts.
}
Start-Sleep -Seconds 2
}
if (-not $dnsReady) {
throw "DNS did not load the $ZoneName zone before the readiness timeout."
}
$recordReady = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$existing = @(Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName `
-RRType A -ErrorAction SilentlyContinue)
$unwanted = @($existing | Where-Object {
$_.RecordData.IPv4Address.IPAddressToString -ne $IPv4Address.IPAddressToString
})
foreach ($record in $unwanted) {
Remove-DnsServerResourceRecord -ZoneName $ZoneName -InputObject $record -Force
}
$desired = @($existing | Where-Object {
$_.RecordData.IPv4Address.IPAddressToString -eq $IPv4Address.IPAddressToString
})
if ($desired.Count -eq 0) {
try {
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName `
-IPv4Address $IPv4Address -ErrorAction Stop
}
catch {
# A record that becomes visible while an AD-integrated zone is
# finishing its load is harmless; the verified read below decides.
}
}
Start-Sleep -Milliseconds 250
$final = @(Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName `
-RRType A -ErrorAction SilentlyContinue)
$finalAddresses = @($final | ForEach-Object {
$_.RecordData.IPv4Address.IPAddressToString
})
if ($finalAddresses.Count -eq 1 -and
$finalAddresses[0] -eq $IPv4Address.IPAddressToString) {
$recordReady = $true
break
}
Start-Sleep -Seconds 1
}
if (-not $recordReady) {
throw "The $RecordName.$ZoneName A record could not be set exclusively to $IPv4Address."
}
if ($ExternalForwarders.Count -gt 0) {
+26 -12
View File
@@ -61,15 +61,29 @@ elseif (-not $existingLinkEnabled -and
$dataCollectionKey = 'HKLM\Software\Policies\Microsoft\Windows\DataCollection'
$powerPolicyRoot = 'HKLM\Software\Policies\Microsoft\Power\PowerSettings'
$credentialProviderPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\System'
$interactiveLogonPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System'
$accountPicturePolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$policies = @(
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Value = 0 },
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Value = 1 },
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInChangeNotification'; Value = 1 },
@{ Key = $dataCollectionKey; Name = 'DisableDiagnosticDataViewer'; Value = 1 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\OOBE'; Name = 'DisablePrivacyExperience'; Value = 1 },
@{ Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System'; Name = 'EnableFirstLogonAnimation'; Value = 0 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\LocationAndSensors'; Name = 'DisableLocation'; Value = 1 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\AppPrivacy'; Name = 'LetAppsAccessLocation'; Value = 2 }
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 },
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Type = 'DWord'; Value = 1 },
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInChangeNotification'; Type = 'DWord'; Value = 1 },
@{ Key = $dataCollectionKey; Name = 'DisableDiagnosticDataViewer'; Type = 'DWord'; Value = 1 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\OOBE'; Name = 'DisablePrivacyExperience'; Type = 'DWord'; Value = 1 },
@{ Key = $interactiveLogonPolicyKey; Name = 'EnableFirstLogonAnimation'; Type = 'DWord'; Value = 0 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\LocationAndSensors'; Name = 'DisableLocation'; Type = 'DWord'; Value = 1 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\AppPrivacy'; Name = 'LetAppsAccessLocation'; Type = 'DWord'; Value = 2 },
# Enrollment selects the provider before domain join; this computer GPO
# becomes the authoritative, self-healing configuration afterwards.
@{ Key = $credentialProviderPolicyKey; Name = 'DefaultCredentialProvider'; Type = 'String'; Value = $providerClassId },
@{ Key = $credentialProviderPolicyKey; Name = 'EnumerateLocalUsers'; Type = 'DWord'; Value = 0 },
@{ Key = $interactiveLogonPolicyKey; Name = 'DontDisplayLastUserName'; Type = 'DWord'; Value = 1 },
# Use Windows' native default account image for named user tiles. LogonUI
# retains ownership of the anonymous Other user tile and its circular mask.
@{ Key = $accountPicturePolicyKey; Name = 'UseDefaultTile'; Type = 'DWord'; Value = 1 }
)
$powerSettingIds = @(
@@ -80,8 +94,8 @@ $powerSettingIds = @(
)
foreach ($settingId in $powerSettingIds) {
$settingKey = "$powerPolicyRoot\$settingId"
$policies += @{ Key = $settingKey; Name = 'ACSettingIndex'; Value = 0 }
$policies += @{ Key = $settingKey; Name = 'DCSettingIndex'; Value = 0 }
$policies += @{ Key = $settingKey; Name = 'ACSettingIndex'; Type = 'DWord'; Value = 0 }
$policies += @{ Key = $settingKey; Name = 'DCSettingIndex'; Type = 'DWord'; Value = 0 }
}
foreach ($policy in $policies) {
@@ -92,7 +106,7 @@ foreach ($policy in $policies) {
-Server $DomainController `
-Key $policy.Key `
-ValueName $policy.Name `
-Type DWord `
-Type $policy.Type `
-Value $policy.Value | Out-Null
}
}
@@ -105,7 +119,7 @@ foreach ($policy in $policies) {
-Server $DomainController `
-Key $policy.Key `
-ValueName $policy.Name
$configuredPolicies[$policy.Name + '@' + $policy.Key] = [int]$configured.Value
$configuredPolicies[$policy.Name + '@' + $policy.Key] = $configured.Value
}
$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks |
Where-Object DisplayName -eq $GpoName |
+27
View File
@@ -10,6 +10,7 @@ $ErrorActionPreference = 'Stop'
$policyKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System'
$policyValueName = 'DisableLockWorkstation'
$desktopPolicyKey = 'HKCU\Software\Policies\Microsoft\Windows\Control Panel\Desktop'
$themeKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
@@ -83,6 +84,19 @@ if ($PSCmdlet.ShouldProcess($GpoName, 'Prevent SGU users from manually locking w
-Type String `
-Value '0' | Out-Null
# Apply the native Windows dark theme at user logon. Both values are required:
# one controls the shell and the other controls supported applications.
foreach ($themeValueName in 'AppsUseLightTheme', 'SystemUsesLightTheme') {
Set-GPRegistryValue `
-Name $GpoName `
-Domain $domainName `
-Server $DomainController `
-Key $themeKey `
-ValueName $themeValueName `
-Type DWord `
-Value 0 | Out-Null
}
if ($WallpaperPath) {
Set-GPRegistryValue `
-Name $GpoName `
@@ -115,6 +129,18 @@ $screenSaverValue = Get-GPRegistryValue `
-Server $DomainController `
-Key $desktopPolicyKey `
-ValueName 'ScreenSaveActive'
$appsThemeValue = Get-GPRegistryValue `
-Name $GpoName `
-Domain $domainName `
-Server $DomainController `
-Key $themeKey `
-ValueName 'AppsUseLightTheme'
$systemThemeValue = Get-GPRegistryValue `
-Name $GpoName `
-Domain $domainName `
-Server $DomainController `
-Key $themeKey `
-ValueName 'SystemUsesLightTheme'
$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks |
Where-Object DisplayName -eq $GpoName |
Select-Object -First 1
@@ -138,5 +164,6 @@ if ($WallpaperPath) {
LinkEnabled = [bool]$linkEnabled
DisableLockWorkstation = [int]$configuredValue.Value
ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0'
DarkMode = ([int]$appsThemeValue.Value -eq 0) -and ([int]$systemThemeValue.Value -eq 0)
Wallpaper = $configuredWallpaper
}
+14 -17
View File
@@ -1,35 +1,32 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Drawing.Drawing2D;
namespace SGU.CredentialProvider;
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()
{
Bitmap bitmap = new(Size, Size, PixelFormat.Format32bppArgb);
using Graphics graphics = Graphics.FromImage(bitmap);
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.CompositingQuality = CompositingQuality.HighQuality;
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
graphics.Clear(Color.Transparent);
using SolidBrush background = new(Color.FromArgb(0, 83, 155));
graphics.FillEllipse(background, 1, 1, Size - 2, Size - 2);
using Pen key = new(Color.White, 5.5f)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
graphics.DrawEllipse(key, 14, 14, 25, 25);
graphics.DrawLine(key, 35, 35, 57, 57);
graphics.DrawLine(key, 47, 47, 55, 39);
graphics.DrawLine(key, 53, 53, 61, 45);
using Stream sourceStream = typeof(ProviderTileIcon).Assembly.GetManifestResourceStream(MascotResourceName)
?? throw new InvalidOperationException($"The branded Credential Provider logo '{MascotResourceName}' is unavailable.");
using Bitmap mascot = new(sourceStream);
using GraphicsPath circularMask = new();
circularMask.AddEllipse(0, 0, Size, Size);
graphics.SetClip(circularMask);
graphics.DrawImage(mascot, new Rectangle(0, 0, Size, Size));
return bitmap;
}
@@ -23,6 +23,11 @@
<ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\..\assets\branding\lasalle-mexico-provider.png"
LogicalName="SGU.CredentialProvider.Branding.LaSalleMascot.png" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>SGU.CredentialProvider.Tests</_Parameter1>
@@ -7,7 +7,7 @@ namespace SGU.CredentialProvider.Tests;
public sealed class ProviderTileIconTests
{
[Fact]
public void ProviderPublishesASeventyTwoPixelLogoForSignInOptions()
public void ProviderPublishesAHighResolutionLogoForSignInOptions()
{
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(0, ProviderTileIcon.Size - 1).A);
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, ProviderTileIcon.Size - 1).A);
Assert.Equal(
Color.FromArgb(0, 83, 155).ToArgb(),
logo.Bitmap.GetPixel(6, ProviderTileIcon.Size / 2).ToArgb());
int lightPixels = 0;
int redPixels = 0;
int navyPixels = 0;
for (int x = 0; x < logo.Bitmap.Width; x++)
{
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]