Skip to content

Commit 2336d70

Browse files
committed
Add DTO validation and CSRF checks to admin user deletion
1 parent 468bac5 commit 2336d70

20 files changed

Lines changed: 997 additions & 10 deletions

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,16 @@ act push --container-architecture linux/amd64
5353
### Onboarding path
5454

5555
1. Read [Operations Playbook](./doc/OPERATIONS.md)
56-
2. Execute the "First day" flow
57-
3. Use troubleshooting matrix when blocked
56+
2. Read [DTO Validation Engine](./doc/DTO_VALIDATION.md)
57+
3. Execute the "First day" flow
58+
4. Use troubleshooting matrix when blocked
5859

5960
### Core contributor path
6061

6162
1. Read [Operations Playbook](./doc/OPERATIONS.md)
62-
2. Read [Propel Workflow](./doc/PROPEL_WORKFLOW.md)
63-
3. Run key tests and validate changes in Docker
63+
2. Read [DTO Validation Engine](./doc/DTO_VALIDATION.md)
64+
3. Read [Propel Workflow](./doc/PROPEL_WORKFLOW.md)
65+
4. Run key tests and validate changes in Docker
6466

6567
## Propel models and migrations
6668

@@ -71,6 +73,7 @@ For operational Propel flow (schema, model generation context, migration executi
7173
## Documentation index
7274

7375
- [Operations Playbook](./doc/OPERATIONS.md)
76+
- [DTO Validation Engine](./doc/DTO_VALIDATION.md)
7477
- [Propel Workflow](./doc/PROPEL_WORKFLOW.md)
7578

7679
## Rules

doc/DTO_VALIDATION.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# DTO Validation Engine (API + CSRF)
2+
3+
## Purpose
4+
5+
PSFS now supports declarative validation directly in DTOs, including optional CSRF enforcement, without depending on Twig `Form` objects.
6+
7+
This provides:
8+
9+
- Cleaner API input validation.
10+
- Explicit, testable contracts per endpoint.
11+
- Opt-in CSRF validation in DTOs for admin/session contexts.
12+
- Strict unknown-field rejection by default.
13+
14+
## Core Components
15+
16+
- `PSFS\base\dto\ValidatableDtoTrait`
17+
- `PSFS\base\dto\ValidationContext`
18+
- `PSFS\base\dto\ValidationResult`
19+
- `PSFS\base\dto\CsrfValidator`
20+
- `PSFS\base\dto\Dto` (integrates trait)
21+
22+
## Validation Attributes
23+
24+
Supported attributes under `src/base/types/helpers/attributes`:
25+
26+
- `Required` (existing)
27+
- `VarType` (existing)
28+
- `Values` (existing enum semantics)
29+
- `DefaultValue` (existing)
30+
- `Pattern`
31+
- `Min`
32+
- `Max`
33+
- `Length`
34+
- `Nullable`
35+
- `CsrfProtected` (DTO-level)
36+
- `CsrfField` (DTO-level, optional custom field names)
37+
38+
## Validation Flow
39+
40+
When `validate()` is called on a DTO:
41+
42+
1. Input is hydrated (`fromArray()` or explicit setters).
43+
2. Default values are applied.
44+
3. Unknown fields are checked (`strictUnknownFields=true` by default).
45+
4. Per-property constraints are validated:
46+
- required
47+
- type
48+
- enum/values
49+
- pattern
50+
- length
51+
- min/max
52+
5. If DTO has `#[CsrfProtected]`, CSRF token is validated.
53+
6. A `ValidationResult` is returned and can be queried via:
54+
- `isValid()`
55+
- `getErrors()`
56+
- `getValidationErrors()`
57+
58+
## Unknown Fields Policy
59+
60+
Default behavior is **fail-closed**:
61+
62+
- Any payload key not declared in DTO public properties is rejected.
63+
- Error code: `unknown_field`.
64+
65+
This prevents accidental mass-assignment and hidden payload drift.
66+
67+
## CSRF in DTOs
68+
69+
CSRF is fully declarative:
70+
71+
- Add `#[CsrfProtected(formKey: '...')]` to the DTO class.
72+
- Optionally add `#[CsrfField(tokenField: '...', tokenKeyField: '...')]`.
73+
74+
Resolution order:
75+
76+
1. Payload fields (`tokenField`, `tokenKeyField`).
77+
2. Header fallback (`X-CSRF-Token` by default, configurable via `CsrfProtected`).
78+
79+
Validation enforces one-time token semantics and expiration using `csrf.expiration`.
80+
81+
## Example DTO
82+
83+
```php
84+
<?php
85+
86+
namespace PSFS\base\dto;
87+
88+
use PSFS\base\types\helpers\attributes\CsrfField;
89+
use PSFS\base\types\helpers\attributes\CsrfProtected;
90+
use PSFS\base\types\helpers\attributes\Length;
91+
use PSFS\base\types\helpers\attributes\Pattern;
92+
93+
#[CsrfProtected(formKey: 'admin_setup')]
94+
#[CsrfField(tokenField: 'admin_setup_token', tokenKeyField: 'admin_setup_token_key')]
95+
class DeleteUserRequestDto extends Dto
96+
{
97+
/** @required */
98+
#[Length(min: 1, max: 64)]
99+
#[Pattern('/^[a-zA-Z0-9._-]+$/')]
100+
public ?string $username = null;
101+
}
102+
```
103+
104+
## API Usage Pattern
105+
106+
```php
107+
$dto = DeleteUserRequestDto::fromArray($request->getRawData());
108+
$result = $dto->validate(ValidationContext::fromRequest($request));
109+
110+
if (!$result->isValid()) {
111+
return ApiResponse::error($result->getFirstErrorMessage(), 400, $result->getErrors());
112+
}
113+
```
114+
115+
Notes:
116+
117+
- Prefer `Request::getRawData()` for payload fidelity in API controllers.
118+
- Use `ValidationContext::fromRequest($request)` to include headers for CSRF fallback.
119+
120+
## Backward Compatibility
121+
122+
- Legacy Twig/Form flow remains available during migration.
123+
- DTO validation is opt-in: only DTOs calling `validate()` are enforced.
124+
- Existing DTOs without attributes keep current behavior.
125+
126+
## Migration Strategy
127+
128+
1. Start with mutation endpoints (create/update/delete).
129+
2. Introduce DTO per endpoint command.
130+
3. Add explicit constraints and optional `CsrfProtected`.
131+
4. Replace ad-hoc checks in controller/service with `validate()`.
132+
5. Keep legacy paths temporarily where needed.
133+
134+
## Testing Recommendations
135+
136+
Mandatory for each new validated DTO:
137+
138+
- Unit tests for rules:
139+
- required/type/enum/pattern/min-max/length/defaults/nullable
140+
- unknown fields rejected
141+
- CSRF tests (when applicable):
142+
- valid token
143+
- missing token
144+
- expired token
145+
- replay token
146+
- Integration tests:
147+
- success path (`200`)
148+
- validation error path (`4xx`)
149+
150+
## Security Notes
151+
152+
- Default strict unknown-field policy is intentional.
153+
- CSRF should be enabled for browser/session-admin mutations.
154+
- Machine-to-machine/JWT-only endpoints may skip CSRF by design.

src/base/dto/CsrfValidator.php

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
namespace PSFS\base\dto;
4+
5+
use PSFS\base\config\Config;
6+
use PSFS\base\Security;
7+
8+
class CsrfValidator
9+
{
10+
private const SESSION_TOKEN_KEY = '__PSFS_CSRF_FORM_TOKENS__';
11+
private const TOKEN_REGEX = '/^[a-f0-9]{64}$/';
12+
13+
/**
14+
* @return array{token:string,key:string}
15+
*/
16+
public static function issueToken(string $formKey): array
17+
{
18+
$storage = self::purgeExpiredStorage(self::getStorage());
19+
$token = self::generateToken();
20+
$key = self::generateToken();
21+
$storage[$key] = [
22+
'token' => $token,
23+
'expires_at' => time() + self::csrfExpiration(),
24+
'form' => $formKey,
25+
];
26+
self::setStorage($storage);
27+
28+
return [
29+
'token' => $token,
30+
'key' => $key,
31+
];
32+
}
33+
34+
public static function validateSubmission(string $token, string $tokenKey, string $formKey): bool
35+
{
36+
if (preg_match(self::TOKEN_REGEX, $token) !== 1 || preg_match(self::TOKEN_REGEX, $tokenKey) !== 1) {
37+
self::purgeInvalidTokenEntry($tokenKey);
38+
return false;
39+
}
40+
41+
$storage = self::purgeExpiredStorage(self::getStorage());
42+
$entry = $storage[$tokenKey] ?? null;
43+
if (!is_array($entry)) {
44+
self::setStorage($storage);
45+
return false;
46+
}
47+
$storedToken = (string)($entry['token'] ?? '');
48+
$expiresAt = (int)($entry['expires_at'] ?? 0);
49+
$storedForm = (string)($entry['form'] ?? '');
50+
$isValid = preg_match(self::TOKEN_REGEX, $storedToken) === 1
51+
&& $expiresAt >= time()
52+
&& $storedForm === $formKey
53+
&& hash_equals($storedToken, $token);
54+
55+
unset($storage[$tokenKey]);
56+
self::setStorage($storage);
57+
58+
return $isValid;
59+
}
60+
61+
private static function purgeInvalidTokenEntry(string $tokenKey): void
62+
{
63+
if (preg_match(self::TOKEN_REGEX, $tokenKey) !== 1) {
64+
return;
65+
}
66+
$storage = self::getStorage();
67+
if (!array_key_exists($tokenKey, $storage)) {
68+
return;
69+
}
70+
unset($storage[$tokenKey]);
71+
self::setStorage($storage);
72+
}
73+
74+
/**
75+
* @return array<string, array{token:string,expires_at:int,form:string}>
76+
*/
77+
private static function getStorage(): array
78+
{
79+
$storage = Security::getInstance()->getSessionKey(self::SESSION_TOKEN_KEY);
80+
return is_array($storage) ? $storage : [];
81+
}
82+
83+
/**
84+
* @param array<string, array{token:string,expires_at:int,form:string}> $storage
85+
*/
86+
private static function setStorage(array $storage): void
87+
{
88+
$security = Security::getInstance();
89+
$security->setSessionKey(self::SESSION_TOKEN_KEY, $storage);
90+
$security->updateSession();
91+
}
92+
93+
/**
94+
* @param array<string, array{token:string,expires_at:int,form:string}> $storage
95+
* @return array<string, array{token:string,expires_at:int,form:string}>
96+
*/
97+
private static function purgeExpiredStorage(array $storage): array
98+
{
99+
$now = time();
100+
foreach ($storage as $key => $entry) {
101+
if ((int)($entry['expires_at'] ?? 0) < $now) {
102+
unset($storage[$key]);
103+
}
104+
}
105+
106+
return $storage;
107+
}
108+
109+
private static function generateToken(): string
110+
{
111+
try {
112+
return bin2hex(random_bytes(32));
113+
} catch (\Exception) {
114+
return hash('sha256', uniqid('csrf', true) . ':' . microtime(true));
115+
}
116+
}
117+
118+
private static function csrfExpiration(): int
119+
{
120+
$expiration = (int)Config::getParam('csrf.expiration', 1800);
121+
return max(60, $expiration);
122+
}
123+
}
124+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace PSFS\base\dto;
4+
5+
use PSFS\base\types\helpers\attributes\CsrfField;
6+
use PSFS\base\types\helpers\attributes\CsrfProtected;
7+
use PSFS\base\types\helpers\attributes\Length;
8+
use PSFS\base\types\helpers\attributes\Pattern;
9+
use PSFS\base\types\helpers\attributes\Required;
10+
use PSFS\base\types\helpers\attributes\VarType;
11+
12+
#[CsrfProtected(formKey: 'admin_setup')]
13+
#[CsrfField('admin_setup_token', 'admin_setup_token_key')]
14+
class DeleteUserRequestDto extends Dto
15+
{
16+
#[Required]
17+
#[VarType('string')]
18+
#[Length(min: 1, max: 120)]
19+
#[Pattern('/^[A-Za-z0-9._@\-]+$/')]
20+
public ?string $user = null;
21+
}

src/base/dto/Dto.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
*/
1414
class Dto extends Singleton implements \JsonSerializable
1515
{
16+
use ValidatableDtoTrait;
17+
1618
/**
1719
* @var array
1820
*/
@@ -117,6 +119,7 @@ protected function parseDtoField(array $properties, string $key, $value = null)
117119
*/
118120
public function fromArray(array $object = [])
119121
{
122+
$this->setValidationInputData($object);
120123
if (!empty($object)) {
121124
$reflector = new \ReflectionClass($this);
122125
$properties = InjectorHelper::extractProperties(

0 commit comments

Comments
 (0)