260 lines
11 KiB
PowerShell
260 lines
11 KiB
PowerShell
$ErrorActionPreference = 'Stop'
|
|
|
|
$root = Split-Path -Parent $PSScriptRoot
|
|
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json
|
|
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml')
|
|
$issues = New-Object System.Collections.Generic.List[string]
|
|
$passwordPath = '/genealogy/app/auth/password'
|
|
|
|
function Add-Issue {
|
|
param([string]$Message)
|
|
$script:issues.Add($Message)
|
|
}
|
|
|
|
function Get-Schema {
|
|
param([string]$Name)
|
|
$property = $document.components.schemas.PSObject.Properties[$Name]
|
|
if (-not $property) {
|
|
Add-Issue "JSON missing schema owner: $Name"
|
|
return $null
|
|
}
|
|
return $property.Value
|
|
}
|
|
|
|
function Get-Response {
|
|
param([object]$Operation, [string]$Status)
|
|
if (-not $Operation) { return $null }
|
|
$property = $Operation.responses.PSObject.Properties[$Status]
|
|
if (-not $property) {
|
|
Add-Issue "JSON PUT $passwordPath missing $Status response"
|
|
return $null
|
|
}
|
|
$response = $property.Value
|
|
if ($response.'$ref') {
|
|
$name = ([string]$response.'$ref').Split('/')[-1]
|
|
$owner = $document.components.responses.PSObject.Properties[$name]
|
|
if (-not $owner) {
|
|
Add-Issue "JSON missing response owner: $name"
|
|
return $null
|
|
}
|
|
$response = $owner.Value
|
|
}
|
|
return $response
|
|
}
|
|
|
|
function Get-JsonSchemaRef {
|
|
param([object]$Response, [string]$Status)
|
|
if (-not $Response) { return '' }
|
|
$media = $Response.content.PSObject.Properties['application/json']
|
|
if (-not $media) {
|
|
Add-Issue "JSON PUT $passwordPath $Status must use application/json"
|
|
return ''
|
|
}
|
|
return [string]$media.Value.schema.'$ref'
|
|
}
|
|
|
|
function Assert-PrivateNoStore {
|
|
param([object]$Response, [string]$Status)
|
|
if (-not $Response) { return }
|
|
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
|
|
if (-not $property) {
|
|
Add-Issue "JSON PUT $passwordPath $Status must document Cache-Control: private, no-store"
|
|
return
|
|
}
|
|
$header = $property.Value
|
|
if ($header.'$ref') {
|
|
$name = ([string]$header.'$ref').Split('/')[-1]
|
|
$owner = $document.components.headers.PSObject.Properties[$name]
|
|
if ($owner) { $header = $owner.Value }
|
|
}
|
|
$evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example)
|
|
if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') {
|
|
Add-Issue "JSON PUT $passwordPath $Status Cache-Control must specify private, no-store"
|
|
}
|
|
}
|
|
|
|
function Assert-SecretSchema {
|
|
param(
|
|
[object]$Schema,
|
|
[string]$Name,
|
|
[int]$Minimum,
|
|
[int]$Maximum
|
|
)
|
|
if (-not $Schema) { return }
|
|
if ($Schema.type -ne 'string' -or $Schema.format -ne 'password' -or $Schema.writeOnly -ne $true -or
|
|
[int]$Schema.minLength -ne $Minimum -or [int]$Schema.maxLength -ne $Maximum) {
|
|
Add-Issue "JSON $Name must be a writeOnly password string of $Minimum..$Maximum Unicode code points"
|
|
}
|
|
if ($Schema.pattern -or $Schema.example -or ([string]$Schema.description) -match '(?i)MD5|hex|字母.*数字|数字.*字母') {
|
|
Add-Issue "JSON $Name must not retain a static digest, composition rule, pattern, or password example"
|
|
}
|
|
if (([string]$Schema.description) -notmatch '(?i)Unicode code point' -or
|
|
([string]$Schema.description) -notmatch '(?i)NFC' -or
|
|
([string]$Schema.description) -notmatch '(?i)(space|空格)') {
|
|
Add-Issue "JSON $Name must define Unicode code-point length, NFC normalization, and space handling"
|
|
}
|
|
}
|
|
|
|
function Assert-FieldRef {
|
|
param([string]$SchemaName, [string]$Field, [string]$ExpectedRef)
|
|
$schema = Get-Schema $SchemaName
|
|
if (-not $schema) { return }
|
|
$property = $schema.properties.PSObject.Properties[$Field]
|
|
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
|
|
if ($actual -ne $ExpectedRef) {
|
|
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
|
|
}
|
|
}
|
|
|
|
$pathProperty = $document.paths.PSObject.Properties[$passwordPath]
|
|
$operation = if ($pathProperty) { $pathProperty.Value.put } else { $null }
|
|
if (-not $operation) { Add-Issue "JSON missing PUT $passwordPath" }
|
|
|
|
if ($operation) {
|
|
$hasSaToken = $false
|
|
foreach ($requirement in @($operation.security)) {
|
|
if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true }
|
|
}
|
|
if (-not $hasSaToken) { Add-Issue "JSON PUT $passwordPath must require SaToken" }
|
|
|
|
$clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' })
|
|
if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or
|
|
$clientHeaders[0].schema.type -ne 'string' -or [int]$clientHeaders[0].schema.minLength -lt 1) {
|
|
Add-Issue "JSON PUT $passwordPath must require one non-empty string clientid header"
|
|
}
|
|
|
|
$requestMedia = $operation.requestBody.content.PSObject.Properties['application/json']
|
|
if ($operation.requestBody.required -ne $true -or -not $requestMedia) {
|
|
Add-Issue "JSON PUT $passwordPath must require an application/json body"
|
|
} elseif ($requestMedia.Value.schema.'$ref' -ne '#/components/schemas/PasswordChangeBody') {
|
|
Add-Issue 'JSON password change request must use PasswordChangeBody'
|
|
}
|
|
|
|
$semantics = [string]$operation.description
|
|
foreach ($semanticPattern in @(
|
|
'(?i)current password.*re-authentication',
|
|
'(?i)atomic.*password.*credential epoch',
|
|
'(?i)all.*access.*refresh.*sessions.*including.*current',
|
|
'(?i)200.*sessions.*invalidated',
|
|
'(?i)new password.*different.*current password',
|
|
'(?i)(common|breached) password.*blocklist',
|
|
'(?i)rate limit'
|
|
)) {
|
|
if ($semantics -notmatch $semanticPattern) {
|
|
Add-Issue "JSON PUT $passwordPath description is missing security/session semantics: $semanticPattern"
|
|
}
|
|
}
|
|
|
|
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
|
|
if (-not $operation.responses.PSObject.Properties[$status]) {
|
|
Add-Issue "JSON PUT $passwordPath missing documented response: $status"
|
|
}
|
|
}
|
|
}
|
|
|
|
$changeBody = Get-Schema 'PasswordChangeBody'
|
|
$currentSecret = Get-Schema 'CurrentPasswordSecret'
|
|
$newSecret = Get-Schema 'NewPasswordSecret'
|
|
Assert-SecretSchema $currentSecret 'CurrentPasswordSecret' 1 64
|
|
Assert-SecretSchema $newSecret 'NewPasswordSecret' 15 64
|
|
|
|
if ($changeBody) {
|
|
$properties = @($changeBody.properties.PSObject.Properties.Name | Sort-Object)
|
|
$required = @($changeBody.required | Sort-Object)
|
|
if ($changeBody.type -ne 'object' -or $changeBody.additionalProperties -ne $false -or
|
|
($properties -join ',') -ne 'newPassword,oldPassword' -or
|
|
($required -join ',') -ne 'newPassword,oldPassword') {
|
|
Add-Issue 'JSON PasswordChangeBody must be a closed object requiring only oldPassword/newPassword'
|
|
}
|
|
}
|
|
|
|
# 密码传输是跨登录、注册、找回和登录态改密的单一合同。禁止只让 M04 改成明文,
|
|
# 其余入口继续接受可重放摘要;新合同落地时必须一次删除全部 MD5 wire fallback。
|
|
Assert-FieldRef 'PasswordLoginBody' 'password' '#/components/schemas/CurrentPasswordSecret'
|
|
Assert-FieldRef 'PasswordRegisterBody' 'password' '#/components/schemas/NewPasswordSecret'
|
|
Assert-FieldRef 'PasswordResetBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
|
|
Assert-FieldRef 'PasswordChangeBody' 'oldPassword' '#/components/schemas/CurrentPasswordSecret'
|
|
Assert-FieldRef 'PasswordChangeBody' 'newPassword' '#/components/schemas/NewPasswordSecret'
|
|
|
|
$responses = @{}
|
|
foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) {
|
|
$responses[$status] = Get-Response $operation $status
|
|
[void](Get-JsonSchemaRef $responses[$status] $status)
|
|
Assert-PrivateNoStore $responses[$status] $status
|
|
}
|
|
if ((Get-JsonSchemaRef $responses['200'] '200') -ne '#/components/schemas/RVoid') {
|
|
Add-Issue 'JSON PUT password 200 must return RVoid after all sessions are invalidated'
|
|
}
|
|
foreach ($status in @('409', '422')) {
|
|
if ((Get-JsonSchemaRef $responses[$status] $status) -ne '#/components/schemas/RPasswordChangeRejected') {
|
|
Add-Issue "JSON PUT password $status must return RPasswordChangeRejected"
|
|
}
|
|
}
|
|
|
|
$retryAfterProperty = if ($responses['429'] -and $responses['429'].headers) {
|
|
$responses['429'].headers.PSObject.Properties['Retry-After']
|
|
} else { $null }
|
|
if (-not $retryAfterProperty) {
|
|
Add-Issue 'JSON PUT password 429 must document Retry-After'
|
|
}
|
|
|
|
$void = Get-Schema 'RVoid'
|
|
$rejected = Get-Schema 'RPasswordChangeRejected'
|
|
if ($void -and ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer')) {
|
|
Add-Issue 'JSON RVoid must require integer code'
|
|
}
|
|
if ($rejected) {
|
|
foreach ($field in @('code', 'businessCode')) {
|
|
if ($field -notin @($rejected.required)) { Add-Issue "JSON RPasswordChangeRejected.required missing: $field" }
|
|
}
|
|
$codes = @($rejected.properties.businessCode.enum | Sort-Object)
|
|
$expectedCodes = @(
|
|
'CREDENTIAL_VERSION_CONFLICT',
|
|
'CURRENT_PASSWORD_INCORRECT',
|
|
'NEW_PASSWORD_SAME_AS_CURRENT',
|
|
'PASSWORD_POLICY_VIOLATION'
|
|
) | Sort-Object
|
|
if ($rejected.properties.code.type -ne 'integer' -or
|
|
$rejected.properties.businessCode.type -ne 'string' -or
|
|
($codes -join ',') -ne ($expectedCodes -join ',')) {
|
|
Add-Issue 'JSON RPasswordChangeRejected must expose the four stable conflict/validation business codes'
|
|
}
|
|
}
|
|
|
|
foreach ($yamlFact in @(
|
|
' /genealogy/app/auth/password:',
|
|
' name: clientid',
|
|
'#/components/schemas/PasswordChangeBody',
|
|
'#/components/schemas/CurrentPasswordSecret',
|
|
'#/components/schemas/NewPasswordSecret',
|
|
'#/components/schemas/RPasswordChangeRejected',
|
|
' CurrentPasswordSecret:',
|
|
' NewPasswordSecret:',
|
|
' writeOnly: true',
|
|
' minLength: 15',
|
|
' maxLength: 64',
|
|
' RPasswordChangeRejected:',
|
|
' - CREDENTIAL_VERSION_CONFLICT',
|
|
' - CURRENT_PASSWORD_INCORRECT',
|
|
' - NEW_PASSWORD_SAME_AS_CURRENT',
|
|
' - PASSWORD_POLICY_VIOLATION',
|
|
' Cache-Control:',
|
|
' Retry-After:'
|
|
)) {
|
|
if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" }
|
|
}
|
|
|
|
if ($issues.Count -gt 0) {
|
|
$lines = New-Object System.Collections.Generic.List[string]
|
|
$lines.Add('PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED')
|
|
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
|
$lines.Add('- Remove static MD5 from login/register/reset/change in one contract migration; accept raw writeOnly passwords only over authenticated HTTPS and store a salted adaptive server-side hash.')
|
|
$lines.Add('- The target new-password policy is 15..64 Unicode code points, NFC, spaces allowed, no composition rule, plus server-side common/breached-password blocklist and rate limiting.')
|
|
$lines.Add('- A strict 200 means the password is durable and every pre-existing access/refresh session, including the caller, is invalidated; the client clears locally and returns to A01.')
|
|
$lines.Add('- Network, timeout, malformed response, or 5xx after dispatch is outcome-unknown: clear secrets/session, return to A01, and never retry automatically or claim success.')
|
|
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
|
|
throw ($lines -join [Environment]::NewLine)
|
|
}
|
|
|
|
Write-Output 'PASSWORD-CHANGE-OPENAPI-CONTRACT PASS'
|