Skip to content

Commit 1645dcc

Browse files
committed
feat: add comprehensive password configuration with validation and passphrase support
Add detailed password configuration options including Classic and Passphrase modes with Microsoft 365 compliance validation. Implement cryptographically secure random generation, character set customization, and enhanced input validation with security checks for special characters and separators.
1 parent fd50db4 commit 1645dcc

3 files changed

Lines changed: 663 additions & 25 deletions

File tree

Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPasswordConfig.ps1

Lines changed: 245 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,267 @@ Function Invoke-ExecPasswordConfig {
77
#>
88
[CmdletBinding()]
99
param($Request, $TriggerMetadata)
10+
11+
$APIName = $Request.Params.CIPPEndpoint
12+
$StatusCode = [HttpStatusCode]::OK
1013
$Table = Get-CIPPTable -TableName Settings
11-
$PasswordType = (Get-CIPPAzDataTableEntity @Table)
14+
$PasswordSettings = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'settings' and RowKey eq 'settings'"
15+
16+
# Helper functions for consistent data conversion
17+
function ConvertTo-BoolString ($raw) {
18+
if ($null -eq $raw) { return $false }
19+
$stringValue = "$raw"
20+
return ($stringValue -eq 'true' -or $stringValue -eq '1' -or $stringValue -eq 'yes')
21+
}
22+
23+
function ConvertTo-Bool ($raw) {
24+
if ($null -eq $raw) { return $false }
25+
$stringValue = "$raw"
26+
return ($stringValue -eq 'true' -or $stringValue -eq '1' -or $stringValue -eq 'yes')
27+
}
28+
29+
function Test-RequestBody ($body) {
30+
if (-not $body) { return $false }
31+
if ($body -is [string] -or $body -is [array]) { return $false }
32+
return $true
33+
}
1234

1335

1436
$results = try {
1537
if ($Request.Query.List) {
16-
@{ passwordType = $PasswordType.passwordType }
38+
if (-not $PasswordSettings) {
39+
# Return default values if not set
40+
@{
41+
passwordType = 'Classic'
42+
charCount = 14
43+
includeUppercase = $true
44+
includeLowercase = $true
45+
includeDigits = $true
46+
includeSpecialChars = $true
47+
specialCharSet = '$%&*#'
48+
wordCount = 4
49+
separator = '-'
50+
capitalizeWords = $false
51+
appendNumber = $false
52+
appendSpecialChar = $false
53+
}
54+
} else {
55+
# Migrate legacy 'Correct-Battery-Horse' type to 'Passphrase'
56+
$storedType = if ($PasswordSettings.passwordType) { $PasswordSettings.passwordType } else { 'Classic' }
57+
$needsMigration = $storedType -eq 'Correct-Battery-Horse'
58+
if ($needsMigration) {
59+
$storedType = 'Passphrase'
60+
}
61+
62+
$resolvedConfig = @{
63+
passwordType = $storedType
64+
charCount = if ($PasswordSettings.charCount -and [int]::TryParse("$($PasswordSettings.charCount)", [ref]$null)) { [int]$PasswordSettings.charCount } else { 14 }
65+
includeUppercase = if ($null -ne $PasswordSettings.includeUppercase) { ConvertTo-Bool $PasswordSettings.includeUppercase } else { $true }
66+
includeLowercase = if ($null -ne $PasswordSettings.includeLowercase) { ConvertTo-Bool $PasswordSettings.includeLowercase } else { $true }
67+
includeDigits = if ($null -ne $PasswordSettings.includeDigits) { ConvertTo-Bool $PasswordSettings.includeDigits } else { $true }
68+
includeSpecialChars = if ($null -ne $PasswordSettings.includeSpecialChars) { ConvertTo-Bool $PasswordSettings.includeSpecialChars } else { $true }
69+
specialCharSet = if ($PasswordSettings.specialCharSet) { $PasswordSettings.specialCharSet } else { '$%&*#' }
70+
wordCount = if ($PasswordSettings.wordCount -and [int]::TryParse("$($PasswordSettings.wordCount)", [ref]$null)) { [int]$PasswordSettings.wordCount } else { 4 }
71+
separator = if ($null -ne $PasswordSettings.separator) { $PasswordSettings.separator } else { '-' }
72+
capitalizeWords = if ($null -ne $PasswordSettings.capitalizeWords) { ConvertTo-Bool $PasswordSettings.capitalizeWords } else { $false }
73+
appendNumber = if ($null -ne $PasswordSettings.appendNumber) { ConvertTo-Bool $PasswordSettings.appendNumber } else { $false }
74+
appendSpecialChar = if ($null -ne $PasswordSettings.appendSpecialChar) { ConvertTo-Bool $PasswordSettings.appendSpecialChar } else { $false }
75+
}
76+
77+
# Persist migrated config so legacy type is upgraded in storage
78+
if ($needsMigration) {
79+
$MigratedEntity = @{
80+
'PartitionKey' = 'settings'
81+
'RowKey' = 'settings'
82+
'passwordType' = $resolvedConfig.passwordType
83+
'charCount' = "$($resolvedConfig.charCount)"
84+
'includeUppercase' = $resolvedConfig.includeUppercase
85+
'includeLowercase' = $resolvedConfig.includeLowercase
86+
'includeDigits' = $resolvedConfig.includeDigits
87+
'includeSpecialChars' = $resolvedConfig.includeSpecialChars
88+
'specialCharSet' = $resolvedConfig.specialCharSet
89+
'wordCount' = "$($resolvedConfig.wordCount)"
90+
'separator' = $resolvedConfig.separator
91+
'capitalizeWords' = $resolvedConfig.capitalizeWords
92+
'appendNumber' = $resolvedConfig.appendNumber
93+
'appendSpecialChar' = $resolvedConfig.appendSpecialChar
94+
}
95+
Add-CIPPAzDataTableEntity @Table -Entity $MigratedEntity -Force | Out-Null
96+
Write-LogMessage -headers $Request.Headers -API $APIName -message "Migrated legacy password type 'Correct-Battery-Horse' to 'Passphrase'" -Sev 'Info'
97+
}
98+
99+
$resolvedConfig
100+
}
17101
} else {
102+
# ── Validate request body ────────────────────────────────────────
103+
if (-not (Test-RequestBody $Request.Body)) {
104+
$StatusCode = [HttpStatusCode]::BadRequest
105+
throw 'Request body must be a valid JSON object'
106+
}
107+
108+
# Password type validation
109+
$pwType = if ($null -ne $Request.Body.passwordType) { "$($Request.Body.passwordType)" } else { '' }
110+
# Accept legacy type name and normalize to new name
111+
if ($pwType -eq 'Correct-Battery-Horse') {
112+
$pwType = 'Passphrase'
113+
}
114+
if ($pwType -notin @('Classic', 'Passphrase')) {
115+
$StatusCode = [HttpStatusCode]::BadRequest
116+
throw 'Please select a valid password type (Classic or Passphrase)'
117+
}
118+
119+
$includeUppercase = ConvertTo-Bool $Request.Body.includeUppercase
120+
$includeLowercase = ConvertTo-Bool $Request.Body.includeLowercase
121+
$includeDigits = ConvertTo-Bool $Request.Body.includeDigits
122+
$includeSpecialChars = ConvertTo-Bool $Request.Body.includeSpecialChars
123+
$capitalizeWords = ConvertTo-Bool $Request.Body.capitalizeWords
124+
$appendNumber = ConvertTo-Bool $Request.Body.appendNumber
125+
$appendSpecialChar = ConvertTo-Bool $Request.Body.appendSpecialChar
126+
127+
# Char count validation (classic only)
128+
$charCount = 0
129+
if ($pwType -eq 'Classic') {
130+
if (-not [int]::TryParse("$($Request.Body.charCount)", [ref]$charCount)) {
131+
$StatusCode = [HttpStatusCode]::BadRequest
132+
throw 'Password length must be a valid number'
133+
} elseif ($charCount -lt 8 -or $charCount -gt 256) {
134+
$StatusCode = [HttpStatusCode]::BadRequest
135+
throw 'Password length must be between 8 and 256 characters'
136+
}
137+
} else {
138+
# Still parse for storage, but don't reject invalid values for the inactive mode
139+
if ([int]::TryParse("$($Request.Body.charCount)", [ref]$charCount)) { } else { $charCount = 14 }
140+
}
141+
142+
# Word count validation (passphrase only)
143+
$wordCount = 0
144+
if ($pwType -eq 'Passphrase') {
145+
if (-not [int]::TryParse("$($Request.Body.wordCount)", [ref]$wordCount)) {
146+
$StatusCode = [HttpStatusCode]::BadRequest
147+
throw 'Word count must be a valid number'
148+
} elseif ($wordCount -lt 2 -or $wordCount -gt 10) {
149+
$StatusCode = [HttpStatusCode]::BadRequest
150+
throw 'Word count must be between 2 and 10 words'
151+
}
152+
} else {
153+
if ([int]::TryParse("$($Request.Body.wordCount)", [ref]$wordCount)) { } else { $wordCount = 4 }
154+
}
155+
156+
# Special character set validation with enhanced security
157+
$specialCharSet = if ($null -ne $Request.Body.specialCharSet) { "$($Request.Body.specialCharSet)" } else { '' }
158+
# Define safe and easily typable special character set including forward slash
159+
$allowedSpecialPattern = '^[!@#$%^&*()\-_=+/]+$'
160+
if ($includeSpecialChars -or $appendSpecialChar) {
161+
if ([string]::IsNullOrEmpty($specialCharSet)) {
162+
$StatusCode = [HttpStatusCode]::BadRequest
163+
throw 'Special characters cannot be empty when enabled'
164+
} elseif ($specialCharSet.Length -gt 32) {
165+
$StatusCode = [HttpStatusCode]::BadRequest
166+
throw 'Special characters set must be 32 characters or fewer'
167+
} elseif ($specialCharSet -match '[\x00-\x1F\x7F]') {
168+
$StatusCode = [HttpStatusCode]::BadRequest
169+
throw 'Special characters cannot contain control characters'
170+
} elseif ($specialCharSet -notmatch $allowedSpecialPattern) {
171+
$StatusCode = [HttpStatusCode]::BadRequest
172+
throw 'Special characters contain invalid symbols. Only safe typable characters allowed: !@#$%^&*()-_=+/'
173+
}
174+
}
175+
176+
# Separator validation with enhanced security - allow space or empty
177+
$separator = if ($null -ne $Request.Body.separator) { "$($Request.Body.separator)" } else { '' }
178+
if ($separator.Length -gt 5) {
179+
$StatusCode = [HttpStatusCode]::BadRequest
180+
throw 'Separator must be 5 characters or fewer'
181+
}
182+
# Allow empty separator or single space, otherwise validate against safe characters
183+
if ($separator -ne '' -and $separator -ne ' ') {
184+
# Use the same validation pattern as special characters for consistency
185+
if ($separator -match '[\x00-\x1F\x7F]') {
186+
$StatusCode = [HttpStatusCode]::BadRequest
187+
throw 'Separator cannot contain control characters'
188+
}
189+
if ($separator -match '[\u2000-\u200F\u2028-\u202F\u205F\u3000]') {
190+
$StatusCode = [HttpStatusCode]::BadRequest
191+
throw 'Separator cannot contain Unicode whitespace characters'
192+
}
193+
if ($separator -notmatch $allowedSpecialPattern) {
194+
$StatusCode = [HttpStatusCode]::BadRequest
195+
throw 'Separator contains invalid symbols. Only safe typable characters allowed: !@#$%^&*()-_=+/ (or space/empty)'
196+
}
197+
}
198+
199+
# Microsoft 365 complexity validation: at least 3 of 4 character types
200+
if ($pwType -eq 'Classic') {
201+
$enabledCount = 0
202+
if ($includeUppercase) { $enabledCount++ }
203+
if ($includeLowercase) { $enabledCount++ }
204+
if ($includeDigits) { $enabledCount++ }
205+
if ($includeSpecialChars) { $enabledCount++ }
206+
if ($enabledCount -lt 3) {
207+
$StatusCode = [HttpStatusCode]::BadRequest
208+
throw 'Classic passwords must include at least 3 of these 4 types: uppercase letters, lowercase letters, numbers, and special characters'
209+
}
210+
} else {
211+
# Passphrase complexity validation
212+
$hasLower = $true # words always contain lowercase
213+
$hasUpper = $capitalizeWords
214+
$hasDigits = $appendNumber
215+
$hasSpecial = $appendSpecialChar
216+
217+
# Check if separator contains special characters or digits - validate actual content
218+
if ($separator) {
219+
$HasSpecialSeparator = $separator -match '[!@#$%^&*()_+\-=[\]{};:,.<>/?|~]'
220+
$HasDigitSeparator = $separator -match '\d'
221+
if ($HasSpecialSeparator) {
222+
$hasSpecial = $true
223+
}
224+
if ($HasDigitSeparator) {
225+
$hasDigits = $true
226+
}
227+
}
228+
229+
$ppTypes = @($hasLower, $hasUpper, $hasDigits, $hasSpecial).Where({ $_ }).Count
230+
if ($ppTypes -lt 3) {
231+
$StatusCode = [HttpStatusCode]::BadRequest
232+
throw 'Passphrases must include at least 3 of these 4 types: lowercase letters (from words), uppercase letters (capitalization), numbers (appended), and special characters (appended)'
233+
}
234+
}
235+
236+
# ── Persist validated config ──────────────────────────────────────
18237
$PasswordConfig = @{
19-
'passwordType' = "$($Request.Body.passwordType)"
20-
'passwordCount' = '12'
21-
'PartitionKey' = 'settings'
22-
'RowKey' = 'settings'
238+
'PartitionKey' = 'settings'
239+
'RowKey' = 'settings'
240+
'passwordType' = $pwType
241+
'charCount' = "$charCount"
242+
'includeUppercase' = $includeUppercase
243+
'includeLowercase' = $includeLowercase
244+
'includeDigits' = $includeDigits
245+
'includeSpecialChars' = $includeSpecialChars
246+
'specialCharSet' = $specialCharSet
247+
'wordCount' = "$wordCount"
248+
'separator' = $separator
249+
'capitalizeWords' = $capitalizeWords
250+
'appendNumber' = $appendNumber
251+
'appendSpecialChar' = $appendSpecialChar
23252
}
24253

25254
Add-CIPPAzDataTableEntity @Table -Entity $PasswordConfig -Force | Out-Null
26-
'Successfully set the configuration'
255+
Write-LogMessage -headers $Request.Headers -API $APIName -message "Successfully set password configuration" -Sev 'Info'
256+
"Successfully set the configuration"
27257
}
28258
} catch {
29-
"Failed to set configuration: $($_.Exception.message)"
259+
if ($StatusCode -eq [HttpStatusCode]::OK) {
260+
$StatusCode = [HttpStatusCode]::InternalServerError
261+
}
262+
$ErrorMessage = Get-CippException -Exception $_
263+
Write-LogMessage -headers $Request.Headers -API $APIName -message "Failed to set password configuration: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage
264+
"Failed to set configuration: $($ErrorMessage.NormalizedError)"
30265
}
31266

32-
33-
$body = [pscustomobject]@{'Results' = $Results }
267+
$body = [pscustomobject]@{'Results' = if ($null -ne $results) { $results } else { "Operation completed" } }
34268

35269
return ([HttpResponseContext]@{
36-
StatusCode = [HttpStatusCode]::OK
270+
StatusCode = $StatusCode
37271
Body = $body
38272
})
39273

0 commit comments

Comments
 (0)