@nodefony/security 10.0.0-alpha.7 → 10.0.0-alpha.9
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/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +4 -3
- package/dist/nodefony/service/auditService.js +3 -2
- package/dist/nodefony/service/tokenService.js +3 -2
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +3 -2
- package/dist/nodefony/service/webhooks.js +5 -3
- package/dist/nodefony/src/SecuredArea.js +7 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +23 -9
- package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/config/config.d.ts +2 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +15 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +6 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +22 -8
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
- package/docs/api-keys.md +62 -60
- package/docs/audit.md +77 -78
- package/docs/authenticators.md +55 -43
- package/docs/authorization.md +128 -23
- package/docs/cors.md +29 -29
- package/docs/csrf.md +26 -26
- package/docs/external-jwt.md +4 -4
- package/docs/firewall.md +80 -29
- package/docs/headers.md +26 -26
- package/docs/oauth2.md +69 -62
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +55 -55
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +81 -81
- package/docs/webhooks.md +43 -37
- package/package.json +16 -13
package/docs/audit.md
CHANGED
|
@@ -30,8 +30,8 @@ source: "src/packages/@nodefony/security/docs/audit.md"
|
|
|
30
30
|
> Chaque fois qu'une **décision de sécurité** est prise — un login réussit, un accès est refusé, une
|
|
31
31
|
> clé d'API est révoquée, un jeton volé resurgit — Nodefony écrit une ligne dans un journal
|
|
32
32
|
> **append-only** : qui, quoi, quand, d'où, avec quel verdict. Pas le trafic (ça, c'est le log HTTP) :
|
|
33
|
-
> les **transitions d'état**. Ancré sur `AuditService` (`auditService.ts:
|
|
34
|
-
> `IAuditEvent` (`IAuditEvent.ts:
|
|
33
|
+
> les **transitions d'état**. Ancré sur `AuditService` (`auditService.ts:51`), le contrat
|
|
34
|
+
> `IAuditEvent` (`IAuditEvent.ts:61`) et les stores de `nodefony/src/audit/`.
|
|
35
35
|
|
|
36
36
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Journal d'audit**
|
|
37
37
|
|
|
@@ -70,7 +70,7 @@ flowchart LR
|
|
|
70
70
|
API --> UI["Studio — Journal d'audit"]
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
`AuditService.record()` (`auditService.ts:
|
|
73
|
+
`AuditService.record()` (`auditService.ts:187`) est le point de passage unique : il pose l'identité et
|
|
74
74
|
l'horodatage, écrit **sans attendre**, et notifie les abonnés live. Tout le reste — stores, pont WS,
|
|
75
75
|
webhooks, console — se branche autour de lui.
|
|
76
76
|
|
|
@@ -123,7 +123,7 @@ requête authentifiée qui passe n'écrit rien. Prouvé par les tests « SUCCÈS
|
|
|
123
123
|
signal, pas du bruit à filtrer.
|
|
124
124
|
|
|
125
125
|
**2. L'audit ne peut jamais casser le métier.** `record()` est synchrone, sans `await`, et l'écriture
|
|
126
|
-
part en fire-and-forget avec un `.catch()` qui se contente de logger (`auditService.ts:
|
|
126
|
+
part en fire-and-forget avec un `.catch()` qui se contente de logger (`auditService.ts:199`). Un
|
|
127
127
|
store en panne dégrade la traçabilité, il ne renvoie pas 500 à l'utilisateur.
|
|
128
128
|
|
|
129
129
|
**3. Le store est pluggable, jamais câblé en dur.** Le service résout un **nom** via un registre
|
|
@@ -196,7 +196,7 @@ export default ExportController;
|
|
|
196
196
|
```
|
|
197
197
|
|
|
198
198
|
> [!IMPORTANT]
|
|
199
|
-
> `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:
|
|
199
|
+
> `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:24`) : il n'existe
|
|
200
200
|
> pas de catégorie « métier ». C'est volontaire — ce journal est celui de la sécurité. Range ton
|
|
201
201
|
> événement dans la catégorie de sécurité qu'il concerne (ici `authz` : un accès privilégié à des
|
|
202
202
|
> données) et laisse `action` porter le vocabulaire métier.
|
|
@@ -254,7 +254,7 @@ et ratées, sur une fenêtre précise.
|
|
|
254
254
|
|
|
255
255
|
Aucune configuration : les événements d'authentification sont émis par défaut. Il suffit de filtrer.
|
|
256
256
|
Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
|
|
257
|
-
(`SecurityAdminApi.ts:
|
|
257
|
+
(`SecurityAdminApi.ts:184`) :
|
|
258
258
|
|
|
259
259
|
| Paramètre | Effet | Exemple |
|
|
260
260
|
| ----------- | ----------------------------------------------- | --------------------------- |
|
|
@@ -312,12 +312,12 @@ use("@nodefony/security", {
|
|
|
312
312
|
```
|
|
313
313
|
|
|
314
314
|
Ce que ça déclenche : le service arme un `GcScheduler` de purge **toutes les heures**
|
|
315
|
-
(`auditService.ts:
|
|
315
|
+
(`auditService.ts:159`, intervalle `GC_INTERVAL_MS` — `auditService.ts:34`), avec gigue
|
|
316
316
|
anti-avalanche en cluster. Chaque
|
|
317
317
|
tour appelle la purge du contrat — `gc()`, voisin de `listPage()` dans le même contrat
|
|
318
|
-
(`IAuditStore.ts:
|
|
319
|
-
côté SQL (`DrizzleAuditStore.ts:
|
|
320
|
-
`threshold` côté mémoire (`MemoryAuditStore.ts:
|
|
318
|
+
(`IAuditStore.ts:59`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
|
|
319
|
+
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:138`).
|
|
321
321
|
|
|
322
322
|
Ce qu'on observe dans les logs : `audit gc — 1284 événement(s) purgé(s)` en niveau DEBUG.
|
|
323
323
|
|
|
@@ -331,8 +331,8 @@ qu'il arrive.
|
|
|
331
331
|
La base est indisponible pendant trente secondes. Que se passe-t-il ?
|
|
332
332
|
|
|
333
333
|
Le choix de Nodefony est explicite : **le métier passe avant la trace**. L'écriture part en
|
|
334
|
-
fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts:
|
|
335
|
-
pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:
|
|
334
|
+
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:146`). Un login n'échoue jamais
|
|
336
336
|
parce que le journal est cassé.
|
|
337
337
|
|
|
338
338
|
> [!WARNING]
|
|
@@ -342,22 +342,22 @@ parce que le journal est cassé.
|
|
|
342
342
|
> disponibilité — le contrat `IAuditStore` (`IAuditStore.ts:48`) est le point d'extension pour ça.
|
|
343
343
|
|
|
344
344
|
Deux garde-fous limitent la casse au démarrage : un store **explicitement** configuré mais inconnu
|
|
345
|
-
**avorte le boot en production** (`auditService.ts:
|
|
345
|
+
**avorte le boot en production** (`auditService.ts:115`), et un store `memory` en production déclenche
|
|
346
346
|
un `WARNING` nommant précisément le risque — volatil, per-pod, rétention réglementaire impossible
|
|
347
|
-
(`auditService.ts:
|
|
347
|
+
(`auditService.ts:128`).
|
|
348
348
|
|
|
349
349
|
## 🔐 Le catalogue des événements audités
|
|
350
350
|
|
|
351
|
-
Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts:
|
|
351
|
+
Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts:24`) : c'est
|
|
352
352
|
l'axe de filtrage principal de la console. L'**action** est une chaîne **ouverte**
|
|
353
|
-
(`IAuditEvent.ts:
|
|
353
|
+
(`IAuditEvent.ts:74`) : la liste grandit sans jamais casser le contrat.
|
|
354
354
|
|
|
355
355
|
### Vue d'ensemble — choisir son filtre en cinq secondes
|
|
356
356
|
|
|
357
357
|
<!-- prettier-ignore -->
|
|
358
358
|
| Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
|
|
359
359
|
| --- | --- | --- |
|
|
360
|
-
| `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` |
|
|
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
361
|
| `authz` | autorisation (voters, `@IsGranted`) | `access.denied` |
|
|
362
362
|
| `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
|
|
363
363
|
| `session` | cycle de vie de session | `logout` |
|
|
@@ -369,7 +369,7 @@ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouvert
|
|
|
369
369
|
| `cors` | politique CORS | _catégorie déclarée, aucune action émise aujourd'hui_ |
|
|
370
370
|
| `config` | mutation de config runtime depuis Studio | _catégorie déclarée, aucune action émise aujourd'hui_ |
|
|
371
371
|
|
|
372
|
-
Les trois issues possibles (`IAuditEvent.ts:
|
|
372
|
+
Les trois issues possibles (`IAuditEvent.ts:43`) ne sont pas interchangeables : `failure` = **l'acteur
|
|
373
373
|
a échoué une preuve** (mauvais mot de passe, signature invalide) ; `denied` = **une politique a
|
|
374
374
|
refusé** un acteur pourtant bien formé (Zero Trust, rôle manquant) ; `success` = l'action a abouti.
|
|
375
375
|
Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non autorisé.
|
|
@@ -377,19 +377,21 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
|
|
|
377
377
|
### `auth` — la chaîne d'authentification
|
|
378
378
|
|
|
379
379
|
Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
|
|
380
|
-
(`firewall.ts:
|
|
380
|
+
(`firewall.ts:900`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
|
|
381
381
|
|
|
382
|
-
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:
|
|
383
|
-
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:
|
|
382
|
+
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:781`) ;
|
|
383
|
+
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:807`) ;
|
|
384
|
+
- `auth.unverifiable` — le vérificateur de jetons est indisponible : réponse 503, sans défi
|
|
385
|
+
(`firewall.ts:795`) ;
|
|
384
386
|
- `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
|
|
385
|
-
(`firewall.ts:
|
|
387
|
+
(`firewall.ts:822`) ;
|
|
386
388
|
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:850`).
|
|
387
389
|
|
|
388
390
|
Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
|
|
389
391
|
`login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
|
|
390
392
|
`login.throttled` (`authFlow.ts:139`), `login.mfa_required` quand un second facteur est réclamé
|
|
391
393
|
(`authFlow.ts:175`), et `login.success` après la preuve complète (`authFlow.ts:191`,
|
|
392
|
-
`authFlow.ts:
|
|
394
|
+
`authFlow.ts:232`).
|
|
393
395
|
|
|
394
396
|
### `authz` — le refus d'autorisation
|
|
395
397
|
|
|
@@ -399,10 +401,10 @@ Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le
|
|
|
399
401
|
### `token` — la vie et la mort des jetons longue durée
|
|
400
402
|
|
|
401
403
|
- `token.issued` — un couple access/refresh vient d'être émis, donc une surface d'attaque vient
|
|
402
|
-
d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts:
|
|
404
|
+
d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts:535`) ;
|
|
403
405
|
- `token.reuse_detected` — **le signal d'attaque le plus fort du journal** : un refresh déjà révoqué
|
|
404
406
|
a été re-présenté, donc quelqu'un détient un jeton volé. Toute la famille est coupée
|
|
405
|
-
(`tokenService.ts:
|
|
407
|
+
(`tokenService.ts:585`, RFC 9700 §4.14) ;
|
|
406
408
|
- `apikey.created` / `apikey.revoked` — cycle de vie des clés d'API (`apiKeys.ts:153`,
|
|
407
409
|
`apiKeys.ts:212`).
|
|
408
410
|
|
|
@@ -427,18 +429,18 @@ depuis l'utilisateur de la requête — avec repli `"admin"`, jamais une décisi
|
|
|
427
429
|
|
|
428
430
|
## 🧰 Le contrat d'une entrée
|
|
429
431
|
|
|
430
|
-
Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts:
|
|
431
|
-
brouillon `IAuditEventDraft` (`IAuditEvent.ts:
|
|
432
|
+
Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts:61`). L'émetteur ne fournit qu'un
|
|
433
|
+
brouillon `IAuditEventDraft` (`IAuditEvent.ts:113`) : `id` et `ts` sont posés par le service, ce qui
|
|
432
434
|
garantit un seul appel d'horloge, centralisé hors des points d'émission.
|
|
433
435
|
|
|
434
436
|
| Champ | Type | Posé par | Rôle |
|
|
435
437
|
| ----------- | -------------------------- | -------- | ------------------------------------------------------------ |
|
|
436
438
|
| `id` | `string` | service | unique dans le process ; sert aussi de composante de curseur |
|
|
437
|
-
| `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts:
|
|
439
|
+
| `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts:195`) |
|
|
438
440
|
| `category` | `AuditCategory` | émetteur | sous-système — union fermée |
|
|
439
441
|
| `action` | `string` | émetteur | le fait, `<sujet>.<verbe>` — chaîne ouverte |
|
|
440
442
|
| `outcome` | `success\|failure\|denied` | émetteur | le verdict |
|
|
441
|
-
| `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts:
|
|
443
|
+
| `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts:82`) |
|
|
442
444
|
| `resource` | `string \| null` | émetteur | zone, route, canal, attribut — descripteur **léger** |
|
|
443
445
|
| `reason` | `string \| null` | émetteur | motif **machine** filtrable, pas un message traduit |
|
|
444
446
|
| `ip` | `string \| null` | contexte | provenance réseau |
|
|
@@ -458,19 +460,19 @@ Quatre mécanismes, tous prouvés par les tests.
|
|
|
458
460
|
|
|
459
461
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
460
462
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
461
|
-
(`firewall.ts:900`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:
|
|
462
|
-
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:
|
|
463
|
+
(`firewall.ts:900`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:352`).
|
|
464
|
+
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
|
|
463
465
|
|
|
464
466
|
**2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
|
|
465
|
-
avant tout appel d'horloge si le service est inactif (`auditService.ts:
|
|
467
|
+
avant tout appel d'horloge si le service est inactif (`auditService.ts:187`). Aucun objet créé,
|
|
466
468
|
aucun appel d'horloge. Prouvé par le banc « audit désactivé → `issueTokens` n'est pas journalisé »
|
|
467
469
|
(`auditEmissionHotPath.test.ts:499`).
|
|
468
470
|
|
|
469
471
|
**3. L'écriture ne bloque pas.** `append()` part sans `await`, avec un `.catch()` qui logge
|
|
470
|
-
(`auditService.ts:
|
|
472
|
+
(`auditService.ts:199`). La latence du store n'entre jamais dans la latence de la requête.
|
|
471
473
|
|
|
472
474
|
**4. Tout ce qui n'est pas utilisé n'est pas alloué.** La liste d'abonnés live reste `null` tant que
|
|
473
|
-
personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts:
|
|
475
|
+
personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts:228`). Le pont
|
|
474
476
|
WS n'existe qu'entre le premier et le dernier auditeur connecté, et son tampon circulaire n'est
|
|
475
477
|
alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est armé à la demande et
|
|
476
478
|
`unref` (`auditBridge.ts:93`).
|
|
@@ -478,13 +480,13 @@ alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est
|
|
|
478
480
|
Le pont applique en plus un **coalescing borné** : au plus une frame WS toutes les 250 ms
|
|
479
481
|
(`auditBridge.ts:59`), tampon plafonné à 200 événements (`auditBridge.ts:60`). Sous une rafale
|
|
480
482
|
d'échecs de login, le tampon écrase les plus anciens et compte les omis dans `dropped`
|
|
481
|
-
(`auditBridge.ts:
|
|
483
|
+
(`auditBridge.ts:79`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
|
|
482
484
|
jamais faire tomber ce qu'on supervise.
|
|
483
485
|
|
|
484
486
|
## ⚙️ Configuration
|
|
485
487
|
|
|
486
|
-
Table dérivée du schéma Zod `auditSchema` (`config.ts:
|
|
487
|
-
(`config.ts:
|
|
488
|
+
Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la racine sous la clé `audit`
|
|
489
|
+
(`config.ts:1158`).
|
|
488
490
|
|
|
489
491
|
| Option | Type | Défaut | Effet |
|
|
490
492
|
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
@@ -498,18 +500,18 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la
|
|
|
498
500
|
> `immutable` et `stream` sont déclarés dans le schéma mais **ne sont lus par aucun code** aujourd'hui.
|
|
499
501
|
> Les mettre à `false` ne change rien. L'immuabilité vient du **contrat** `IAuditStore`, qui n'expose
|
|
500
502
|
> ni `update` ni `delete` ciblé (`IAuditStore.ts:48`) ; la diffusion live est gouvernée par la
|
|
501
|
-
> **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts:
|
|
503
|
+
> **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts:55`).
|
|
502
504
|
|
|
503
505
|
### Comment `store: "auto"` décide
|
|
504
506
|
|
|
505
507
|
Le défaut ne suppose rien : il **suit l'infrastructure déclarée**, borné aux backends réellement
|
|
506
|
-
enregistrés (`auditService.ts:
|
|
508
|
+
enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:289`).
|
|
507
509
|
|
|
508
510
|
1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
|
|
509
511
|
2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
|
|
510
512
|
3. sinon, repli **annoncé** sur `memory` — la décision est loggée en INFO, jamais silencieuse.
|
|
511
513
|
|
|
512
|
-
La résolution finale est publiée au kernel (`auditService.ts:
|
|
514
|
+
La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
|
|
513
515
|
de Studio : configuré, résolu, disponible, motif, emplacement physique.
|
|
514
516
|
|
|
515
517
|
## 🏗️ Architecture interne
|
|
@@ -536,8 +538,8 @@ Quatre pièces, quatre responsabilités :
|
|
|
536
538
|
- **`recordAudit()`** (`recordAudit.ts:15`) — le point d'émission côté appelant. Une résolution par le
|
|
537
539
|
container, sur le cold-path uniquement. C'est ce qui rend l'audit **découplé** : module absent →
|
|
538
540
|
aucun effet, jamais d'exception qui remonterait dans le flux métier.
|
|
539
|
-
- **`AuditService`** (`auditService.ts:
|
|
540
|
-
au container sous le nom `auditStore` (`auditService.ts:
|
|
541
|
+
- **`AuditService`** (`auditService.ts:51`) — le propriétaire. Il construit le store au boot, le pose
|
|
542
|
+
au container sous le nom `auditStore` (`auditService.ts:144`), arme la purge, et implémente
|
|
541
543
|
`IAuditSink` (`IAuditStore.ts:77`).
|
|
542
544
|
- **`IAuditStore`** (`IAuditStore.ts:48`) — le contrat de persistance : `append`, `listPage`, `gc`.
|
|
543
545
|
**`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
|
|
@@ -569,27 +571,24 @@ ligne de garde `limit + 1` (`DrizzleAuditStore.ts:198`).
|
|
|
569
571
|
|
|
570
572
|
## Entité de persistance et dialectes pris en charge
|
|
571
573
|
|
|
572
|
-
**
|
|
574
|
+
**Les deux backends durables portent l'audit** : `@nodefony/drizzle` et `@nodefony/mongoose`. Le
|
|
575
|
+
décompte honnête :
|
|
573
576
|
|
|
574
|
-
| Backend | Store d'audit |
|
|
577
|
+
| Backend | Store d'audit | Moteurs prouvés |
|
|
575
578
|
| -------------------- | ------------- | ----------------------------------- |
|
|
576
579
|
| builtin `memory` | ✅ fourni | — (mémoire du process, per-pod) |
|
|
577
580
|
| `@nodefony/drizzle` | ✅ fourni | SQLite · PostgreSQL · MySQL/MariaDB |
|
|
578
|
-
| `@nodefony/mongoose` |
|
|
581
|
+
| `@nodefony/mongoose` | ✅ fourni | MongoDB |
|
|
579
582
|
| `@nodefony/redis` | ❌ absent | — |
|
|
580
583
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
que le journal d'audit **croît sans borne**, se conserve des mois pour la conformité, et se
|
|
585
|
-
**consulte** (recherche, filtres, pagination) : garder tout ça en mémoire vive coûte cher pour un
|
|
586
|
-
motif d'accès qui n'est pas le sien. Mongo, lui, est un chemin **durable** :
|
|
587
|
-
l'audit y **manque**, et c'est un manque à combler (objectif « full NoSQL », `MIGRATION_STATUS.md`
|
|
588
|
-
P7.11) — un utilisateur choisit sa base de données, pas de perdre sa traçabilité.
|
|
584
|
+
Une application peut donc tourner **entièrement sur MongoDB** sans perdre sa traçabilité : c'était
|
|
585
|
+
la condition pour que Mongo soit un chemin complet, et pas un chemin qui oblige à rapatrier une
|
|
586
|
+
base SQL pour une seule brique.
|
|
589
587
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
588
|
+
**Redis n'en aura pas, et la raison n'est pas « c'est un cache »** — il porte déjà des données
|
|
589
|
+
durables comme les passkeys, en opt-in assumé. C'est que le journal d'audit **croît sans borne**,
|
|
590
|
+
se conserve des mois pour la conformité, et se **consulte** (recherche, filtres, pagination) :
|
|
591
|
+
garder tout ça en mémoire vive coûte cher pour un motif d'accès qui n'est pas le sien.
|
|
593
592
|
|
|
594
593
|
### La table `audit_event`
|
|
595
594
|
|
|
@@ -631,9 +630,9 @@ La règle d'or est écrite en tête du contrat (`IAuditEvent.ts:8`) : **un secre
|
|
|
631
630
|
Ce que le code garantit, concrètement :
|
|
632
631
|
|
|
633
632
|
- **Le typage rend le secret difficile à faire entrer.** `actor` est documenté comme un _libellé
|
|
634
|
-
d'identité_ (`IAuditEvent.ts:
|
|
635
|
-
les en-têtes de la requête » (`IAuditEvent.ts:
|
|
636
|
-
- **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:
|
|
633
|
+
d'identité_ (`IAuditEvent.ts:82`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
|
|
634
|
+
les en-têtes de la requête » (`IAuditEvent.ts:87`).
|
|
635
|
+
- **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:49`) ne porte que deux
|
|
637
636
|
booléens : un en-tête `Authorization` était-il là, un cookie était-il là. `readAuditContext()`
|
|
638
637
|
les calcule par un simple `Boolean(headers[…])` (`readAuditContext.ts:40`) — la valeur n'est jamais
|
|
639
638
|
copiée.
|
|
@@ -642,7 +641,7 @@ Ce que le code garantit, concrètement :
|
|
|
642
641
|
complète de l'événement **ne contient pas** le secret créé
|
|
643
642
|
(`auditEmissionHotPath.test.ts:545`).
|
|
644
643
|
- **Le motif reste machine.** `reason` est une valeur stable et filtrable, pas un message d'erreur
|
|
645
|
-
libre (`IAuditEvent.ts:
|
|
644
|
+
libre (`IAuditEvent.ts:92`) : c'est ce qui empêche une cause fine de fuir dans la trace… et qui
|
|
646
645
|
permet de la garder **côté audit** alors que le client, lui, reçoit un message d'échec uniforme
|
|
647
646
|
(anti-énumération).
|
|
648
647
|
|
|
@@ -661,11 +660,11 @@ Deux autres propriétés de sécurité valent d'être connues :
|
|
|
661
660
|
| ----------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
|
|
662
661
|
| Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts:693`) |
|
|
663
662
|
| Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts:130`) |
|
|
664
|
-
| Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:
|
|
665
|
-
| Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:
|
|
663
|
+
| Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:49`) |
|
|
664
|
+
| Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:61`) |
|
|
666
665
|
| Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts:48`) |
|
|
667
|
-
| Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:
|
|
668
|
-
| Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:
|
|
666
|
+
| Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:912`) |
|
|
667
|
+
| Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:585`) |
|
|
669
668
|
| Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts:773`) |
|
|
670
669
|
|
|
671
670
|
## 📡 Observabilité — Studio
|
|
@@ -675,7 +674,7 @@ L'écran **Sécurité → Journal d'audit** (`/nodefony/audit`) (`Audit.tsx:62`)
|
|
|
675
674
|
|
|
676
675
|
- un tableau filtrable par heure, catégorie, action, issue, acteur, raison et IP (`Audit.tsx:227`) ;
|
|
677
676
|
- un indicateur du **store réellement résolu** pour la brique `audit`, alimenté par la publication de
|
|
678
|
-
résolution du service (`auditService.ts:
|
|
677
|
+
résolution du service (`auditService.ts:145`) ;
|
|
679
678
|
- un interrupteur **Temps réel** qui s'abonne au canal `nodefony:audit` (`AuditLive.tsx:20`) — pensé
|
|
680
679
|
pour les pics d'activité : un journal d'audit se consulte, il ne se regarde pas défiler ;
|
|
681
680
|
- un renvoi vers la **trace de requête** quand l'événement porte un `requestId`, ce qui recolle
|
|
@@ -693,19 +692,19 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
|
|
|
693
692
|
|
|
694
693
|
## ⚠️ Pièges
|
|
695
694
|
|
|
696
|
-
| Symptôme | Cause (dans le code)
|
|
697
|
-
| --------------------------------------------------- |
|
|
698
|
-
| Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`)
|
|
699
|
-
| Un pod voit des événements, l'autre non | Store `memory` non partagé
|
|
700
|
-
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:
|
|
701
|
-
| `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`)
|
|
702
|
-
| La page 2 répète ou saute des événements | Pagination réimplémentée en offset
|
|
703
|
-
| Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`)
|
|
704
|
-
| Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`)
|
|
705
|
-
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:
|
|
706
|
-
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:
|
|
707
|
-
| Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF
|
|
708
|
-
| Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts:320`)
|
|
695
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
696
|
+
| --------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
697
|
+
| 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
|
+
| Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
|
|
699
|
+
| 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 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
|
|
701
|
+
| 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
|
+
| Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
|
|
703
|
+
| Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`) | Filtrer par `category`/`actor`/`action`/`requestId` |
|
|
704
|
+
| `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
|
+
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
|
|
706
|
+
| 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 |
|
|
707
|
+
| Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts:320`) | Réactiver l'audit en configuration |
|
|
709
708
|
|
|
710
709
|
## 🧪 Tests & couverture
|
|
711
710
|
|
package/docs/authenticators.md
CHANGED
|
@@ -31,7 +31,7 @@ source: "src/packages/@nodefony/security/docs/authenticators.md"
|
|
|
31
31
|
> Un **authenticator** répond à une seule question : _« qui es-tu, et peux-tu le prouver ? »_. Il ne
|
|
32
32
|
> décide **pas** des droits (ça, c'est l'autorisation / les voters) — il établit une **identité**.
|
|
33
33
|
> Le firewall enchaîne les authenticators déclarés par une zone jusqu'à obtenir une preuve valide,
|
|
34
|
-
> sinon il ferme en 401 (Zero Trust). Nodefony en fournit **
|
|
34
|
+
> sinon il ferme en 401 (Zero Trust). Nodefony en fournit **sept** intégrés, tous ancrés ici sur le
|
|
35
35
|
> code (`src/packages/@nodefony/security/nodefony/src/authenticator/`).
|
|
36
36
|
|
|
37
37
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Authenticators**
|
|
@@ -90,7 +90,7 @@ classe d'attaque précise — détaillées brique par brique dans le catalogue :
|
|
|
90
90
|
|
|
91
91
|
### Le contrat `IAuthenticator`
|
|
92
92
|
|
|
93
|
-
Tout authenticator implémente le même cycle (`IAuthenticator.ts:
|
|
93
|
+
Tout authenticator implémente le même cycle (`IAuthenticator.ts:23`), ce qui rend le firewall
|
|
94
94
|
totalement agnostique de la stratégie :
|
|
95
95
|
|
|
96
96
|
| Méthode | Rôle |
|
|
@@ -100,7 +100,7 @@ totalement agnostique de la stratégie :
|
|
|
100
100
|
| `authenticate(token)` | **Vérifie** (signature/hash/session), applique la **révocation**, **re-résout le sujet** — ou lève un 401. |
|
|
101
101
|
| `onSuccess(ctx,tok)` | Effet de bord au succès (poser l'identité en session, audit). |
|
|
102
102
|
| `onFailure(ctx,err)` | Slot d'audit (le 401 + challenge sont posés par le firewall). |
|
|
103
|
-
| `challenge()` | **Optionnel** (`IAuthenticator.ts:
|
|
103
|
+
| `challenge()` | **Optionnel** (`IAuthenticator.ts:55`) — la valeur `WWW-Authenticate` (RFC 7235) des 401 de la zone. |
|
|
104
104
|
|
|
105
105
|
### Le registre pluggable
|
|
106
106
|
|
|
@@ -108,11 +108,11 @@ Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticat
|
|
|
108
108
|
(`firewall.ts:429`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
|
|
109
109
|
un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
|
|
110
110
|
|
|
111
|
-
- Les **
|
|
111
|
+
- Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
|
|
112
112
|
s'enregistrent à l'import du module via `registerAuthenticatorFactory()`
|
|
113
|
-
(`authenticatorRegistry.ts:
|
|
114
|
-
- Le
|
|
115
|
-
lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:
|
|
113
|
+
(`authenticatorRegistry.ts:73-145`) — donc toujours avant le boot.
|
|
114
|
+
- Le septième, `firewall-realtime`, n'est **pas dans le registre** : c'est le firewall qui le câble
|
|
115
|
+
lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:279`).
|
|
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
|
|
@@ -205,16 +205,17 @@ Requête par requête, qui répond :
|
|
|
205
205
|
| `Authorization: Bearer nf_…` | `apikey` | identifié — clé vérifiée au store, révocable |
|
|
206
206
|
| rien | aucun | **401** + `WWW-Authenticate: Bearer` (`firewall.ts:1200`) |
|
|
207
207
|
|
|
208
|
-
## 🔐 Les
|
|
208
|
+
## 🔐 Les authenticators intégrés
|
|
209
209
|
|
|
210
|
-
| Nom | Credential
|
|
211
|
-
| ------------------- |
|
|
212
|
-
| `anonymous` | (aucun)
|
|
213
|
-
| `userpassword` | `Authorization: Basic base64(id:mdp)`
|
|
214
|
-
| `session` | cookie de session (identifiant en blob)
|
|
215
|
-
| `jwt` | `Authorization: Bearer <a.b.c>`
|
|
216
|
-
| `
|
|
217
|
-
| `
|
|
210
|
+
| Nom | Credential | Vérité | Révocable | Pour… |
|
|
211
|
+
| ------------------- | --------------------------------------------------- | ---------- | :------------: | ------------------------------------------ |
|
|
212
|
+
| `anonymous` | (aucun) | — | — | accepter l'anonymat explicitement |
|
|
213
|
+
| `userpassword` | `Authorization: Basic base64(id:mdp)` | verifier | n/a | outils/scripts, brique login |
|
|
214
|
+
| `session` | cookie de session (identifiant en blob) | serveur | immédiate | le **web** après login (BFF) |
|
|
215
|
+
| `jwt` | `Authorization: Bearer <a.b.c>` | auto-porté | via état | API service↔service, agents |
|
|
216
|
+
| `external-jwt` | `Authorization: Bearer <a.b.c>` d'un émetteur TIERS | émetteur | via l'émetteur | jetons d'un serveur d'autorisation externe |
|
|
217
|
+
| `apikey` | `Authorization: Bearer nf_…` | serveur | immédiate | API/CI/scripts d'un user |
|
|
218
|
+
| `firewall-realtime` | identité déjà résolue au handshake (ALS) | serveur | 1 fenêtre | le **WebSocket** de toute identité |
|
|
218
219
|
|
|
219
220
|
### `anonymous` — accepter explicitement l'anonymat
|
|
220
221
|
|
|
@@ -235,7 +236,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
|
|
|
235
236
|
|
|
236
237
|
Schéma **HTTP Basic** (RFC 7617) : `Authorization: Basic base64(id:mdp)`, charset UTF-8, scheme
|
|
237
238
|
case-insensitive (`UserPasswordAuthenticator.ts:11`) ; `createToken()` split au **premier** `:` —
|
|
238
|
-
le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:
|
|
239
|
+
le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:68`).
|
|
239
240
|
|
|
240
241
|
- **La vérification est déléguée** au `IPasswordVerifier` (le `UserService`) : hash, comparaison,
|
|
241
242
|
leurre anti-timing, re-hash transparent — l'authenticator ne voit que le verdict.
|
|
@@ -245,7 +246,7 @@ le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:74`).
|
|
|
245
246
|
(`UserPasswordAuthenticator.ts:101-103`) — un identifiant bloqué ne coûte **aucun hash** → le
|
|
246
247
|
throttle protège aussi le serveur du **DoS argon2**. Échec compté, succès remis à zéro
|
|
247
248
|
(`UserPasswordAuthenticator.ts:111-114`). `ThrottledError` → **429 + `Retry-After`**
|
|
248
|
-
(`firewall.ts:
|
|
249
|
+
(`firewall.ts:780`).
|
|
249
250
|
- **Le throttler est PARTAGÉ** avec le login JSON du BFF — même `loginThrottler` du container : un
|
|
250
251
|
attaquant ne contourne pas le backoff en changeant de porte (`authenticatorRegistry.ts:75-79`).
|
|
251
252
|
- Challenge : `Basic realm="nodefony", charset="UTF-8"` (`UserPasswordAuthenticator.ts:130`).
|
|
@@ -258,7 +259,7 @@ Après le login, chaque requête web prouve son identité par la **session serve
|
|
|
258
259
|
Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
|
|
259
260
|
|
|
260
261
|
- **N'ouvre jamais la session lui-même** : `supports()` exige une session **déjà reprise** porteuse
|
|
261
|
-
d'un utilisateur (`SessionAuthenticator.ts:
|
|
262
|
+
d'un utilisateur (`SessionAuthenticator.ts:75-78`) — le pipeline http démarre la session _avant_
|
|
262
263
|
le firewall ; c'est `AuthFlow.login()` qui ouvre et régénère l'ID (anti-fixation).
|
|
263
264
|
- **L'identité est re-résolue à CHAQUE requête** via `resolveSessionIdentity`
|
|
264
265
|
(`SessionAuthenticator.ts:91`) → rôles frais, révocation immédiate. Les contrôles d'état sont
|
|
@@ -272,21 +273,32 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
|
|
|
272
273
|
|
|
273
274
|
Réservé aux **API service↔service / agents** (le web reste sur la session). Vérifie un access token
|
|
274
275
|
**EdDSA** signé par le keystore du serveur ; `supports()` ne réclame que la structure compacte
|
|
275
|
-
`a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:
|
|
276
|
+
`a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:21`). Les défenses **dures** du JWT BCP, toutes
|
|
276
277
|
prouvées en test :
|
|
277
278
|
|
|
278
279
|
| Défense | Comment | Attaque fermée |
|
|
279
280
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
|
280
281
|
| **Allowlist d'algorithmes** | `algorithms: ["EdDSA"]` — jamais l'algo de l'en-tête du token (`JwtAuthenticator.ts:120`) | `alg=none`, algorithm confusion (§3.1) |
|
|
281
|
-
| **Clé par `kid` du keyset LOCAL** | `createLocalJWKSet` — jamais `jku`/`jwk` de l'en-tête (`JwtAuthenticator.ts:
|
|
282
|
-
| **`aud` + `iss` + `typ` obligatoires** | `typ: "at+jwt"` (§3.11) sépare access et refresh (`JwtAuthenticator.ts:
|
|
283
|
-
| **Révocation** | denylist `isJtiDenied` + seuil `invalidBefore` par porteur (`JwtAuthenticator.ts:
|
|
284
|
-
| **Sujet revérifié** | `loadUserByIdentifier(sub)` → disparu/inactif/verrouillé = rejet (`JwtAuthenticator.ts:
|
|
282
|
+
| **Clé par `kid` du keyset LOCAL** | `createLocalJWKSet` — jamais `jku`/`jwk` de l'en-tête (`JwtAuthenticator.ts:174`) | injection de clé / SSRF (§3.5) |
|
|
283
|
+
| **`aud` + `iss` + `typ` obligatoires** | `typ: "at+jwt"` (§3.11) sépare access et refresh (`JwtAuthenticator.ts:119-124`) | refresh présenté comme access, token d'un autre service (§3.8-3.9) |
|
|
284
|
+
| **Révocation** | denylist `isJtiDenied` + seuil `invalidBefore` par porteur (`JwtAuthenticator.ts:138-148`) | jeton auto-porté volé, logout global |
|
|
285
|
+
| **Sujet revérifié** | `loadUserByIdentifier(sub)` → disparu/inactif/verrouillé = rejet (`JwtAuthenticator.ts:190-203`) | compte banni encore « valide » via son token (§3.10) |
|
|
285
286
|
|
|
286
|
-
Le **message d'échec est uniforme** (`INVALID_TOKEN`, `JwtAuthenticator.ts:
|
|
287
|
+
Le **message d'échec est uniforme** (`INVALID_TOKEN`, `JwtAuthenticator.ts:25`) : la cause fine
|
|
287
288
|
(expiré, `aud`, signature, sujet banni) part en **audit**, jamais au client — anti-oracle. Le token
|
|
288
|
-
promu porte `scopes`, `jti`, `claims` en attributs (`JwtAuthenticator.ts:
|
|
289
|
-
importé **lazy** — dépendance lourde (`JwtAuthenticator.ts:
|
|
289
|
+
promu porte `scopes`, `jti`, `claims` en attributs (`JwtAuthenticator.ts:178-188`). `jose` est
|
|
290
|
+
importé **lazy** — dépendance lourde (`JwtAuthenticator.ts:112`).
|
|
291
|
+
|
|
292
|
+
Les six premiers se déclarent dans une zone (registre `authenticatorRegistry.ts:73`) ;
|
|
293
|
+
`firewall-realtime` ne se déclare pas — le firewall le câble lui-même au handshake WebSocket.
|
|
294
|
+
|
|
295
|
+
### `external-jwt` — un jeton émis par un serveur d'autorisation TIERS
|
|
296
|
+
|
|
297
|
+
Là où `jwt` ne vérifie que les jetons signés par Nodefony, `external-jwt` accepte un jeton d'un
|
|
298
|
+
émetteur de confiance déclaré (`resourceServer.issuers`), vérifié contre ses clés publiques, et
|
|
299
|
+
rattache son sujet à un compte local. `jwt` et `external-jwt` s'aiguillent par l'émetteur (`iss`) :
|
|
300
|
+
leur ordre dans la zone ne décide de rien. Tout le détail — audience, espace de noms du sujet, panne
|
|
301
|
+
de l'émetteur en 503 — est dans **[Jetons d'un émetteur tiers](external-jwt.md)**.
|
|
290
302
|
|
|
291
303
|
### `apikey` — PAT opaque révocable
|
|
292
304
|
|
|
@@ -294,17 +306,17 @@ Clé API personnelle en `Authorization: Bearer nf_…` (préfixe `apiKeys.prefix
|
|
|
294
306
|
Contrairement au JWT (auto-porté), un PAT est un **bearer opaque** dont la vérité vit **côté
|
|
295
307
|
serveur** (`ITokenStore`) → **révocable immédiatement**. Défenses :
|
|
296
308
|
|
|
297
|
-
- **Forme + CRC validés AVANT tout accès au store** (`parseApiKey`, `ApiKeyAuthenticator.ts:
|
|
309
|
+
- **Forme + CRC validés AVANT tout accès au store** (`parseApiKey`, `ApiKeyAuthenticator.ts:103-106`)
|
|
298
310
|
→ une valeur malformée n'atteint jamais la base (**anti-DoS**).
|
|
299
|
-
- **Lookup par hash SHA-256** (`findByHash`, `ApiKeyAuthenticator.ts:
|
|
311
|
+
- **Lookup par hash SHA-256** (`findByHash`, `ApiKeyAuthenticator.ts:109`) — le secret n'existe
|
|
300
312
|
**nulle part au repos**.
|
|
301
|
-
- **Révocation** (`revokedAt`) + **expiration** (`expiresAt`) (`ApiKeyAuthenticator.ts:
|
|
302
|
-
**ban en masse** du porteur (`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:
|
|
303
|
-
- **Sujet revérifié** à chaque requête → rôles frais (`ApiKeyAuthenticator.ts:
|
|
313
|
+
- **Révocation** (`revokedAt`) + **expiration** (`expiresAt`) (`ApiKeyAuthenticator.ts:113-116`) +
|
|
314
|
+
**ban en masse** du porteur (`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:121-122`).
|
|
315
|
+
- **Sujet revérifié** à chaque requête → rôles frais (`ApiKeyAuthenticator.ts:127`).
|
|
304
316
|
- **`lastUsedAt` throttlé** : aucune écriture sur le hot path tant que la fenêtre
|
|
305
|
-
`apiKeys.lastUsedThrottleS` n'est pas dépassée (`ApiKeyAuthenticator.ts:
|
|
317
|
+
`apiKeys.lastUsedThrottleS` n'est pas dépassée (`ApiKeyAuthenticator.ts:136-143`).
|
|
306
318
|
|
|
307
|
-
Le token promu porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:
|
|
319
|
+
Le token promu porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:146-149`).
|
|
308
320
|
|
|
309
321
|
### `firewall-realtime` — la promotion, en WebSocket, de l'identité déjà posée
|
|
310
322
|
|
|
@@ -323,7 +335,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
323
335
|
- **Perf : il ne relit pas la base.** `authenticate()` réutilise l'identité de l'ALS
|
|
324
336
|
(`FirewallRealtimeAuthenticator.ts:91`) au lieu de refaire deux lectures base par connexion —
|
|
325
337
|
un coût évitable sur le différenciateur temps réel.
|
|
326
|
-
- **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:
|
|
338
|
+
- **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:279`) pour toute zone
|
|
327
339
|
protégée `realtime !== false` — une instance par zone au handshake (`firewall.ts:289`).
|
|
328
340
|
- **Le mode de preuve suit le jeton du firewall**, il n'est pas deviné : absent (zone historique),
|
|
329
341
|
on retombe sur le mode le plus strict, la session (`FirewallRealtimeAuthenticator.ts:101-103`).
|
|
@@ -333,7 +345,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
333
345
|
`buildSessionRevalidator()` re-lit `storage.read(id)` et compare l'identifiant ; toute erreur de
|
|
334
346
|
lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts:227`). En mode jeton porteur, la
|
|
335
347
|
preuve est autre : `exp`, `jti` denylisté, `invalidBefore`
|
|
336
|
-
(`FirewallRealtimeAuthenticator.ts:
|
|
348
|
+
(`FirewallRealtimeAuthenticator.ts:179`).
|
|
337
349
|
|
|
338
350
|
> [!NOTE]
|
|
339
351
|
> **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
|
|
@@ -344,7 +356,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
344
356
|
## ⚙️ Composer une zone — ordre, mode, cohabitation
|
|
345
357
|
|
|
346
358
|
La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
|
|
347
|
-
(`firewall.ts:
|
|
359
|
+
(`firewall.ts:1128`) selon le `mode` de la zone (`first` par défaut, `config.ts:87-92`).
|
|
348
360
|
|
|
349
361
|
### Situation 1 — humains ET machines sur la même API (`first`)
|
|
350
362
|
|
|
@@ -377,14 +389,14 @@ authenticators: ["anonymous", "session"], // ❌ anonymous accepte TOUT le mon
|
|
|
377
389
|
### Situation 3 — empiler les preuves (`all`)
|
|
378
390
|
|
|
379
391
|
En mode `all`, **chaque** maillon est obligatoire : `supports()` faux = 401 immédiat
|
|
380
|
-
(`firewall.ts:
|
|
392
|
+
(`firewall.ts:1144-1149`) et le **dernier** token de la chaîne porte l'identité (`firewall.ts:1198-1199`) —
|
|
381
393
|
utile pour exiger une preuve de canal (mTLS) **et** une identité, ou un « sudo mode » session +
|
|
382
394
|
re-saisie du mot de passe. Scénario complet côté zones : [firewall](./firewall.md).
|
|
383
395
|
|
|
384
396
|
### Cohabitation JWT + clé API dans une même zone
|
|
385
397
|
|
|
386
398
|
Les deux sont des `Bearer`, mais Nodefony les **discrimine sur la forme** — un JWT a la structure
|
|
387
|
-
compacte `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:
|
|
399
|
+
compacte `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:21`), un PAT porte le préfixe `nf_` sans
|
|
388
400
|
point (`looksLikeApiKey`, `ApiKeyAuthenticator.ts:72`). Chaque `supports()` ne réclame que _son_
|
|
389
401
|
format → aucun conflit, aucune double vérification.
|
|
390
402
|
|
|
@@ -428,8 +440,8 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
428
440
|
| JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
|
|
429
441
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
|
|
430
442
|
| Backoff de login | NIST SP 800-63B | `#throttler.check()` avant le verifier (`UserPasswordAuthenticator.ts:101-103`) |
|
|
431
|
-
| Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:
|
|
432
|
-
| Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:
|
|
443
|
+
| Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:780`) |
|
|
444
|
+
| Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:25`) · `INVALID_CREDENTIALS` (`UserPasswordAuthenticator.ts:16`) |
|
|
433
445
|
|
|
434
446
|
## ⚡ Performance & mémoire
|
|
435
447
|
|
|
@@ -437,7 +449,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
437
449
|
(le chemin chaud/froid vit dans le [firewall](./firewall.md)).
|
|
438
450
|
- Résolutions **lazy** : le verifier `#resolveVerifier` est mémoïsé au premier login
|
|
439
451
|
(`UserPasswordAuthenticator.ts:105`) ; keystore, `tokenStore` et `users` sont résolus du
|
|
440
|
-
container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:
|
|
452
|
+
container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:112`).
|
|
441
453
|
- `anonymous` : singleton gelé `anonymousUser`, zéro allocation d'utilisateur
|
|
442
454
|
(`AnonymousToken.ts:9`).
|
|
443
455
|
- `apikey` : écriture `lastUsedAt` **coalescée** — pas une écriture par requête
|
|
@@ -456,7 +468,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
456
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) |
|
|
457
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` |
|
|
458
470
|
| Révocation WS pas immédiate | Identité figée au handshake — asymétrie assumée du `FirewallRealtimeAuthenticator` (`FirewallRealtimeAuthenticator.ts:51-55`) | Attendre la fenêtre de re-validation, ou utiliser JWT + canal révocation |
|
|
459
|
-
| Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:
|
|
471
|
+
| Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:613`) | Configurer `rateLimit` pour poser le throttler |
|
|
460
472
|
|
|
461
473
|
## 🧪 Tests & couverture
|
|
462
474
|
|