@nodefony/security 10.0.0-alpha.8 → 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 +2 -2
- 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/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/contracts/IAuditEvent.d.ts +3 -3
- 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 -77
- package/docs/authenticators.md +55 -43
- package/docs/authorization.md +13 -13
- package/docs/cors.md +29 -29
- package/docs/csrf.md +26 -26
- package/docs/external-jwt.md +4 -4
- package/docs/firewall.md +29 -29
- package/docs/headers.md +26 -26
- package/docs/oauth2.md +60 -60
- 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 +15 -12
package/docs/tokens.md
CHANGED
|
@@ -87,11 +87,11 @@ défaut » en prod.
|
|
|
87
87
|
## La vision Nodefony — un service propriétaire, des endpoints minces
|
|
88
88
|
|
|
89
89
|
`TokenService` est **propriétaire** du store et du keystore : à `TokenService.#build()`
|
|
90
|
-
(`tokenService.ts:
|
|
91
|
-
`tokenStore` au container (`tokenService.ts:
|
|
92
|
-
(`tokenService.ts:
|
|
90
|
+
(`tokenService.ts:101`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
|
|
91
|
+
`tokenStore` au container (`tokenService.ts:175`) puis crée le keystore et pose `jwtKeystore`
|
|
92
|
+
(`tokenService.ts:181-186`) — consommés par le `JwtAuthenticator` et les endpoints. Il arme un
|
|
93
93
|
**gc** via `GcScheduler` (timer `unref` + **jitter** de phase pour étaler les balayages entre pods,
|
|
94
|
-
`tokenService.ts:
|
|
94
|
+
`tokenService.ts:188-194`).
|
|
95
95
|
|
|
96
96
|
Les endpoints HTTP sont des **adaptateurs minces** portés par `@nodefony/framework`, couplés **par
|
|
97
97
|
nom de service** via le contrat structurel `ITokenIssuer` — framework n'importe jamais security
|
|
@@ -136,7 +136,7 @@ seulement ses signatures vérifiables. Omettre `grant_types_supported` annoncera
|
|
|
136
136
|
### Les endpoints d'émission sont FOURNIS
|
|
137
137
|
|
|
138
138
|
Dans une app `nodefony create app`, dès que le module security est chargé avec `jwt.enabled` (défaut),
|
|
139
|
-
le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts:
|
|
139
|
+
le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts:161-176`) :
|
|
140
140
|
|
|
141
141
|
- `POST /nodefony/security/api/token` — body `{username, password, scope?}` → couple access/refresh ;
|
|
142
142
|
- `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
|
|
@@ -228,36 +228,36 @@ Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController
|
|
|
228
228
|
|
|
229
229
|
### Émission (grant M2M/CLI)
|
|
230
230
|
|
|
231
|
-
`issueForCredentials()` (`tokenService.ts:
|
|
231
|
+
`issueForCredentials()` (`tokenService.ts:324`) vérifie l'identifiant/mot de passe via le
|
|
232
232
|
service `users`, avec le **throttling NIST partagé** — `ThrottledError` avant tout hachage
|
|
233
|
-
(`tokenService.ts:
|
|
234
|
-
par `#auditGrant()` (`tokenService.ts:
|
|
233
|
+
(`tokenService.ts:353`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
|
|
234
|
+
par `#auditGrant()` (`tokenService.ts:369-381`). Puis `issueTokens()` (`tokenService.ts:497`)
|
|
235
235
|
produit :
|
|
236
236
|
|
|
237
237
|
- un **access token** : JWT signé EdDSA, en-tête `typ:"at+jwt"` + `kid`, claims
|
|
238
|
-
`iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts:
|
|
238
|
+
`iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts:649-662`) ;
|
|
239
239
|
- un **refresh token** : secret opaque haute entropie `nfr_<32 octets base64url>`, **stocké haché**
|
|
240
240
|
`sha256` (le clair n'existe qu'en réponse, jamais au repos) — `#buildRefresh()`
|
|
241
|
-
(`tokenService.ts:
|
|
241
|
+
(`tokenService.ts:681-716`).
|
|
242
242
|
|
|
243
|
-
La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:
|
|
243
|
+
La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:49-57`). Tout succès est audité
|
|
244
244
|
`token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts:312-318`).
|
|
245
245
|
|
|
246
246
|
### Rotation & détection de rejeu (RFC 9700 §4.14)
|
|
247
247
|
|
|
248
|
-
`refresh()` (`tokenService.ts:
|
|
248
|
+
`refresh()` (`tokenService.ts:562`) est le cœur défensif, dans l'ordre :
|
|
249
249
|
|
|
250
|
-
1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:
|
|
250
|
+
1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:571`).
|
|
251
251
|
2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
|
|
252
|
-
famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:
|
|
253
|
-
3. Expiration `expiresAt` vérifiée (`tokenService.ts:
|
|
252
|
+
famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:577-591`).
|
|
253
|
+
3. Expiration `expiresAt` vérifiée (`tokenService.ts:594`).
|
|
254
254
|
4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
|
|
255
|
-
`#resolveUserForRefresh()` (`tokenService.ts:
|
|
255
|
+
`#resolveUserForRefresh()` (`tokenService.ts:752`).
|
|
256
256
|
5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
|
|
257
|
-
(`tokenService.ts:
|
|
257
|
+
(`tokenService.ts:600`).
|
|
258
258
|
6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
|
|
259
|
-
`"rotated"` (`tokenService.ts:
|
|
260
|
-
et le refresh courant reste valide (`tokenService.ts:
|
|
259
|
+
`"rotated"` (`tokenService.ts:634-636`). Si `rotateRefresh` est désactivé, l'access est réémis
|
|
260
|
+
et le refresh courant reste valide (`tokenService.ts:620`).
|
|
261
261
|
|
|
262
262
|
### Mise en situation — ton refresh token a été volé
|
|
263
263
|
|
|
@@ -336,35 +336,35 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
|
|
|
336
336
|
|
|
337
337
|
- Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts:63-68`).
|
|
338
338
|
- `listPage` : tri `createdAt` DESC + tiebreaker `id`, déterministe pour l'offset — parité SQL
|
|
339
|
-
(`MemoryTokenStore.ts:
|
|
339
|
+
(`MemoryTokenStore.ts:165-185`).
|
|
340
340
|
- Denylist bornée : purge **amortie** tous les 256 ajouts — `#maybeSweep()`
|
|
341
|
-
(`MemoryTokenStore.ts:
|
|
341
|
+
(`MemoryTokenStore.ts:393`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
|
|
342
342
|
- `snapshot()`/`restore()` sérialisables — base d'une persistance fichier, index reconstruits
|
|
343
|
-
(`MemoryTokenStore.ts:
|
|
343
|
+
(`MemoryTokenStore.ts:324-354`).
|
|
344
344
|
- Volatil, par-process : dev/tests. Pilote le banc de contrat commun.
|
|
345
345
|
|
|
346
346
|
### `drizzle` — SQL, le défaut durable
|
|
347
347
|
|
|
348
|
-
- Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:
|
|
348
|
+
- Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:261`).
|
|
349
349
|
- Élu par `store:"auto"` dès qu'une infra database SQL est déclarée ; sinon sqlite local si drizzle
|
|
350
|
-
est chargé (`config.ts:
|
|
350
|
+
est chargé (`config.ts:428-433`).
|
|
351
351
|
- Pagination **offset + total** (helper `paginate()` d'orm-core) ; e2e sur PostgreSQL et MySQL réels.
|
|
352
352
|
|
|
353
353
|
### `mongoose` — MongoDB
|
|
354
354
|
|
|
355
|
-
- Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:
|
|
356
|
-
- Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:
|
|
357
|
-
- Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:
|
|
355
|
+
- Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:170`).
|
|
356
|
+
- Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:236-246`).
|
|
357
|
+
- Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:170`).
|
|
358
358
|
|
|
359
359
|
### `redis` — cluster, TTL natif
|
|
360
360
|
|
|
361
|
-
- Enregistré par le module redis (`redis/nodefony/registerStores.ts:
|
|
362
|
-
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:
|
|
361
|
+
- Enregistré par le module redis (`redis/nodefony/registerStores.ts:75`).
|
|
362
|
+
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:308`) — l'expiration ne
|
|
363
363
|
dépend pas du gc.
|
|
364
364
|
- Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
|
|
365
|
-
(`RedisTokenStore.ts:
|
|
365
|
+
(`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
|
|
366
366
|
- `countTokens()` renvoie `-1` : un comptage exact exigerait un SCAN complet O(N), refusé
|
|
367
|
-
(`RedisTokenStore.ts:
|
|
367
|
+
(`RedisTokenStore.ts:483`).
|
|
368
368
|
|
|
369
369
|
### Le record — une seule table pour PAT et refresh
|
|
370
370
|
|
|
@@ -398,72 +398,72 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
|
|
|
398
398
|
- **Un refresh/PAT** : `revoke()` idempotent, `revokeFamily()` pour la chaîne de rotation
|
|
399
399
|
(`ITokenStore.ts:242`).
|
|
400
400
|
- **Tout un porteur** (logout global, ban) : seuil `revokeAllForSubject()` — tout access dont
|
|
401
|
-
`iat < invalidBefore` est rejeté (`ITokenStore.ts:
|
|
402
|
-
logouts successifs ne le reculent pas (`MemoryTokenStore.ts:
|
|
401
|
+
`iat < invalidBefore` est rejeté (`ITokenStore.ts:259-263`) ; le seuil est **monotone**, deux
|
|
402
|
+
logouts successifs ne le reculent pas (`MemoryTokenStore.ts:262-267`).
|
|
403
403
|
|
|
404
404
|
### La maintenance (gc)
|
|
405
405
|
|
|
406
406
|
Le `gc()` du store purge la `denylist` expirée, les records à terme, les PAT révoqués au-delà de
|
|
407
|
-
la rétention (`ITokenStore.ts:
|
|
407
|
+
la rétention (`ITokenStore.ts:274-280`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
|
|
408
408
|
un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:293`).
|
|
409
409
|
|
|
410
410
|
> [!TIP]
|
|
411
411
|
> Un refresh révoqué **par rotation** n'est PAS purgé tout de suite : il est conservé jusqu'à son
|
|
412
412
|
> `expiresAt` — c'est la **fenêtre de détection de rejeu** — puis tombe au gc, `#isPurgeable()`
|
|
413
|
-
> (`MemoryTokenStore.ts:
|
|
413
|
+
> (`MemoryTokenStore.ts:301-310`). Un store **local** (memory) est par-process : seul SON process
|
|
414
414
|
> peut le purger → ne déléguez le gc au cron QUE pour un store partagé.
|
|
415
415
|
|
|
416
416
|
## ⚙️ Configuration
|
|
417
417
|
|
|
418
|
-
Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:
|
|
419
|
-
(`config.ts:
|
|
418
|
+
Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `tokenStoreSchema`
|
|
419
|
+
(`config.ts:426-458`), défauts inclus.
|
|
420
420
|
|
|
421
421
|
### `jwt.*`
|
|
422
422
|
|
|
423
423
|
<!-- prettier-ignore -->
|
|
424
424
|
| Option | Type | Défaut | Effet |
|
|
425
425
|
| --- | --- | --- | --- |
|
|
426
|
-
| `enabled` | boolean | `true` | Active signature + refresh (`config.ts:
|
|
426
|
+
| `enabled` | boolean | `true` | Active signature + refresh (`config.ts:368`) |
|
|
427
427
|
| `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts:21`) |
|
|
428
|
-
| `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:
|
|
429
|
-
| `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:
|
|
430
|
-
| `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:
|
|
428
|
+
| `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:370`) |
|
|
429
|
+
| `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:375`) |
|
|
430
|
+
| `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:380`) |
|
|
431
431
|
| `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
|
|
432
|
-
| `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:
|
|
432
|
+
| `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:390`) |
|
|
433
433
|
| `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
|
|
434
|
-
| `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:
|
|
435
|
-
| `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:
|
|
434
|
+
| `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:404`) |
|
|
435
|
+
| `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:410-418`) |
|
|
436
436
|
|
|
437
437
|
### `tokenStore.*`
|
|
438
438
|
|
|
439
439
|
| Option | Type | Défaut | Effet |
|
|
440
440
|
| ---------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
|
|
441
|
-
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:
|
|
442
|
-
| `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:
|
|
443
|
-
| `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:
|
|
444
|
-
| `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:
|
|
441
|
+
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:428-433`) |
|
|
442
|
+
| `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:434`) |
|
|
443
|
+
| `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:442`) |
|
|
444
|
+
| `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:448`) |
|
|
445
445
|
|
|
446
446
|
## 📜 Normes appliquées
|
|
447
447
|
|
|
448
448
|
| Domaine | Norme | Ancrage |
|
|
449
449
|
| -------------------------------- | --------------- | ------------------------------------------------------- |
|
|
450
|
-
| Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:
|
|
451
|
-
| Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:
|
|
452
|
-
| Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:
|
|
453
|
-
| Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:
|
|
450
|
+
| Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:49-57`) |
|
|
451
|
+
| Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:562`) |
|
|
452
|
+
| Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:649-662`) |
|
|
453
|
+
| Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:649-657`) |
|
|
454
454
|
| Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts:156-158`) |
|
|
455
455
|
| Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts:104-105`) |
|
|
456
456
|
| 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts:108-115`) |
|
|
457
|
-
| Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:
|
|
457
|
+
| Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:353`) |
|
|
458
458
|
|
|
459
459
|
## ⚡ Performance & mémoire
|
|
460
460
|
|
|
461
|
-
- **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:
|
|
461
|
+
- **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:726`)
|
|
462
462
|
— le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
|
|
463
463
|
- **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
|
|
464
464
|
vérification (hot path) vit chez le `JwtAuthenticator`.
|
|
465
465
|
- **Timers civilisés** : `GcScheduler` `unref` (n'empêche pas l'arrêt) + jitter anti-balayages
|
|
466
|
-
simultanés (`tokenService.ts:
|
|
466
|
+
simultanés (`tokenService.ts:188-194`) ; denylist mémoire bornée par purge amortie
|
|
467
467
|
(`MemoryTokenStore.ts:331-341`).
|
|
468
468
|
- **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts:233`).
|
|
469
469
|
|
package/docs/totp.md
CHANGED
|
@@ -110,9 +110,9 @@ reste le second facteur **universel** — celui qui marche sans matériel dédi
|
|
|
110
110
|
|
|
111
111
|
Trois partis pris, tous vérifiables dans le code.
|
|
112
112
|
|
|
113
|
-
**1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts:
|
|
113
|
+
**1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts:74`)
|
|
114
114
|
résout au boot deux choses seulement : le **store** de secrets et la **clé de chiffrement**
|
|
115
|
-
(`TotpService.#build()`, `totp.ts:
|
|
115
|
+
(`TotpService.#build()`, `totp.ts:92`). Tout le reste — enrôler, confirmer, vérifier — vit dans des
|
|
116
116
|
fonctions sans I/O ni container (`totpOperations.ts`), qui reçoivent leurs dépendances en argument
|
|
117
117
|
(`ITotpDeps`, `totpOperations.ts:22`). Conséquence directe : la logique critique se teste **sans
|
|
118
118
|
serveur, sans base, avec une horloge injectée** — et c'est pour ça qu'elle est couverte à ~100 %.
|
|
@@ -125,14 +125,14 @@ différence de traitement, pas une négligence.
|
|
|
125
125
|
|
|
126
126
|
**3. Le TOTP n'est pas un authenticator du firewall — c'est un step-up de login.** Il n'apparaît
|
|
127
127
|
jamais dans `area.authenticators` : il s'insère **dans le flux de session BFF**, entre le mot de
|
|
128
|
-
passe et l'ouverture de session (`AuthFlow.completeMfaLogin()`, `authFlow.ts:
|
|
128
|
+
passe et l'ouverture de session (`AuthFlow.completeMfaLogin()`, `authFlow.ts:257`). Même dessin que
|
|
129
129
|
WebAuthn et OAuth : le firewall n'a qu'un seul mécanisme à connaître, la **session**.
|
|
130
130
|
|
|
131
131
|
> [!IMPORTANT]
|
|
132
132
|
> Le couplage est fait **par nom de service**, jamais par import : `AuthFlow` ne connaît du 2FA
|
|
133
133
|
> qu'une interface locale de trois méthodes (`ITotpLoginVerifier`, `authFlow.ts:44`). 2FA désactivé
|
|
134
134
|
> ⇒ service absent ⇒ le login nominal **ne paie strictement rien** (`AuthFlow.#resolveTotp()`,
|
|
135
|
-
> `authFlow.ts:
|
|
135
|
+
> `authFlow.ts:458`).
|
|
136
136
|
|
|
137
137
|
## 🚀 Démarrage rapide
|
|
138
138
|
|
|
@@ -143,7 +143,7 @@ activer un second facteur depuis sa page « ma sécurité », et que le login l'
|
|
|
143
143
|
|
|
144
144
|
Le secret TOTP est chiffré au repos → il faut une clé **stable** (sinon les secrets deviennent
|
|
145
145
|
illisibles au redémarrage). La commande la génère et te dit exactement où la coller
|
|
146
|
-
(`nodefony security:secrets`, `security-secrets.ts:
|
|
146
|
+
(`nodefony security:secrets`, `security-secrets.ts:79`) :
|
|
147
147
|
|
|
148
148
|
```bash
|
|
149
149
|
npx nodefony security:secrets --write
|
|
@@ -154,7 +154,7 @@ npx nodefony security:secrets --write
|
|
|
154
154
|
# 3. Fichier nodefony.config.ts — le câblage vers le module security
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts:
|
|
157
|
+
Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts:136`) et
|
|
158
158
|
**n'écrase jamais** une valeur existante — une rotation reste un geste manuel et conscient.
|
|
159
159
|
|
|
160
160
|
### 2. Déclarer puis câbler la clé
|
|
@@ -301,7 +301,7 @@ Ce que le code garantit à chaque étape :
|
|
|
301
301
|
|
|
302
302
|
> [!TIP]
|
|
303
303
|
> Le HTTP ne laisse jamais fuir le détail : code faux, enrôlement absent, ou déjà confirmé donnent
|
|
304
|
-
> tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts:
|
|
304
|
+
> tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts:78`).
|
|
305
305
|
> La cause fine reste côté serveur.
|
|
306
306
|
|
|
307
307
|
### La vérification au login
|
|
@@ -333,13 +333,13 @@ sequenceDiagram
|
|
|
333
333
|
A-->>U: 200 { user }
|
|
334
334
|
```
|
|
335
335
|
|
|
336
|
-
Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:
|
|
336
|
+
Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) :
|
|
337
337
|
|
|
338
338
|
1. **Le défi vit en session, pas dans l'URL ni dans un jeton client** — clé `mfa:pending`
|
|
339
339
|
(`authFlow.ts:18`), posée par le login, **consommée** avant l'ouverture de session
|
|
340
|
-
(`authFlow.ts:
|
|
340
|
+
(`authFlow.ts:301`).
|
|
341
341
|
2. **Le code à 6 chiffres est throttlé** comme un mot de passe — même backoff partagé
|
|
342
|
-
(`AuthFlow.#resolveThrottler()`, `authFlow.ts:
|
|
342
|
+
(`AuthFlow.#resolveThrottler()`, `authFlow.ts:447`) : 10⁶ combinaisons se forcent brute en
|
|
343
343
|
quelques minutes sans lui. Trop de tentatives → `429` + `Retry-After`.
|
|
344
344
|
3. **Un échec ne détruit pas le défi** — l'utilisateur qui s'est trompé de chiffre ressaisit ; il
|
|
345
345
|
n'a pas à refaire son mot de passe.
|
|
@@ -403,13 +403,13 @@ Le store, lui, ne voit que des octets : il ne déchiffre jamais rien (`ITotpSecr
|
|
|
403
403
|
|
|
404
404
|
### La politique de clé — bruyante en dev, fail-closed en production
|
|
405
405
|
|
|
406
|
-
`TotpService.#resolveKey()` (`totp.ts:
|
|
406
|
+
`TotpService.#resolveKey()` (`totp.ts:211`) tranche au boot :
|
|
407
407
|
|
|
408
408
|
| Situation | Environnement | Comportement |
|
|
409
409
|
| ---------------------------- | ------------- | ------------------------------------------------------------ |
|
|
410
|
-
| `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts:
|
|
411
|
-
| Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts:
|
|
412
|
-
| Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts:
|
|
410
|
+
| `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts:214`). |
|
|
411
|
+
| Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts:228`). |
|
|
412
|
+
| Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts:220`). |
|
|
413
413
|
| `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot. |
|
|
414
414
|
|
|
415
415
|
Le refus en production est délibéré : une clé éphémère chiffrerait des secrets **illisibles au
|
|
@@ -452,20 +452,20 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
|
|
|
452
452
|
|
|
453
453
|
## ⚙️ Configuration et mises en situation
|
|
454
454
|
|
|
455
|
-
La section `totp` du schéma Zod (`config.ts:
|
|
455
|
+
La section `totp` du schéma Zod (`config.ts:1144`) — validée au boot, donc une valeur hors bornes
|
|
456
456
|
échoue **au démarrage**, pas au premier login :
|
|
457
457
|
|
|
458
|
-
| Option | Type | Défaut | Effet
|
|
459
|
-
| --------------- | ---------------------------- | ------ |
|
|
460
|
-
| `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes non montées (`totp.ts:
|
|
461
|
-
| `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app.
|
|
462
|
-
| `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:
|
|
463
|
-
| `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum).
|
|
464
|
-
| `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes.
|
|
465
|
-
| `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:
|
|
466
|
-
| `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:
|
|
467
|
-
| `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:
|
|
468
|
-
| `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:
|
|
458
|
+
| Option | Type | Défaut | Effet |
|
|
459
|
+
| --------------- | ---------------------------- | ------ | ------------------------------------------------------------------ |
|
|
460
|
+
| `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes non montées (`totp.ts:101`). |
|
|
461
|
+
| `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
|
|
462
|
+
| `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
|
|
463
|
+
| `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
|
|
464
|
+
| `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
|
|
465
|
+
| `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
|
|
466
|
+
| `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
|
|
467
|
+
| `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
|
|
468
|
+
| `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`). |
|
|
469
469
|
|
|
470
470
|
### Situation 1 — un utilisateur active la 2FA sur son compte
|
|
471
471
|
|
|
@@ -492,7 +492,7 @@ Le second facteur validé au login ne dit rien de **qui est devant l'écran dix
|
|
|
492
492
|
redemande le code : c'est le _sudo mode_.
|
|
493
493
|
|
|
494
494
|
Nodefony fournit le step-up **de login** ; la re-vérification en cours de session, elle, se compose
|
|
495
|
-
dans ton controller à partir du service public `TotpService.verifyLogin()` (`totp.ts:
|
|
495
|
+
dans ton controller à partir du service public `TotpService.verifyLogin()` (`totp.ts:269`) :
|
|
496
496
|
|
|
497
497
|
```typescript
|
|
498
498
|
import { controller, Controller, Post } from "@nodefony/framework";
|
|
@@ -557,28 +557,30 @@ dialecte via le colKit :
|
|
|
557
557
|
| `period` | Durée d'un code (s) | `integer` | `integer` | `int` |
|
|
558
558
|
| `recoveryCodes` | Condensats des codes **non consommés** | `text` (mode json) | `jsonb` | `json` |
|
|
559
559
|
| `confirmedAt` | Activation (epoch ms) ou `null` = en attente | `integer` | `bigint` | `bigint` |
|
|
560
|
-
| `lastUsedStep` | Dernière tranche `T` validée (**pas** une date) | `integer` | `
|
|
560
|
+
| `lastUsedStep` | Dernière tranche `T` validée (**pas** une date) | `integer` | `bigint` | `bigint` |
|
|
561
561
|
| `createdAt` | Création (epoch ms) | `integer` | `bigint` | `bigint` |
|
|
562
562
|
| `lastUsedAt` | Dernier usage réussi (epoch ms) ou `null` | `integer` | `bigint` | `bigint` |
|
|
563
563
|
|
|
564
564
|
Deux pièges de lecture, signalés dans l'entité elle-même :
|
|
565
565
|
|
|
566
566
|
- `lastUsedStep` est un **numéro de tranche RFC 6238**, pas un horodatage — d'où le type `int`
|
|
567
|
-
partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts:
|
|
567
|
+
partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts:61`).
|
|
568
568
|
- Un epoch en millisecondes **déborde** un `integer` 32 bits → `bigint` en PostgreSQL et MySQL
|
|
569
569
|
(SQLite, lui, a des INTEGER 64 bits).
|
|
570
570
|
|
|
571
|
-
### Les backends disponibles
|
|
571
|
+
### Les backends disponibles
|
|
572
572
|
|
|
573
573
|
| Backend | Enregistré par | Durabilité | État |
|
|
574
574
|
| ---------- | --------------------------------------------- | ----------------------------------- | -------------------- |
|
|
575
575
|
| `memory` | builtin (`totpSecretStoreRegistry.ts:54`) | **volatile** — perdu au redémarrage | ✅ dev / tests |
|
|
576
|
-
| `drizzle` | `@nodefony/drizzle` (`registerStores.ts:
|
|
577
|
-
| `mongoose` |
|
|
576
|
+
| `drizzle` | `@nodefony/drizzle` (`registerStores.ts:320`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
|
|
577
|
+
| `mongoose` | `@nodefony/mongoose` (`registerStores.ts`) | durable, partagé entre pods | ✅ MongoDB |
|
|
578
578
|
| `redis` | — | — | ⏳ manquant, à venir |
|
|
579
579
|
|
|
580
|
-
|
|
581
|
-
|
|
580
|
+
Une application peut donc activer le 2FA **entièrement sur MongoDB**, sans charger d'ORM SQL : une
|
|
581
|
+
application choisit son ORM, elle ne choisit pas de se passer du second facteur.
|
|
582
|
+
|
|
583
|
+
Reste une absence, et c'est un **manque**, pas un choix de périmètre.
|
|
582
584
|
|
|
583
585
|
`redis` le portera **en opt-in explicite, jamais choisi par `auto`** — exactement le régime des
|
|
584
586
|
passkeys qu'il porte déjà. Un secret TOTP est de la même famille qu'un credential passkey : une
|
|
@@ -587,20 +589,19 @@ dehors. Porter l'un et refuser l'autre au nom du « cache évincible » serait i
|
|
|
587
589
|
est identique, et il est déjà assumé, avec son avertissement — sur Redis, la persistance devient la
|
|
588
590
|
responsabilité de l'exploitant (AOF, pas d'éviction sur ces clés).
|
|
589
591
|
|
|
590
|
-
`mongoose
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
à rendre le store durable, les deux modules cohabitent sans conflit.
|
|
592
|
+
Côté `mongoose`, le store est **100 % portable** lui aussi : tout passe par le contrat
|
|
593
|
+
`IRepository` et le helper `paginate()` d'orm-core, sans query native
|
|
594
|
+
(`MongooseTotpSecretStore.ts`). Le modèle « un secret par utilisateur » y est porté par la clé
|
|
595
|
+
primaire (`_id` = `userId`) — donc l'unicité existe dès le premier document, sans index construit
|
|
596
|
+
en tâche de fond.
|
|
596
597
|
|
|
597
598
|
Côté `drizzle`, les **trois dialectes** sont portés — `TOTP_PORTED` vaut l'ensemble des dialectes
|
|
598
|
-
(`registerStores.ts:
|
|
599
|
-
tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts:
|
|
599
|
+
(`registerStores.ts:94`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
|
|
600
|
+
tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts:44`)
|
|
600
601
|
— c'est ce qui rend la portabilité gratuite.
|
|
601
602
|
|
|
602
603
|
**Comment le backend est choisi.** `store: "auto"` (le défaut) suit l'infra déclarée puis les
|
|
603
|
-
adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:
|
|
604
|
+
adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:137`) :
|
|
604
605
|
|
|
605
606
|
1. `NF_STORE` (override global de banc de charge), s'il est enregistré ici ;
|
|
606
607
|
2. infra base de données déclarée (`NF_DATABASE_URL`) → `drizzle` ;
|
|
@@ -608,7 +609,7 @@ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:134`) :
|
|
|
608
609
|
4. sinon **repli `memory`, annoncé** — jamais silencieux.
|
|
609
610
|
|
|
610
611
|
Un `store` **explicite** introuvable, en revanche, ne se replie pas : `CRITIC` en dev, boot avorté en
|
|
611
|
-
production (`totp.ts:
|
|
612
|
+
production (`totp.ts:174`). Une faute de frappe ne dégrade jamais la sécurité en douce.
|
|
612
613
|
|
|
613
614
|
## Le listing paginé des enrôlements
|
|
614
615
|
|
|
@@ -621,14 +622,14 @@ d'un seul utilisateur.
|
|
|
621
622
|
- **pagination native au store** — jamais de parcours complet en mémoire ; l'ordre est contractuel
|
|
622
623
|
(`createdAt` DESC, départagé par `userId` ASC) ;
|
|
623
624
|
- **filtres appliqués côté backend** — `confirmed` (activés / en attente) et `q` (préfixe d'`userId`,
|
|
624
|
-
donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts:
|
|
625
|
+
donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts:176`) ;
|
|
625
626
|
- **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts:16`)
|
|
626
627
|
n'a ni `secretEnc` ni les condensats de récupération, seulement leur **nombre**
|
|
627
628
|
(`recoveryCodesLeft`).
|
|
628
629
|
|
|
629
630
|
Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'affichage : quel que
|
|
630
631
|
soit le backend, ces champs ne peuvent pas remonter par ce chemin, même si un appelant les demandait
|
|
631
|
-
(`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:
|
|
632
|
+
(`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:19`). C'est ce qu'exerce le banc de contrat partagé.
|
|
632
633
|
|
|
633
634
|
`countEnrollments()` (`ITotpSecretStore.ts:90`) donne le KPI de couverture sans énumérer une seule
|
|
634
635
|
ligne.
|
|
@@ -637,23 +638,23 @@ ligne.
|
|
|
637
638
|
|
|
638
639
|
Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.ai/symbols.json`.
|
|
639
640
|
|
|
640
|
-
**Le service** (`TotpService`, `totp.ts:
|
|
641
|
+
**Le service** (`TotpService`, `totp.ts:74`), résolu par nom dans le container (`"totp"`) :
|
|
641
642
|
|
|
642
|
-
| Méthode
|
|
643
|
-
|
|
|
644
|
-
| `isEnabled()` (`totp.ts:
|
|
645
|
-
| `beginEnrollment()` (`totp.ts:
|
|
646
|
-
| `confirmEnrollment()` (
|
|
647
|
-
| `verifyLogin()` (`totp.ts:
|
|
648
|
-
| `disable()` (`totp.ts:
|
|
649
|
-
| `status()` (`totp.ts:
|
|
650
|
-
| `isEnabledFor()` (`totp.ts:
|
|
651
|
-
| `listPage()` (`totp.ts:
|
|
652
|
-
| `countEnrollments()` (
|
|
643
|
+
| Méthode | Rôle |
|
|
644
|
+
| ------------------------------------- | ------------------------------------------------------------ |
|
|
645
|
+
| `isEnabled()` (`totp.ts:254`) | 2FA opérationnel (activé en config **et** boot réussi). |
|
|
646
|
+
| `beginEnrollment()` (`totp.ts:259`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
|
|
647
|
+
| `confirmEnrollment()` (`totp.ts:264`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
|
|
648
|
+
| `verifyLogin()` (`totp.ts:269`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
|
|
649
|
+
| `disable()` (`totp.ts:274`) | Retire secret et codes. |
|
|
650
|
+
| `status()` (`totp.ts:279`) | `{ enabled, pending, recoveryCodesRemaining }`. |
|
|
651
|
+
| `isEnabledFor()` (`totp.ts:306`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
|
|
652
|
+
| `listPage()` (`totp.ts:290`) | Page d'enrôlements (data plane admin). |
|
|
653
|
+
| `countEnrollments()` (`totp.ts:301`) | Compte filtré, sans énumération. |
|
|
653
654
|
|
|
654
655
|
**Les opérations pures**, si tu veux le 2FA **sans** le service (test, script, autre transport) —
|
|
655
656
|
elles prennent leurs dépendances en argument : `beginTotpEnrollment()`, `confirmTotpEnrollment()`,
|
|
656
|
-
`verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts:
|
|
657
|
+
`verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts:200`).
|
|
657
658
|
|
|
658
659
|
**Les primitives crypto**, pour écrire un client ou un banc de test : `totpCode()`
|
|
659
660
|
(`totpCrypto.ts:174`), `base32Decode()` (`totpCrypto.ts:81`), `deriveTotpKey()`
|
|
@@ -698,7 +699,7 @@ vérifie que ta projection n'expose ni secret ni condensat.
|
|
|
698
699
|
| Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`) |
|
|
699
700
|
| Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`) |
|
|
700
701
|
| Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts:330`) |
|
|
701
|
-
| Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:
|
|
702
|
+
| Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) |
|
|
702
703
|
| Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts:145`) |
|
|
703
704
|
|
|
704
705
|
Les **vecteurs de test de la RFC 6238 (Appendix B)** sont rejoués en test sur les trois fonctions de
|
|
@@ -708,18 +709,18 @@ hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
|
|
|
708
709
|
|
|
709
710
|
Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas.
|
|
710
711
|
|
|
711
|
-
- **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:
|
|
712
|
+
- **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:458`)
|
|
712
713
|
résout le service **une seule fois** puis met le résultat en cache. Service absent ⇒ `null` ⇒
|
|
713
714
|
**zéro accès au store**, zéro allocation par login.
|
|
714
715
|
- **Aucun coût par requête** : le TOTP n'est pas un authenticator du firewall, il ne s'exécute donc
|
|
715
716
|
jamais dans le pipeline HTTP/WS.
|
|
716
717
|
- **Allocation paresseuse du store** : la `Map` de `MemoryTotpSecretStore`
|
|
717
|
-
(`MemoryTotpSecretStore.ts:
|
|
718
|
-
quand `totp.enabled` est `false` (`totp.ts:
|
|
718
|
+
(`MemoryTotpSecretStore.ts:74`) n'existe que si le 2FA est activé — le service ne construit rien
|
|
719
|
+
quand `totp.enabled` est `false` (`totp.ts:101`).
|
|
719
720
|
- **Le coût réel d'une vérification** : ≤ `2·window + 1` HMAC (3 par défaut) + un déchiffrement
|
|
720
721
|
AES-GCM. De l'ordre de la microseconde — négligeable devant le hachage Argon2id du mot de passe
|
|
721
722
|
qui l'a précédé.
|
|
722
|
-
- **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts:
|
|
723
|
+
- **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts:241`)
|
|
723
724
|
le déclenche à `onTerminate` — aucune écriture en attente perdue.
|
|
724
725
|
|
|
725
726
|
## 📡 Observabilité — Studio
|
|
@@ -733,34 +734,34 @@ Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas
|
|
|
733
734
|
|
|
734
735
|
Le data plane admin correspondant, gardé par `ROLE_NODEFONY_ADMIN` :
|
|
735
736
|
|
|
736
|
-
- `GET /nodefony/security/api/totp/list` — couverture 2FA paginée (`SecurityAdminApi.ts:
|
|
737
|
+
- `GET /nodefony/security/api/totp/list` — couverture 2FA paginée (`SecurityAdminApi.ts:636`).
|
|
737
738
|
Réponse **honnête** si le 2FA est désactivé : `{ enabled: false, items: [] }`, jamais une erreur —
|
|
738
739
|
la console doit pouvoir afficher « 2FA désactivé ».
|
|
739
|
-
- `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:
|
|
740
|
+
- `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:686`).
|
|
740
741
|
- `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
|
|
741
|
-
(`SecurityAdminApi.ts:
|
|
742
|
+
(`SecurityAdminApi.ts:711`).
|
|
742
743
|
|
|
743
744
|
L'admin peut **désactiver**, jamais **activer** pour autrui : le secret se scanne sur l'appareil de
|
|
744
745
|
l'utilisateur, lui seul peut l'armer.
|
|
745
746
|
|
|
746
747
|
Côté journal d'audit, quatre actions tracent le cycle : `login.mfa_required` (`authFlow.ts:177`),
|
|
747
|
-
`login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:
|
|
748
|
+
`login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:232`), `login.failure` avec
|
|
748
749
|
`reason: "mfa_invalid"` (`authFlow.ts:291`), et `user.totp_disabled` côté admin
|
|
749
|
-
(`SecurityAdminApi.ts:
|
|
750
|
+
(`SecurityAdminApi.ts:735`).
|
|
750
751
|
|
|
751
752
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
752
753
|
|
|
753
754
|
| Symptôme | Cause (dans le code) | Correction |
|
|
754
755
|
| ------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
755
|
-
| 2FA inactif en production, `CRITIC` au boot | `totp.encryptionKey` absente — fail-closed (`totp.ts:
|
|
756
|
+
| 2FA inactif en production, `CRITIC` au boot | `totp.encryptionKey` absente — fail-closed (`totp.ts:220`) | `npx nodefony security:secrets`, puis câbler `ctx.env.NF_TOTP_KEY` |
|
|
756
757
|
| Tous les secrets illisibles après déploiement | Clé éphémère (dev) ou `TOTP_DERIVATION` modifié | Clé **stable** partagée ; ne jamais toucher au contexte HKDF |
|
|
757
758
|
| Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
|
|
758
759
|
| Code « juste » systématiquement refusé | Horloge décalée de plus d'un pas (fenêtre = ±30 s) | Synchroniser NTP serveur **et** téléphone |
|
|
759
760
|
| Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
|
|
760
761
|
| `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
|
|
761
762
|
| `401` sur `…/auth/me` juste après le mot de passe | L'identité n'est posée qu'après le 2ᵉ facteur (`authFlow.ts:170`) | Terminer le step-up |
|
|
762
|
-
| `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:
|
|
763
|
-
| `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:
|
|
763
|
+
| `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) | Respecter `Retry-After` — attendu sous attaque |
|
|
764
|
+
| `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:21`) | Vérifier `totp.enabled` + la clé + les logs de boot |
|
|
764
765
|
| Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
|
|
765
766
|
| Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts:173`) | — |
|
|
766
767
|
| Code de récupération réutilisable | Impossible — retiré du stock à l'usage (`totpOperations.ts:186`) | Régénérer un lot en ré-enrôlant si le stock est bas |
|