Files

360 lines
16 KiB
PowerShell

[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$PublishPath,
[Parameter(Mandatory)]
[string]$ServerCertificateSubject,
[ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
[string[]]$AllowedClientThumbprints = @(),
[string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/',
[string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'),
[ValidatePattern('^/')]
[string]$AuthenticationPath = '/psulsa/',
[ValidatePattern('^/')]
[string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx',
[ValidatePattern('^/')]
[string]$AdministrativePersonalProfilePath = '/psulsa/gadmon/capitalhumano/datos/personales.aspx',
[ValidatePattern('^/')]
[string]$AdministrativeLocationProfilePath = '/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx',
[ValidatePattern('^/')]
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
[ValidatePattern('^/')]
[string]$ProfessorPayrollProfilePath = '/psulsa/gadmon/nomina/consultanomina.aspx',
[ValidatePattern('^/')]
[string]$MenuProfilePath = '/psulsa/menu.aspx',
[ValidateRange(32768, 2097152)]
[int]$MaxProfileBytes = 524288,
[string]$LdapHost = 'localhost',
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
[string]$DomainNetbios = 'LCI',
[string]$UpnSuffix = 'lci.lasalle.mx',
[string]$ProfessorGroupDn = '',
[string]$StudentGroupDn = '',
[string]$AdministrativeGroupDn = '',
[string]$RemoteDesktopGroupDn = '',
[ValidateLength(1, 64)]
[string]$DefaultCompany = 'La Salle',
[ValidateRange(10, 60)]
[int]$NtlmTimeoutSeconds = 20,
[ValidateRange(2, 90)]
[int]$ProfileTimeoutSeconds = 90,
[ValidateNotNullOrEmpty()]
[string[]]$FirewallRemoteAddress = @('LocalSubnet'),
[ipaddress]$FirewallLocalAddress,
[switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab
)
$ErrorActionPreference = 'Stop'
$serviceName = 'SGUAuthBroker'
$installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker'
$brokerEventLogName = 'SGU Auth Broker'
$brokerEventSource = 'SGU.AuthBroker.Operational'
$normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' })
if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) {
throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.'
}
$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.'
}
$serverCertificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object { $_.Subject -like "*$ServerCertificateSubject*" -and $_.HasPrivateKey } |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $serverCertificate) {
throw 'The HTTPS server certificate with private key was not found in LocalMachine\My.'
}
if (-not $serverCertificate.Verify()) {
throw 'The HTTPS server 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.'
}
Import-Module ActiveDirectory -ErrorAction Stop
function ConvertTo-LdapFilterValue {
param([Parameter(Mandatory)][string]$Value)
return $Value.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29').Replace(([string][char]0), '\00')
}
$usersOuName = 'Usuarios-SGU'
$usersOuDn = "OU=$usersOuName,$BaseDn"
if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) {
$ProfessorGroupDn = "CN=SGU-Docentes,OU=Docentes,$usersOuDn"
}
if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) {
$StudentGroupDn = "CN=SGU-Alumnos,OU=Alumnos,$usersOuDn"
}
if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) {
$AdministrativeGroupDn = "CN=SGU-Administrativos,OU=Administrativos,$usersOuDn"
}
if ($CreateMissingOus) {
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$usersOuName)" -SearchBase $BaseDn -SearchScope OneLevel -Server $LdapHost -ErrorAction SilentlyContinue)) {
New-ADOrganizationalUnit -Name $usersOuName -Path $BaseDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
}
foreach ($ouName in @('Docentes', 'Alumnos', 'Administrativos')) {
$targetOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $usersOuDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
if ($targetOu) {
if (-not $targetOu.ProtectedFromAccidentalDeletion) {
$targetOuDn = [string]$targetOu.DistinguishedName
Set-ADOrganizationalUnit -Identity $targetOuDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost -Confirm:$false
}
continue
}
$legacyOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $BaseDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
if ($legacyOu) {
$legacyOuDn = [string]$legacyOu.DistinguishedName
try {
if ($legacyOu.ProtectedFromAccidentalDeletion) {
Set-ADOrganizationalUnit -Identity $legacyOuDn -ProtectedFromAccidentalDeletion $false -Server $LdapHost -Confirm:$false
Start-Sleep -Seconds 1
}
Move-ADObject -Identity $legacyOuDn -TargetPath $usersOuDn -Server $LdapHost -Confirm:$false -ErrorAction Stop
}
finally {
$currentOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $usersOuDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
if (-not $currentOu) {
$currentOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$ouName)" -SearchBase $BaseDn -SearchScope OneLevel -Properties ProtectedFromAccidentalDeletion -Server $LdapHost -ErrorAction SilentlyContinue
}
if ($currentOu) {
$currentOuDn = [string]$currentOu.DistinguishedName
Set-ADOrganizationalUnit -Identity $currentOuDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost -Confirm:$false
}
}
}
else {
New-ADOrganizationalUnit -Name $ouName -Path $usersOuDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
}
}
}
$roleGroupDefinitions = @(
[pscustomobject]@{ Role = 'Professor'; Dn = $ProfessorGroupDn; Description = 'SGU accounts with the DO institutional prefix.' }
[pscustomobject]@{ Role = 'Student'; Dn = $StudentGroupDn; Description = 'SGU accounts with the AL institutional prefix.' }
[pscustomobject]@{ Role = 'Administrative'; Dn = $AdministrativeGroupDn; Description = 'SGU accounts with the AD institutional prefix.' }
)
foreach ($definition in $roleGroupDefinitions) {
if (-not $definition.Dn.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
throw "$($definition.Role)GroupDn must identify a security group beneath BaseDn."
}
try {
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {
$roleGroup = $null
}
if (-not $roleGroup -and $CreateMissingOus) {
$groupDnMatch = [regex]::Match($definition.Dn, '^CN=(?<Name>[^,]+),(?<Path>.+)$', [Text.RegularExpressions.RegexOptions]::IgnoreCase)
if (-not $groupDnMatch.Success) {
throw "$($definition.Role)GroupDn must start with a simple CN component."
}
$groupName = $groupDnMatch.Groups['Name'].Value
$groupPath = $groupDnMatch.Groups['Path'].Value
if ($groupName.Length -gt 20) {
throw "$($definition.Role) group name exceeds the 20-character sAMAccountName limit."
}
$matchingGroups = @(Get-ADGroup `
-LDAPFilter "(sAMAccountName=$(ConvertTo-LdapFilterValue -Value $groupName))" `
-SearchBase $BaseDn -SearchScope Subtree -Server $LdapHost -ErrorAction Stop)
if ($matchingGroups.Count -gt 1) {
throw "More than one Active Directory group uses sAMAccountName $groupName; the bootstrap cannot select one safely."
}
if ($matchingGroups.Count -eq 1) {
if ($matchingGroups[0].GroupCategory -ne 'Security') {
throw "$($definition.Role)GroupDn must identify a security group."
}
Move-ADObject -Identity $matchingGroups[0].DistinguishedName `
-TargetPath $groupPath -Server $LdapHost -Confirm:$false -ErrorAction Stop
}
else {
New-ADGroup -Name $groupName -SamAccountName $groupName `
-GroupCategory Security -GroupScope Global `
-Path $groupPath `
-Description $definition.Description -Server $LdapHost | Out-Null
}
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
}
if (-not $roleGroup) {
throw "The required $($definition.Role) security group does not exist: $($definition.Dn)"
}
if ($roleGroup.GroupCategory -ne 'Security') {
throw "$($definition.Role)GroupDn must identify a security group."
}
}
if ($RemoteDesktopGroupDn) {
$remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop
if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or
-not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
throw 'RemoteDesktopGroupDn must identify a security group beneath BaseDn.'
}
}
foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.json')) {
if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) {
throw "PublishPath is missing $file."
}
}
$productionSettings = @{
Logging = @{
EventLog = @{
LogLevel = @{
Default = 'Information'
'Microsoft.AspNetCore' = 'Warning'
}
}
}
Kestrel = @{
Endpoints = @{
Https = @{
Url = 'https://0.0.0.0:8443'
Certificate = @{
Subject = $ServerCertificateSubject
Store = 'My'
Location = 'LocalMachine'
AllowInvalid = $false
}
}
}
}
Broker = @{
Diagnostics = @{
UseDedicatedEventLog = $true
}
Tls = @{
AllowedClientThumbprints = $normalizedClientThumbprints
CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
}
Ntlm = @{
Endpoint = $NtlmEndpoint
Domain = ''
TimeoutSeconds = $NtlmTimeoutSeconds
ProfileTimeoutSeconds = $ProfileTimeoutSeconds
MaxRedirects = 5
AuthenticationPath = $AuthenticationPath
AdministrativeProfilePath = $AdministrativeProfilePath
AdministrativePersonalProfilePath = $AdministrativePersonalProfilePath
AdministrativeLocationProfilePath = $AdministrativeLocationProfilePath
StudentProfilePath = $StudentProfilePath
ProfessorPayrollProfilePath = $ProfessorPayrollProfilePath
MenuProfilePath = $MenuProfilePath
MaxProfileBytes = $MaxProfileBytes
AllowedRedirectHosts = $AllowedNtlmRedirectHosts
}
Directory = @{
LdapHost = $LdapHost
BaseDn = $BaseDn
DomainNetbios = $DomainNetbios
UpnSuffix = $UpnSuffix
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
ProfessorGroupDn = $ProfessorGroupDn
StudentGroupDn = $StudentGroupDn
AdministrativeGroupDn = $AdministrativeGroupDn
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
DefaultCompany = $DefaultCompany
CreateMissingOus = [bool]$CreateMissingOus
}
}
}
if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker Windows service')) {
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
Stop-Service -Name $serviceName -Force
(Get-Service -Name $serviceName).WaitForStatus(
[System.ServiceProcess.ServiceControllerStatus]::Stopped,
[TimeSpan]::FromSeconds(15))
# A self-contained .NET process can briefly retain mapped runtime files
# after SCM reports Stopped. Give Windows time to release those handles.
Start-Sleep -Seconds 2
}
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
Copy-Item -Path (Join-Path $PublishPath '*') -Destination $installPath -Recurse -Force
$settingsJson = $productionSettings | ConvertTo-Json -Depth 8
$utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText(
(Join-Path $installPath 'appsettings.Production.json'),
$settingsJson,
$utf8WithoutBom)
if ([Diagnostics.EventLog]::SourceExists($brokerEventSource)) {
$registeredLog = [Diagnostics.EventLog]::LogNameFromSourceName($brokerEventSource, '.')
if (-not $registeredLog.Equals($brokerEventLogName, [StringComparison]::OrdinalIgnoreCase)) {
throw "Event source $brokerEventSource is already registered to $registeredLog."
}
}
else {
New-EventLog -LogName $brokerEventLogName -Source $brokerEventSource
}
Limit-EventLog -LogName $brokerEventLogName -MaximumSize 268435456 `
-OverflowAction OverwriteAsNeeded
if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) {
New-Service -Name $serviceName `
-DisplayName 'SGU Authentication Broker' `
-Description 'Validates SGU NTLM credentials and synchronizes Active Directory accounts.' `
-BinaryPathName ('"{0}"' -f (Join-Path $installPath 'SGU.AuthBroker.exe')) `
-StartupType Automatic
}
else {
Set-Service -Name $serviceName -StartupType Automatic
}
& sc.exe failure $serviceName 'reset=' '86400' 'actions=' 'restart/5000/restart/15000/restart/60000' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Could not configure automatic recovery for SGUAuthBroker.'
}
& sc.exe failureflag $serviceName '1' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Could not enable recovery for non-crash SGUAuthBroker failures.'
}
$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
}
Get-Service -Name $serviceName | Select-Object Name, Status, StartType,
@{ Name = 'EventLog'; Expression = { $brokerEventLogName } }