Files
SGU-CredentialProvider/scripts/Install-CredentialProvider.ps1
T

328 lines
13 KiB
PowerShell

[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, 90)]
[int]$TimeoutSeconds = 90,
[switch]$DoNotSetAsDefaultCredentialProvider,
[switch]$InstallDotNetRuntime,
[string]$DotNetRuntimeInstallerPath
)
$ErrorActionPreference = 'Stop'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$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'
$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)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
}
function Test-DotNet10Runtime {
$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\.') {
return $true
}
}
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.'
}
if ($DotNetRuntimeInstallerPath) {
if (-not (Test-Path -LiteralPath $DotNetRuntimeInstallerPath -PathType Leaf)) {
throw 'DotNetRuntimeInstallerPath does not exist.'
}
$runtimeInstaller = Start-Process -FilePath $DotNetRuntimeInstallerPath `
-ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru
if ($runtimeInstaller.ExitCode -notin @(0, 1641, 3010)) {
throw "The Microsoft .NET 10 runtime installer returned $($runtimeInstaller.ExitCode)."
}
}
else {
$winget = Get-Command winget -ErrorAction SilentlyContinue
if (-not $winget) {
throw 'winget is unavailable. Supply the offline installer with -DotNetRuntimeInstallerPath.'
}
& $winget.Source install --id Microsoft.DotNet.Runtime.10 --exact --silent `
--accept-package-agreements --accept-source-agreements --disable-interactivity
if ($LASTEXITCODE -ne 0) {
throw 'winget could not install the Microsoft .NET 10 runtime.'
}
}
if (-not (Test-DotNet10Runtime)) {
throw 'The Microsoft .NET 10 runtime installation failed.'
}
}
$requiredFiles = @(
'SGU.CredentialProvider.dll',
'SGU.CredentialProvider.comhost.dll',
'SGU.CredentialProvider.runtimeconfig.json',
'SGU.CredentialProvider.deps.json',
'Lithnet.CredentialProvider.dll',
'SGU.AuthBroker.Core.dll'
)
foreach ($file in $requiredFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
throw "PublishPath is missing $file."
}
}
$resolvedPublishPath = (Resolve-Path -LiteralPath $PublishPath).Path.TrimEnd('\')
$packageManifest = Get-ChildItem -LiteralPath $resolvedPublishPath -Recurse -File |
Sort-Object FullName |
ForEach-Object {
$relativePath = $_.FullName.Substring($resolvedPublishPath.Length).TrimStart('\')
'{0}={1}' -f $relativePath, (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
}
$manifestBytes = [Text.Encoding]::UTF8.GetBytes(($packageManifest -join "`n"))
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$packageHash = -join ($sha256.ComputeHash($manifestBytes) | ForEach-Object { $_.ToString('x2') })
}
finally {
$sha256.Dispose()
}
$versionId = $packageHash.Substring(0, 16)
$installPath = Join-Path $installRoot "versions\$versionId"
$completeMarker = Join-Path $installPath '.complete'
$completeMarkerValid = $false
if (Test-Path -LiteralPath $completeMarker -PathType Leaf) {
try {
$completeMarkerValid = [IO.File]::ReadAllText($completeMarker).Trim() -eq $packageHash
}
catch {
# Treat an unreadable marker as an incomplete installation. Never reuse
# a version directory unless its marker proves that every package byte
# represented by packageHash finished installing.
}
}
if ((Test-Path -LiteralPath $installPath) -and -not $completeMarkerValid) {
$installPath = '{0}-{1}' -f $installPath, ([Guid]::NewGuid().ToString('N').Substring(0, 8))
$completeMarker = Join-Path $installPath '.complete'
}
$clientThumbprint = $ClientCertificateThumbprint -replace ' ', ''
$serverThumbprint = $ServerCertificateThumbprint -replace ' ', ''
if ($clientThumbprint.Length -ne 40 -or $serverThumbprint.Length -ne 40) {
throw 'Certificate thumbprints must contain exactly 40 hexadecimal characters.'
}
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object Thumbprint -eq $clientThumbprint |
Select-Object -First 1
if (-not $clientCertificate -or -not $clientCertificate.HasPrivateKey) {
throw 'The client certificate with private key is not installed in LocalMachine\My.'
}
if (-not $clientCertificate.Verify()) {
throw 'The client certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
}
$serverCertificate = Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA | Where-Object Thumbprint -eq $serverThumbprint
if (-not $serverCertificate) {
throw 'The broker server certificate or its issuing CA is not trusted by LocalMachine.'
}
if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credential Provider')) {
if (-not (Test-Path -LiteralPath $completeMarker)) {
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
Copy-Item -Path (Join-Path $resolvedPublishPath '*') -Destination $installPath -Recurse -Force
[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
DomainNetbios = $DomainNetbios
TimeoutSeconds = $TimeoutSeconds
ClientCertificateThumbprint = $clientThumbprint
ServerCertificateThumbprint = $serverThumbprint
} | ConvertTo-Json
$utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($settingsPath, $settingsJson, $utf8WithoutBom)
$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(
$systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
$administratorsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
Set-Acl -LiteralPath (Split-Path $settingsPath -Parent) -AclObject $acl
New-Item -Path $classRegistryPath -Force | Out-Null
Set-Item -Path $classRegistryPath -Value (Join-Path $installPath 'SGU.CredentialProvider.comhost.dll')
New-ItemProperty -Path $classRegistryPath -Name ThreadingModel -Value Both -PropertyType String -Force | Out-Null
New-Item -Path $providerRegistryPath -Force | Out-Null
# Windows PowerShell 5.1 interprets an UTF-8 script without a BOM using the
# current ANSI code page. Construct the middle dot from its Unicode value so
# the LogonUI registry label remains correct on every client locale.
Set-Item -Path $providerRegistryPath -Value ('La Salle {0} Acceso SGU' -f [char]0x00B7)
if (-not $DoNotSetAsDefaultCredentialProvider) {
if (-not (Test-Path -LiteralPath $defaultProviderPolicyPath)) {
New-Item -Path $defaultProviderPolicyPath -Force | Out-Null
}
New-ItemProperty -Path $defaultProviderPolicyPath `
-Name DefaultCredentialProvider `
-Value $providerClassId `
-PropertyType String `
-Force | Out-Null
}
# Do not leave a signed-out SGU identity exposed as a persistent user tile.
# The Microsoft password provider remains registered and supplies Other user.
if (-not (Test-Path -LiteralPath $interactiveLogonPolicyPath)) {
New-Item -Path $interactiveLogonPolicyPath -Force | Out-Null
}
New-ItemProperty -Path $interactiveLogonPolicyPath `
-Name DontDisplayLastUserName `
-Value 1 `
-PropertyType DWord `
-Force | Out-Null
if (-not (Test-Path -LiteralPath $defaultProviderPolicyPath)) {
New-Item -Path $defaultProviderPolicyPath -Force | Out-Null
}
New-ItemProperty -Path $defaultProviderPolicyPath `
-Name EnumerateLocalUsers `
-Value 0 `
-PropertyType DWord `
-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]@{
ProviderClassId = $providerClassId
InstallPath = $installPath
SettingsPath = $settingsPath
Registered = Test-Path -LiteralPath $providerRegistryPath
DefaultProviderConfigured = $defaultProviderConfigured
LastSignedInUserHidden = (Get-ItemPropertyValue `
-LiteralPath $interactiveLogonPolicyPath `
-Name DontDisplayLastUserName) -eq 1
LocalUserEnumerationDisabled = (Get-ItemPropertyValue `
-LiteralPath $defaultProviderPolicyPath `
-Name EnumerateLocalUsers) -eq 0
SystemPasswordProviderPreserved = $true
}