@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/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 |
|