@nodefony/security 10.0.0-alpha.9 → 10.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-token.js +4 -4
- package/dist/nodefony/command/security-user-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +3 -3
- package/dist/nodefony/service/authFlow.js +2 -2
- package/dist/nodefony/service/authorization.js +3 -4
- package/dist/nodefony/service/firewall.js +21 -9
- package/dist/nodefony/service/oauth2.js +5 -3
- package/dist/nodefony/service/securityHeaders.js +13 -2
- package/dist/nodefony/service/tokenService.js +27 -18
- package/dist/nodefony/service/webAuthn.js +38 -31
- package/dist/nodefony/service/webhooks.js +50 -41
- package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
- package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
- package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
- package/dist/nodefony/src/audit/auditBridge.js +9 -5
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
- package/dist/nodefony/src/csp.js +4 -5
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
- package/dist/nodefony/src/token/JwtKeystore.js +7 -6
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -1
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
- package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
- package/dist/types/nodefony/service/firewall.d.ts +3 -2
- package/dist/types/nodefony/service/securityHeaders.d.ts +7 -6
- package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
- package/docs/api-keys.md +16 -16
- package/docs/audit.md +27 -20
- package/docs/authenticators.md +8 -8
- package/docs/authorization.md +15 -15
- package/docs/cors.md +7 -7
- package/docs/csrf.md +8 -8
- package/docs/external-jwt.md +19 -10
- package/docs/firewall.md +28 -28
- package/docs/headers.md +20 -20
- package/docs/index.md +6 -4
- package/docs/lexique.md +15 -15
- package/docs/oauth2.md +23 -20
- package/docs/tokens.md +11 -10
- package/docs/totp.md +15 -14
- package/docs/webauthn.md +51 -54
- package/docs/webhooks.md +27 -30
- package/package.json +13 -13
package/docs/api-keys.md
CHANGED
|
@@ -137,7 +137,7 @@ protégerait que des secrets faibles, et coûterait sur le chemin chaud.
|
|
|
137
137
|
|
|
138
138
|
### 1. Déclarer la zone machine et les règles d'émission
|
|
139
139
|
|
|
140
|
-
Dans une app générée par `nodefony create app`, tout se déclare dans `nodefony.config.ts`. Une zone
|
|
140
|
+
Dans une app générée par `nodefony create app`, tout se déclare dans la config du module — `nodefony/config/security.ts`, importé par `nodefony.config.ts`. Une zone
|
|
141
141
|
dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
|
|
142
142
|
|
|
143
143
|
```typescript
|
|
@@ -229,7 +229,7 @@ curl -si https://localhost:5152/api/v1/orders/list | head -1
|
|
|
229
229
|
> [!WARNING]
|
|
230
230
|
> Le champ `token` de la réponse 201 est la **seule** occasion de lire le secret : il n'est pas
|
|
231
231
|
> stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts:44`). Le listing ultérieur ne
|
|
232
|
-
> renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:
|
|
232
|
+
> renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:386`). Perdue = ré-émise.
|
|
233
233
|
|
|
234
234
|
## 🔐 Anatomie d'une clé — ce que chaque morceau paie
|
|
235
235
|
|
|
@@ -276,16 +276,16 @@ jamais par le préfixe.
|
|
|
276
276
|
|
|
277
277
|
### Émission — `ApiKeyService.createForSubject()`
|
|
278
278
|
|
|
279
|
-
`ApiKeyService.createForSubject()` (`apiKeys.ts:
|
|
279
|
+
`ApiKeyService.createForSubject()` (`apiKeys.ts:121`) est le seul chemin d'émission. Dans l'ordre :
|
|
280
280
|
|
|
281
281
|
1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
|
|
282
|
-
`apiKeys.ts:
|
|
282
|
+
`apiKeys.ts:330`).
|
|
283
283
|
2. **Validation des scopes** — tableau de chaînes non vides, dédupliquées, et **⊆ catalogue** si
|
|
284
|
-
`allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:
|
|
284
|
+
`allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:341`).
|
|
285
285
|
3. **Résolution de l'expiration** — `expiresInDays` explicite, sinon le défaut de config, `null` =
|
|
286
|
-
sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:
|
|
286
|
+
sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:363`).
|
|
287
287
|
4. **Plafond anti-abus** — on ne compte que les clés **actives** (ni révoquées ni expirées) via
|
|
288
|
-
`#isActive()` (`apiKeys.ts:
|
|
288
|
+
`#isActive()` (`apiKeys.ts:379`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts:48`).
|
|
289
289
|
5. **Génération** — 32 octets aléatoires, `publicPrefix` et `secretHash` dérivés
|
|
290
290
|
(`generateApiKey()`, `apiKeyFormat.ts:92`).
|
|
291
291
|
6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:177`).
|
|
@@ -293,7 +293,7 @@ jamais par le préfixe.
|
|
|
293
293
|
secret** (`apiKeys.ts:183-189`).
|
|
294
294
|
|
|
295
295
|
Le service ne connaît pas le store à la construction : il le résout **paresseusement** du container
|
|
296
|
-
au premier usage (`#resolveStore()`, `apiKeys.ts:
|
|
296
|
+
au premier usage (`#resolveStore()`, `apiKeys.ts:317`) — indépendant de l'ordre de boot. Store
|
|
297
297
|
absent = **503 explicite**, jamais une 500 opaque.
|
|
298
298
|
|
|
299
299
|
### Vérification — `ApiKeyAuthenticator.authenticate()`
|
|
@@ -353,7 +353,7 @@ apiKeys: {
|
|
|
353
353
|
**Ce qu'on observe** : `npx nodefony security:user:add ci-bot` (rôle `ROLE_USER` par défaut), login
|
|
354
354
|
en tant que `ci-bot`, puis émission avec `{"scopes":["orders:read"]}`. Demander
|
|
355
355
|
`{"scopes":["orders:write"]}` renvoie **400 `scope not allowed: orders:write`** — refusé à
|
|
356
|
-
l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:
|
|
356
|
+
l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:341`).
|
|
357
357
|
|
|
358
358
|
> [!TIP]
|
|
359
359
|
> Le catalogue `allowedScopes` de la config est un **complément**, pas la source : la console
|
|
@@ -368,7 +368,7 @@ doit y avoir **aucune** fenêtre pendant laquelle le job échoue.
|
|
|
368
368
|
|
|
369
369
|
**Il n'y a pas de bouton « rotate »** — et c'est délibéré : une rotation atomique impliquerait
|
|
370
370
|
soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure. Le motif est le
|
|
371
|
-
**recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:
|
|
371
|
+
**recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:48`) :
|
|
372
372
|
|
|
373
373
|
1. Émettre une **seconde** clé (même porteur, mêmes scopes, nom `CI deploy v2`).
|
|
374
374
|
2. Déployer le nouveau secret dans le CI.
|
|
@@ -388,8 +388,8 @@ Deux chemins, selon qui agit :
|
|
|
388
388
|
|
|
389
389
|
| Qui | Endpoint | Portée | Ancrage |
|
|
390
390
|
| ---------- | ------------------------------------------------- | ---------------------- | --------------------------------------- |
|
|
391
|
-
| Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:
|
|
392
|
-
| Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:
|
|
391
|
+
| Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:296`) |
|
|
392
|
+
| Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:250`) |
|
|
393
393
|
|
|
394
394
|
**Ce qu'on observe** : la révocation est **idempotente** et prend effet à la requête suivante —
|
|
395
395
|
l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts:111-118`).
|
|
@@ -417,7 +417,7 @@ Trois sources, et il faut connaître les limites de chacune :
|
|
|
417
417
|
|
|
418
418
|
1. **L'état** — le listing d'administration paginé, tous porteurs confondus : `GET
|
|
419
419
|
/nodefony/security/api/apikeys` (`SecurityAdminApi.ts:394`), servi par `listPagePat()`
|
|
420
|
-
(`apiKeys.ts:
|
|
420
|
+
(`apiKeys.ts:209`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
|
|
421
421
|
`order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts:126`), plafonnée à 200 entrées
|
|
422
422
|
(`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:114`).
|
|
423
423
|
|
|
@@ -596,7 +596,7 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
|
|
|
596
596
|
| Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts:17`) |
|
|
597
597
|
| Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:111-118`) |
|
|
598
598
|
| Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts:30`) |
|
|
599
|
-
| Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:
|
|
599
|
+
| Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:48`) |
|
|
600
600
|
|
|
601
601
|
## ⚡ Performance & mémoire
|
|
602
602
|
|
|
@@ -614,7 +614,7 @@ Le coût par requête authentifiée par clé est **maîtrisé par construction**
|
|
|
614
614
|
container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts:173`, `apiKeys.ts:268`) — le boot
|
|
615
615
|
ne paie rien si aucune clé n'est jamais présentée.
|
|
616
616
|
- **Jamais N enregistrements en RAM** côté administration : le listing est paginé **au store**
|
|
617
|
-
(`listPagePat()`, `apiKeys.ts:
|
|
617
|
+
(`listPagePat()`, `apiKeys.ts:209`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts:107`).
|
|
618
618
|
|
|
619
619
|
Le point de vigilance restant : `createForSubject()` compte les clés actives via `findBySubject()`
|
|
620
620
|
(`ITokenStore.ts:211`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
|
|
@@ -649,7 +649,7 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
|
|
|
649
649
|
| 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
|
|
650
650
|
| Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts:70`) | Émettre une nouvelle clé, révoquer l'ancienne |
|
|
651
651
|
| 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:144-147`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
|
|
652
|
-
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:
|
|
652
|
+
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:284`) | Ajouter le scope au catalogue, ou corriger la demande |
|
|
653
653
|
| Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:142`) | Garder le `prefix` STABLE après la première émission |
|
|
654
654
|
| Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.ts:50`) | Émettre une clé portant le scope exigé par `@RequireScope` |
|
|
655
655
|
| Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:121-124`) | Réactiver le compte, ou réémettre après le bannissement |
|
package/docs/audit.md
CHANGED
|
@@ -135,7 +135,8 @@ lourds s'enregistrent depuis **leur** module. Un
|
|
|
135
135
|
## 🚀 Démarrage rapide
|
|
136
136
|
|
|
137
137
|
Dans une app générée par `nodefony create app`, l'audit est **déjà actif** (`enabled: true` par
|
|
138
|
-
défaut, `config.ts:729`) sur
|
|
138
|
+
défaut, `config.ts:729`) sur le store que résout `auto` : la base SQLite locale de l'application
|
|
139
|
+
générée, la mémoire seulement si aucun ORM n'est chargé. Voici le parcours complet : configurer, émettre,
|
|
139
140
|
relire.
|
|
140
141
|
|
|
141
142
|
### 1. Choisir où le journal est écrit
|
|
@@ -196,7 +197,7 @@ export default ExportController;
|
|
|
196
197
|
```
|
|
197
198
|
|
|
198
199
|
> [!IMPORTANT]
|
|
199
|
-
> `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:
|
|
200
|
+
> `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:71`) : il n'existe
|
|
200
201
|
> pas de catégorie « métier ». C'est volontaire — ce journal est celui de la sécurité. Range ton
|
|
201
202
|
> événement dans la catégorie de sécurité qu'il concerne (ici `authz` : un accès privilégié à des
|
|
202
203
|
> données) et laisse `action` porter le vocabulaire métier.
|
|
@@ -265,7 +266,7 @@ Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
|
|
|
265
266
|
| `requestId` | tous les événements d'**une seule requête** | `requestId=req-7c1e` |
|
|
266
267
|
| `since` | borne basse d'horodatage, epoch ms **inclus** | `since=1763503200000` |
|
|
267
268
|
| `until` | borne haute d'horodatage, epoch ms **inclus** | `until=1763524800000` |
|
|
268
|
-
| `limit` | taille de page (défaut 100, **plafonné à
|
|
269
|
+
| `limit` | taille de page (défaut 100, **plafonné à 200**) | `limit=200` |
|
|
269
270
|
| `cursor` | le `nextCursor` de la page précédente | `cursor=1763548800123:9f-1` |
|
|
270
271
|
|
|
271
272
|
```bash
|
|
@@ -317,7 +318,7 @@ anti-avalanche en cluster. Chaque
|
|
|
317
318
|
tour appelle la purge du contrat — `gc()`, voisin de `listPage()` dans le même contrat
|
|
318
319
|
(`IAuditStore.ts:59`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
|
|
319
320
|
côté SQL (`DrizzleAuditStore.ts:256`), un défilement de file tant que l'événement de tête dépasse le
|
|
320
|
-
`threshold` côté mémoire (`MemoryAuditStore.ts:
|
|
321
|
+
`threshold` côté mémoire (`MemoryAuditStore.ts:133`).
|
|
321
322
|
|
|
322
323
|
Ce qu'on observe dans les logs : `audit gc — 1284 événement(s) purgé(s)` en niveau DEBUG.
|
|
323
324
|
|
|
@@ -332,7 +333,7 @@ La base est indisponible pendant trente secondes. Que se passe-t-il ?
|
|
|
332
333
|
|
|
333
334
|
Le choix de Nodefony est explicite : **le métier passe avant la trace**. L'écriture part en
|
|
334
335
|
fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts:199`) ; côté SQL, si l'ORM n'est
|
|
335
|
-
pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:
|
|
336
|
+
pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:152`). Un login n'échoue jamais
|
|
336
337
|
parce que le journal est cassé.
|
|
337
338
|
|
|
338
339
|
> [!WARNING]
|
|
@@ -357,17 +358,22 @@ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouvert
|
|
|
357
358
|
<!-- prettier-ignore -->
|
|
358
359
|
| Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
|
|
359
360
|
| --- | --- | --- |
|
|
360
|
-
| `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.unverifiable` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` |
|
|
361
|
-
| `authz` | autorisation (voters, `@IsGranted`) | `access.denied` |
|
|
361
|
+
| `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.unverifiable` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` · `user.password_change_self` (par `@nodefony/user`) |
|
|
362
|
+
| `authz` | autorisation (voters, `@IsGranted`), administration des comptes | `access.denied` · `user.created` · `user.updated` · `user.password_changed` · `user.deleted` (ces quatre-là par `@nodefony/user`) |
|
|
362
363
|
| `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
|
|
363
|
-
| `session` | cycle de vie de session | `logout` |
|
|
364
|
+
| `session` | cycle de vie de session | `logout` · `session.revoked` (révocation depuis l'administration, par `@nodefony/http`) |
|
|
364
365
|
| `webauthn` | passkeys | `user.passkey_revoked` |
|
|
365
366
|
| `ws` | verrou de frame WebSocket | `frame.denied` |
|
|
366
367
|
| `webhook` | webhooks sortants | `webhook.created` · `webhook.updated` · `webhook.deleted` · `webhook.rotated` · `webhook.revealed` · `webhook.disabled` |
|
|
367
368
|
| `oauth` | login social OAuth2 | _catégorie déclarée, aucune action émise aujourd'hui_ |
|
|
368
369
|
| `csrf` | défense CSRF | _catégorie déclarée, aucune action émise aujourd'hui_ |
|
|
369
370
|
| `cors` | politique CORS | _catégorie déclarée, aucune action émise aujourd'hui_ |
|
|
370
|
-
| `config` | mutation de config runtime depuis Studio |
|
|
371
|
+
| `config` | mutation de config runtime depuis Studio | `config.update` · `log.debug.set` · `log.debug.clear` (par `@nodefony/framework`) |
|
|
372
|
+
|
|
373
|
+
Toute catégorie émise appartient au type `AuditCategory`, y compris depuis un paquet qui ne peut pas
|
|
374
|
+
l'importer (`@nodefony/user`, `@nodefony/framework`) : `tests/unit/auditCategoryEmitters.test.ts`
|
|
375
|
+
relit les sources de tous les paquets et refuse un littéral hors contrat. Un filtre sur les
|
|
376
|
+
catégories déclarées voit donc tout le journal.
|
|
371
377
|
|
|
372
378
|
Les trois issues possibles (`IAuditEvent.ts:43`) ne sont pas interchangeables : `failure` = **l'acteur
|
|
373
379
|
a échoué une preuve** (mauvais mot de passe, signature invalide) ; `denied` = **une politique a
|
|
@@ -377,7 +383,7 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
|
|
|
377
383
|
### `auth` — la chaîne d'authentification
|
|
378
384
|
|
|
379
385
|
Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
|
|
380
|
-
(`firewall.ts:
|
|
386
|
+
(`firewall.ts:907`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
|
|
381
387
|
|
|
382
388
|
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:781`) ;
|
|
383
389
|
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:807`) ;
|
|
@@ -460,7 +466,7 @@ Quatre mécanismes, tous prouvés par les tests.
|
|
|
460
466
|
|
|
461
467
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
462
468
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
463
|
-
(`firewall.ts:
|
|
469
|
+
(`firewall.ts:907`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:358`).
|
|
464
470
|
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
|
|
465
471
|
|
|
466
472
|
**2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
|
|
@@ -480,7 +486,7 @@ alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est
|
|
|
480
486
|
Le pont applique en plus un **coalescing borné** : au plus une frame WS toutes les 250 ms
|
|
481
487
|
(`auditBridge.ts:59`), tampon plafonné à 200 événements (`auditBridge.ts:60`). Sous une rafale
|
|
482
488
|
d'échecs de login, le tampon écrase les plus anciens et compte les omis dans `dropped`
|
|
483
|
-
(`auditBridge.ts:
|
|
489
|
+
(`auditBridge.ts:86`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
|
|
484
490
|
jamais faire tomber ce qu'on supervise.
|
|
485
491
|
|
|
486
492
|
## ⚙️ Configuration
|
|
@@ -491,7 +497,7 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la
|
|
|
491
497
|
| Option | Type | Défaut | Effet |
|
|
492
498
|
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
493
499
|
| `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
|
|
494
|
-
| `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`
|
|
500
|
+
| `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`, `mongoose` |
|
|
495
501
|
| `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
|
|
496
502
|
| `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
|
|
497
503
|
| `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
|
|
@@ -509,7 +515,8 @@ enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:
|
|
|
509
515
|
|
|
510
516
|
1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
|
|
511
517
|
2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
|
|
512
|
-
3. sinon,
|
|
518
|
+
3. sinon, un backend local persistant réellement chargé (`drizzle` = SQLite, puis `mongoose`) ;
|
|
519
|
+
4. sinon, repli **annoncé** sur `memory` (volatil) — la décision est loggée en INFO, jamais silencieuse.
|
|
513
520
|
|
|
514
521
|
La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
|
|
515
522
|
de Studio : configuré, résolu, disponible, motif, emplacement physique.
|
|
@@ -545,7 +552,7 @@ Quatre pièces, quatre responsabilités :
|
|
|
545
552
|
**`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
|
|
546
553
|
d'audit.
|
|
547
554
|
- **`createAuditBridge()`** (`auditBridge.ts:53`) — le pont vers le canal WS `nodefony:audit`
|
|
548
|
-
(`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:
|
|
555
|
+
(`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:387`) et donc
|
|
549
556
|
gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
|
|
550
557
|
|
|
551
558
|
### La lecture paginée — pourquoi un curseur et pas un décalage
|
|
@@ -567,7 +574,7 @@ tiennent identiquement :
|
|
|
567
574
|
|
|
568
575
|
Le `total` est **refusable** (`withTotal: false`) : un `COUNT` filtré sur une rétention longue se
|
|
569
576
|
paie (`IAuditStore.ts:59`). `hasNext` et `nextCursor` restent fiables dans les deux cas, grâce à une
|
|
570
|
-
ligne de garde `limit + 1` (`DrizzleAuditStore.ts:
|
|
577
|
+
ligne de garde `limit + 1` (`DrizzleAuditStore.ts:225`).
|
|
571
578
|
|
|
572
579
|
## Entité de persistance et dialectes pris en charge
|
|
573
580
|
|
|
@@ -632,7 +639,7 @@ Ce que le code garantit, concrètement :
|
|
|
632
639
|
- **Le typage rend le secret difficile à faire entrer.** `actor` est documenté comme un _libellé
|
|
633
640
|
d'identité_ (`IAuditEvent.ts:82`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
|
|
634
641
|
les en-têtes de la requête » (`IAuditEvent.ts:87`).
|
|
635
|
-
- **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:
|
|
642
|
+
- **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:53`) ne porte que deux
|
|
636
643
|
booléens : un en-tête `Authorization` était-il là, un cookie était-il là. `readAuditContext()`
|
|
637
644
|
les calcule par un simple `Boolean(headers[…])` (`readAuditContext.ts:40`) — la valeur n'est jamais
|
|
638
645
|
copiée.
|
|
@@ -697,10 +704,10 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
|
|
|
697
704
|
| Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
|
|
698
705
|
| Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
|
|
699
706
|
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:115`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
|
|
700
|
-
| `limit=5000` ne rend que
|
|
707
|
+
| `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts:77`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
|
|
701
708
|
| La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
|
|
702
|
-
|
|
|
703
|
-
| Le paramètre `q`
|
|
709
|
+
| `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
|
|
710
|
+
| Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
|
|
704
711
|
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:55`) | Retirer le rôle admin, ou ne pas exposer le canal |
|
|
705
712
|
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
|
|
706
713
|
| Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
|
package/docs/authenticators.md
CHANGED
|
@@ -105,7 +105,7 @@ totalement agnostique de la stratégie :
|
|
|
105
105
|
### Le registre pluggable
|
|
106
106
|
|
|
107
107
|
Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
|
|
108
|
-
(`firewall.ts:
|
|
108
|
+
(`firewall.ts:435`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
|
|
109
109
|
un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
|
|
110
110
|
|
|
111
111
|
- Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
|
|
@@ -116,7 +116,7 @@ un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggabl
|
|
|
116
116
|
- La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
|
|
117
117
|
`tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
|
|
118
118
|
- Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
|
|
119
|
-
(`firewall.ts:
|
|
119
|
+
(`firewall.ts:588`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
|
|
120
120
|
faute de frappe.
|
|
121
121
|
|
|
122
122
|
## 🚀 Démarrage rapide
|
|
@@ -185,7 +185,7 @@ curl -si http://localhost:5151/api/v1/whoami | grep -E "^(HTTP|WWW)"
|
|
|
185
185
|
|
|
186
186
|
# 2) Web — login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
|
|
187
187
|
curl -si -c /tmp/jar -H 'Content-Type: application/json' \
|
|
188
|
-
-d '{"username":"admin","password":"
|
|
188
|
+
-d '{"username":"admin","password":"nodefony-dev-42"}' \
|
|
189
189
|
http://localhost:5151/nodefony/security/api/auth/login | head -1
|
|
190
190
|
# HTTP/1.1 200 OK
|
|
191
191
|
|
|
@@ -228,7 +228,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
|
|
|
228
228
|
si preuve présente, sinon visiteur anonyme accepté ». En mode `all`, utile en **dernier** :
|
|
229
229
|
« canal prouvé (ex. mTLS), identité utilisateur optionnelle ».
|
|
230
230
|
- Sans lui, zone protégée + aucune preuve = 401 : la défense en profondeur du firewall n'accepte un
|
|
231
|
-
token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:
|
|
231
|
+
token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:850`).
|
|
232
232
|
- **Faille fermée** : l'anonymat _implicite_ — ici il est un choix explicite et auditable, jamais un
|
|
233
233
|
défaut.
|
|
234
234
|
|
|
@@ -349,7 +349,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
349
349
|
|
|
350
350
|
> [!NOTE]
|
|
351
351
|
> **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
|
|
352
|
-
> frames lisent un cache O(1)) → une révocation prend effet
|
|
352
|
+
> frames lisent un cache O(1)) → une révocation prend effet **en une fenêtre** (tick du hub, et devant chaque `api.request`), pas à la frame
|
|
353
353
|
> suivante. C'est l'état de l'art (Socket.IO/Phoenix figent aussi au handshake) ; la révocation
|
|
354
354
|
> immédiate forte passe par le JWT + un canal « token révoqué ».
|
|
355
355
|
|
|
@@ -409,7 +409,7 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
|
|
|
409
409
|
Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
|
|
410
410
|
invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
|
|
411
411
|
client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
|
|
412
|
-
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:
|
|
412
|
+
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1216`).
|
|
413
413
|
|
|
414
414
|
## 🧩 Ajouter un authenticator maison
|
|
415
415
|
|
|
@@ -435,7 +435,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
435
435
|
<!-- prettier-ignore -->
|
|
436
436
|
| Domaine | Norme | Ancrage |
|
|
437
437
|
| --- | --- | --- |
|
|
438
|
-
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:
|
|
438
|
+
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
|
|
439
439
|
| Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
|
|
440
440
|
| JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
|
|
441
441
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
|
|
@@ -463,7 +463,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
463
463
|
<!-- prettier-ignore -->
|
|
464
464
|
| Symptôme | Cause (dans le code) | Correction |
|
|
465
465
|
| --- | --- | --- |
|
|
466
|
-
| 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:
|
|
466
|
+
| 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:850`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
|
|
467
467
|
| 401 générique + log ERROR « service `users` » | Câblage : pas de `UserService` au container (`authenticatorRegistry.ts:85-88`) | Enregistrer un `UserService` au boot de l'app |
|
|
468
468
|
| JWT rejeté alors qu'il « semble » valide | `aud`/`iss`/`typ` non conformes, ou `alg` ≠ EdDSA (`JwtAuthenticator.ts:103-107`) | Émettre via le `TokenService` (mêmes iss/aud/typ) |
|
|
469
469
|
| Clé API révoquée encore acceptée quelques secondes | Confusion avec un JWT (auto-porté) — `revokedAt` est lu à chaque requête (`ApiKeyAuthenticator.ts:110`) | Un PAT est révoqué immédiatement ; vérifier `revokedAt` |
|
package/docs/authorization.md
CHANGED
|
@@ -331,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
|
|
|
331
331
|
|
|
332
332
|
**Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
|
|
333
333
|
`@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
|
|
334
|
-
(`Resolver.ts:
|
|
334
|
+
(`Resolver.ts:878-885`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
|
|
335
335
|
consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
|
|
336
336
|
`@IsGranted("doc.edit", { subject: "id" })`.
|
|
337
337
|
|
|
@@ -360,20 +360,20 @@ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`
|
|
|
360
360
|
|
|
361
361
|
| Déclaration | Sémantique |
|
|
362
362
|
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
363
|
-
| `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:
|
|
364
|
-
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:
|
|
365
|
-
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:
|
|
366
|
-
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:
|
|
367
|
-
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:
|
|
368
|
-
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:
|
|
369
|
-
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:
|
|
370
|
-
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:
|
|
363
|
+
| `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:903`) |
|
|
364
|
+
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:424-433`) |
|
|
365
|
+
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1579`) |
|
|
366
|
+
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1544`) |
|
|
367
|
+
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:923`) |
|
|
368
|
+
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:1002`) |
|
|
369
|
+
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:953`) |
|
|
370
|
+
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1283`) |
|
|
371
371
|
|
|
372
372
|
La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
|
|
373
|
-
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:
|
|
373
|
+
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:489-491`). Le
|
|
374
374
|
même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
|
|
375
375
|
transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
|
|
376
|
-
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:
|
|
376
|
+
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:853`).
|
|
377
377
|
|
|
378
378
|
> [!IMPORTANT]
|
|
379
379
|
> **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
|
|
@@ -511,7 +511,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
511
511
|
|
|
512
512
|
## 🔌 HTTP et WebSocket — une garde, N transports
|
|
513
513
|
|
|
514
|
-
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:
|
|
514
|
+
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:446`) est le point unique
|
|
515
515
|
d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
|
|
516
516
|
`@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
|
|
517
517
|
`ws-isgranted-jwt`).
|
|
@@ -541,10 +541,10 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
541
541
|
## ⚡ Performance & mémoire
|
|
542
542
|
|
|
543
543
|
- **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
|
|
544
|
-
alloc (`Resolver.ts:
|
|
545
|
-
requêtes (`SecurityRequirement`, `routerDecorators.ts:
|
|
544
|
+
alloc (`Resolver.ts:486`) ; l'exigence est **figée une fois** par route et partagée entre
|
|
545
|
+
requêtes (`SecurityRequirement`, `routerDecorators.ts:444`).
|
|
546
546
|
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
|
|
547
|
-
instanciés **une seule fois** au boot (`authorization.ts:
|
|
547
|
+
instanciés **une seule fois** au boot (`authorization.ts:70`).
|
|
548
548
|
- **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
|
|
549
549
|
(`RoleHierarchyWalker.ts:23-30`).
|
|
550
550
|
- **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
|
package/docs/cors.md
CHANGED
|
@@ -134,7 +134,7 @@ chaque appel du front.
|
|
|
134
134
|
### 1. La config — dans `nodefony.config.ts`
|
|
135
135
|
|
|
136
136
|
```typescript
|
|
137
|
-
// nodefony
|
|
137
|
+
// nodefony/config/security.ts (extrait généré par `nodefony create app`, puis complété)
|
|
138
138
|
use("@nodefony/security", {
|
|
139
139
|
cors: {
|
|
140
140
|
// Allowlist EXACTE : scheme + host + port. Un sous-domaine n'est PAS inclus.
|
|
@@ -323,7 +323,7 @@ sequenceDiagram
|
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
`Firewall.handleCors()` (`firewall.ts:1007`) est appelé **en tête de** `HttpKernel.handleHttp()`
|
|
326
|
-
(`http-kernel.ts:
|
|
326
|
+
(`http-kernel.ts:1456`), à la ligne `http-kernel.ts:1456` — **avant le routing**. La raison est
|
|
327
327
|
concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
|
|
328
328
|
router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
|
|
329
329
|
credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
|
|
@@ -354,7 +354,7 @@ Deux nuances utiles :
|
|
|
354
354
|
`Access-Control-Request-Headers` du client (`cors.ts:78`). Ce que tu déclares est ce qui est annoncé,
|
|
355
355
|
point. Un en-tête custom non déclaré fait échouer le preflight côté navigateur.
|
|
356
356
|
- **Les fichiers statiques sont couverts.** `handleCors` s'exécute avant le fallback `serve-static`
|
|
357
|
-
(`http-kernel.ts:
|
|
357
|
+
(`http-kernel.ts:1630`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
|
|
358
358
|
tes routes.
|
|
359
359
|
|
|
360
360
|
Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
|
|
@@ -387,7 +387,7 @@ victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un con
|
|
|
387
387
|
(`firewall.ts:1007`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
|
|
388
388
|
|
|
389
389
|
La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
|
|
390
|
-
(`http-kernel.ts:
|
|
390
|
+
(`http-kernel.ts:621`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
|
|
391
391
|
si elle est refusée. Sa doctrine :
|
|
392
392
|
|
|
393
393
|
- **same-origin par défaut** : l'`Origin` du handshake doit correspondre au `Host` servi ;
|
|
@@ -409,7 +409,7 @@ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
|
|
|
409
409
|
| `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:156-159`) + reflet défensif (`cors.ts:58`) |
|
|
410
410
|
| Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts:81`, `cors.ts:94`) |
|
|
411
411
|
| Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts:57`) |
|
|
412
|
-
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:
|
|
412
|
+
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:621`) |
|
|
413
413
|
|
|
414
414
|
## ⚡ Performance & mémoire
|
|
415
415
|
|
|
@@ -428,7 +428,7 @@ Le coût par requête est donc :
|
|
|
428
428
|
## 📡 Observabilité — Studio
|
|
429
429
|
|
|
430
430
|
La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
|
|
431
|
-
`Firewall.describe()` (`firewall.ts:
|
|
431
|
+
`Firewall.describe()` (`firewall.ts:555`), qui délègue à `Firewall.#describeDefenses()`
|
|
432
432
|
(`firewall.ts:591`). La projection CORS y expose `origins`, `credentials`, `methods`,
|
|
433
433
|
`allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:610`) — aucun secret ne transite par
|
|
434
434
|
cette surface.
|
|
@@ -455,7 +455,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
|
|
|
455
455
|
| Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts:78`) | Déclarer l'en-tête dans `cors.allowedHeaders` |
|
|
456
456
|
| Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts:81`) | Ne pas `setHeader("Vary", …)` dans un controller — utiliser `appendHeader` |
|
|
457
457
|
| `OPTIONS` renvoie 405 au lieu de 204 | Requête `OPTIONS` **sans** `Access-Control-Request-Method` : ce n'est pas un preflight | Envoyer l'en-tête, ou déclarer une route `OPTIONS` |
|
|
458
|
-
| Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:
|
|
458
|
+
| Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:621`) — vérifier `allowedOrigins` |
|
|
459
459
|
| Ouverture CORS « temporaire » restée en production | `origins:["*"]` posé en dev | Vérifier la valeur **résolue** dans Studio, pas le fichier source |
|
|
460
460
|
|
|
461
461
|
## 🧪 Tests & couverture
|
package/docs/csrf.md
CHANGED
|
@@ -93,7 +93,7 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
|
|
|
93
93
|
- **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
|
|
94
94
|
couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:163-168`).
|
|
95
95
|
- **Globale, pas liée aux zones** : toute mutation cross-site est refusée, route publique ou non —
|
|
96
|
-
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:
|
|
96
|
+
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1747`) arrive **après** le
|
|
97
97
|
resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
|
|
98
98
|
attaquant ne coûte ni lecture de session ni authentification).
|
|
99
99
|
- **Logique pure** : la classe `Csrf` est synchrone, sans I/O ni allocation sur le hot-path —
|
|
@@ -148,7 +148,7 @@ export default ProfileController;
|
|
|
148
148
|
|
|
149
149
|
(Wiring : `@controllers([ProfileController])` dans le module de l'app — `nodefony create controller`
|
|
150
150
|
le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les actions : les marqueurs
|
|
151
|
-
`csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:
|
|
151
|
+
`csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1650-1655`.)
|
|
152
152
|
|
|
153
153
|
### Comment le front obtient — puis rejoue — le token
|
|
154
154
|
|
|
@@ -241,7 +241,7 @@ autorisation :
|
|
|
241
241
|
```
|
|
242
242
|
|
|
243
243
|
> [!WARNING]
|
|
244
|
-
> `@CsrfExempt` (`routerDecorators.ts:
|
|
244
|
+
> `@CsrfExempt` (`routerDecorators.ts:1142`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
|
|
245
245
|
> webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
|
|
246
246
|
|
|
247
247
|
Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
|
|
@@ -296,7 +296,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
296
296
|
1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
|
|
297
297
|
`@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts:886`).
|
|
298
298
|
2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
|
|
299
|
-
(`Resolver.ts:
|
|
299
|
+
(`Resolver.ts:156-186`) — champs portés par le `Context` de base, HTTP comme WS
|
|
300
300
|
(`Context.ts:241-243`).
|
|
301
301
|
3. `Firewall.enforceCsrf()` (`firewall.ts:948`) fait les trois rôles : **émission** du token sur
|
|
302
302
|
requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
|
|
@@ -315,7 +315,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
315
315
|
| `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:169-174`). |
|
|
316
316
|
| `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:176-181`). |
|
|
317
317
|
| `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:182-187`). |
|
|
318
|
-
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:
|
|
318
|
+
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:542`). |
|
|
319
319
|
| `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:188-193`). |
|
|
320
320
|
| `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:194-200`). |
|
|
321
321
|
|
|
@@ -325,7 +325,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
325
325
|
| ------------------------------ | --------------------------------- | -------------------------------------- |
|
|
326
326
|
| Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts:8-13`) |
|
|
327
327
|
| Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts:85`) |
|
|
328
|
-
| Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:
|
|
328
|
+
| Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:96` |
|
|
329
329
|
| Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts:23`) |
|
|
330
330
|
| Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts:17-21`) |
|
|
331
331
|
| Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts:38-41`) |
|
|
@@ -339,14 +339,14 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
339
339
|
- **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
|
|
340
340
|
allouée « au cas où » (`firewall.ts:164`).
|
|
341
341
|
- Le coût HMAC (1 à l'émission, 1 à la vérif) n'est payé **que** sur les routes `@CsrfProtect` ; les
|
|
342
|
-
marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:
|
|
342
|
+
marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:205`).
|
|
343
343
|
|
|
344
344
|
## 📡 Observabilité — Studio
|
|
345
345
|
|
|
346
346
|
L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
|
|
347
347
|
`Firewall.tsx:313-314`). La projection est **sans secret par construction** :
|
|
348
348
|
`Firewall.#describeDefenses()` (`firewall.ts:591`) publie la config résolue, et `synchronizerToken`
|
|
349
|
-
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:
|
|
349
|
+
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:607`).
|
|
350
350
|
|
|
351
351
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
352
352
|
|
package/docs/external-jwt.md
CHANGED
|
@@ -44,16 +44,24 @@ d'accès » :
|
|
|
44
44
|
|
|
45
45
|
```ts
|
|
46
46
|
// nodefony.config.ts
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
import { defineConfig, use } from "nodefony";
|
|
48
|
+
|
|
49
|
+
export default defineConfig({
|
|
50
|
+
modules: [
|
|
51
|
+
"@nodefony/http",
|
|
52
|
+
"@nodefony/framework",
|
|
53
|
+
use("@nodefony/security", {
|
|
54
|
+
resourceServer: {
|
|
55
|
+
issuers: [
|
|
56
|
+
{
|
|
57
|
+
issuer: "https://auth.example.com/realms/mon-royaume",
|
|
58
|
+
algorithms: ["RS256"],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
53
61
|
},
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
});
|
|
57
65
|
```
|
|
58
66
|
|
|
59
67
|
Cela suffit : les clés publiques de l'émetteur sont découvertes par ses points de métadonnées
|
|
@@ -115,11 +123,12 @@ concernent d'après la liste d'émetteurs, puis délègue la vérification au se
|
|
|
115
123
|
La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.
|
|
116
124
|
|
|
117
125
|
```ts
|
|
118
|
-
|
|
126
|
+
areas: {
|
|
119
127
|
api: {
|
|
120
128
|
pattern: "^/api",
|
|
121
129
|
stateless: true,
|
|
122
130
|
authenticators: ["external-jwt"],
|
|
131
|
+
resource: "https://api.example.com",
|
|
123
132
|
},
|
|
124
133
|
}
|
|
125
134
|
```
|