Declare the service account DN before the OU loop and print the LDAP result code and server message when the password-change check fails. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
150 lines
8.1 KiB
PowerShell
150 lines
8.1 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Checks the portal service account's main rights and refusals over LDAPS.
|
|
|
|
.DESCRIPTION
|
|
Binds over LDAPS (port 636) with certificate verification as the service account and checks:
|
|
1. the bind works and the server certificate is trusted by this machine,
|
|
2. a test user can be found and its account state read,
|
|
3. the domain password policy can be read,
|
|
4. each of the six allowed attributes can be written (and is restored),
|
|
5. a not-allowed attribute cannot be written,
|
|
6. a user OUTSIDE the test OU cannot be written (a no-change request, safe even if it succeeds),
|
|
7. optionally a password change works with the Change Password right (-TestPasswordChange).
|
|
Not covered: that the account cannot create or delete objects or reset passwords. Check those in
|
|
Active Directory (Delegation of Control / dsacls) if you need that proof.
|
|
Every check prints PASS or FAIL; any unexpected error stops the script with a non-zero exit code,
|
|
so "All checks passed." is only printed when every check ran. Nothing is left changed except the test user's password if you
|
|
use -TestPasswordChange. Run it from a machine that can reach the directory server.
|
|
Not tested by the author against a live directory.
|
|
|
|
.EXAMPLE
|
|
.\Test-PortalLdaps.ps1 -Server dc1.example.local -BindUser svc-portal-test@example.local `
|
|
-TestOuDn "OU=PortalTest,OU=Organisation,DC=example,DC=local" `
|
|
-OutsideUserDn "CN=Some Person,OU=Elsewhere,DC=example,DC=local"
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Server,
|
|
[Parameter(Mandatory = $true)][string]$BindUser,
|
|
[Parameter(Mandatory = $true)][string]$TestOuDn,
|
|
[string]$TestSam = 'ptest-user1',
|
|
[string]$OutsideUserDn,
|
|
[switch]$TestPasswordChange
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
Add-Type -AssemblyName System.DirectoryServices.Protocols
|
|
$P = [System.DirectoryServices.Protocols.SearchScope]
|
|
$failed = 0
|
|
function Report([string]$Name, [bool]$Ok, [string]$Detail = '') {
|
|
if ($Ok) { Write-Host "PASS $Name" -ForegroundColor Green }
|
|
else { Write-Host "FAIL $Name $Detail" -ForegroundColor Red; $script:failed++ }
|
|
}
|
|
function Plain([securestring]$s) {
|
|
$b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)
|
|
try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }
|
|
}
|
|
function Search([string]$Base, [string]$Filter, $Scope, [string[]]$Attrs) {
|
|
$req = New-Object System.DirectoryServices.Protocols.SearchRequest($Base, $Filter, $Scope, $Attrs)
|
|
return [System.DirectoryServices.Protocols.SearchResponse]($script:conn.SendRequest($req))
|
|
}
|
|
function Modify([string]$Dn, [string]$Attr, $Values) {
|
|
$m = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification
|
|
$m.Name = $Attr
|
|
$m.Operation = [System.DirectoryServices.Protocols.DirectoryAttributeOperation]::Replace
|
|
foreach ($v in $Values) { [void]$m.Add($v) }
|
|
$req = New-Object System.DirectoryServices.Protocols.ModifyRequest($Dn, $m)
|
|
[void]$script:conn.SendRequest($req)
|
|
}
|
|
function Denied([scriptblock]$Action) {
|
|
try { & $Action; return $false }
|
|
catch [System.DirectoryServices.Protocols.DirectoryOperationException] {
|
|
return ($_.Exception.Response.ResultCode -eq 'InsufficientAccessRights')
|
|
}
|
|
}
|
|
|
|
# 1. bind with certificate verification (the default uses this machine's trust store)
|
|
$pw = Read-Host -AsSecureString "Password for $BindUser"
|
|
$id = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($Server, 636, $false, $false)
|
|
$conn = New-Object System.DirectoryServices.Protocols.LdapConnection($id)
|
|
$conn.SessionOptions.SecureSocketLayer = $true
|
|
$conn.SessionOptions.ProtocolVersion = 3
|
|
$conn.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
|
$conn.Credential = New-Object System.Net.NetworkCredential($BindUser, (Plain $pw))
|
|
try { $conn.Bind(); Report 'LDAPS bind with a trusted certificate' $true }
|
|
catch { Report 'LDAPS bind with a trusted certificate' $false $_.Exception.Message; exit 1 }
|
|
|
|
# 2. find the test user and read its state
|
|
$allowed = @('givenName', 'sn', 'displayName', 'mail', 'telephoneNumber', 'mobile')
|
|
try {
|
|
$root = Search '' '(objectClass=*)' $P::Base @('defaultNamingContext')
|
|
$domainDn = $root.Entries[0].Attributes['defaultNamingContext'][0]
|
|
$r = Search $TestOuDn "(sAMAccountName=$TestSam)" $P::Subtree (@('distinguishedName', 'pwdLastSet', 'userAccountControl', 'memberOf') + $allowed)
|
|
$found = $r.Entries.Count -eq 1
|
|
Report "find $TestSam in the test OU and read its state" ($found -and $r.Entries[0].Attributes.Contains('userAccountControl'))
|
|
}
|
|
catch { Report "find $TestSam in the test OU and read its state" $false $_.Exception.Message; exit 1 }
|
|
if (-not $found) { Write-Host 'Cannot continue without the test user.'; exit 1 }
|
|
$entry = $r.Entries[0]
|
|
$userDn = $entry.DistinguishedName
|
|
|
|
# 3. domain password policy
|
|
try {
|
|
$pol = Search $domainDn '(objectClass=*)' $P::Base @('minPwdLength', 'pwdProperties', 'pwdHistoryLength')
|
|
Report 'read the domain password policy' ($pol.Entries[0].Attributes.Contains('minPwdLength'))
|
|
}
|
|
catch { Report 'read the domain password policy' $false $_.Exception.Message }
|
|
|
|
# 4. every allowed attribute: write a marker, then restore the original value
|
|
foreach ($attr in $allowed) {
|
|
$old = @(); if ($entry.Attributes.Contains($attr)) { $old = @([string]$entry.Attributes[$attr][0]) }
|
|
$marker = if ($attr -eq 'mail') { 'portal-test-marker@example.test' } else { 'PortalTest-marker' }
|
|
try {
|
|
Modify $userDn $attr @($marker)
|
|
Modify $userDn $attr $old
|
|
Report "write the allowed attribute $attr and restore it" $true
|
|
}
|
|
catch { Report "write the allowed attribute $attr and restore it" $false $_.Exception.Message }
|
|
}
|
|
|
|
# 5. not-allowed attribute must be refused
|
|
Report 'writing a not-allowed attribute (description) is refused' (Denied { Modify $userDn 'description' @('portal test') })
|
|
|
|
# 6. outside the test OU must be refused (no-change request, safe even if it were allowed)
|
|
if ($OutsideUserDn) {
|
|
try {
|
|
$o = Search $OutsideUserDn '(objectClass=*)' $P::Base @('telephoneNumber')
|
|
$same = @(); if ($o.Entries[0].Attributes.Contains('telephoneNumber')) { $same = @([string]$o.Entries[0].Attributes['telephoneNumber'][0]) }
|
|
Report 'writing a user outside the test OU is refused' (Denied { Modify $OutsideUserDn 'telephoneNumber' $same })
|
|
}
|
|
catch { Report 'writing a user outside the test OU is refused' $false "cannot read -OutsideUserDn: $($_.Exception.Message)" }
|
|
}
|
|
else { Write-Host 'SKIP outside-OU check (pass -OutsideUserDn to run it)' -ForegroundColor Yellow }
|
|
|
|
# 7. password change with the Change Password right (changes the test user's password!)
|
|
if ($TestPasswordChange) {
|
|
$oldPw = Plain (Read-Host -AsSecureString "Current password of $TestSam")
|
|
$newPw = Plain (Read-Host -AsSecureString "New password for $TestSam (must meet the policy)")
|
|
$enc = [System.Text.Encoding]::Unicode
|
|
$del = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification
|
|
$del.Name = 'unicodePwd'; $del.Operation = 'Delete'; [void]$del.Add($enc.GetBytes('"' + $oldPw + '"'))
|
|
$add = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification
|
|
$add.Name = 'unicodePwd'; $add.Operation = 'Add'; [void]$add.Add($enc.GetBytes('"' + $newPw + '"'))
|
|
$ok = $true; $detail = ''
|
|
try { [void]$conn.SendRequest((New-Object System.DirectoryServices.Protocols.ModifyRequest($userDn, @($del, $add)))) }
|
|
catch {
|
|
$ok = $false; $detail = $_.Exception.Message
|
|
$inner = $_.Exception.InnerException
|
|
if ($inner -is [System.DirectoryServices.Protocols.DirectoryOperationException]) {
|
|
$detail += " [LDAP result: $($inner.Response.ResultCode); server: $($inner.Response.ErrorMessage)]"
|
|
}
|
|
}
|
|
Report 'change a password with the Change Password right' $ok $detail
|
|
}
|
|
else { Write-Host 'SKIP password change (use -TestPasswordChange; it changes the test user password)' -ForegroundColor Yellow }
|
|
|
|
Write-Host ''
|
|
if ($failed -eq 0) { Write-Host 'All checks passed.' -ForegroundColor Green } else { Write-Host "$failed check(s) failed." -ForegroundColor Red; exit 1 }
|