@nodefony/security 10.0.0-beta.1 → 10.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/securityHeaders.js +12 -1
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-token.d.ts +9 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +63 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +125 -9
- package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +18 -18
- package/docs/audit.md +24 -22
- package/docs/authenticators.md +62 -29
- package/docs/authorization.md +16 -16
- package/docs/cors.md +30 -30
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +68 -5
- package/docs/firewall.md +78 -45
- package/docs/headers.md +35 -35
- package/docs/index.md +15 -7
- package/docs/keycloak.md +818 -0
- package/docs/lexique.md +15 -15
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +242 -125
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +138 -86
- package/docs/totp.md +113 -71
- package/docs/webauthn.md +115 -115
- package/docs/webhooks.md +21 -21
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +17 -15
package/docs/totp.md
CHANGED
|
@@ -66,7 +66,7 @@ flowchart TD
|
|
|
66
66
|
|
|
67
67
|
Tant que le second facteur n'est pas validé, **l'identité n'est pas établie** : `session.user` reste
|
|
68
68
|
vide et le Zero Trust du firewall répond 401 sur tout le reste (`AuthFlow.login()`,
|
|
69
|
-
`authFlow.ts:
|
|
69
|
+
`authFlow.ts:142`).
|
|
70
70
|
|
|
71
71
|
## 📖 Lexique
|
|
72
72
|
|
|
@@ -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:297`). 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
|
-
> qu'une interface locale de trois méthodes (`ITotpLoginVerifier`, `authFlow.ts:
|
|
133
|
+
> qu'une interface locale de trois méthodes (`ITotpLoginVerifier`, `authFlow.ts:79`). 2FA désactivé
|
|
134
134
|
> ⇒ service absent ⇒ le login nominal **ne paie strictement rien** (`AuthFlow.#resolveTotp()`,
|
|
135
|
-
> `authFlow.ts:
|
|
135
|
+
> `authFlow.ts:642`).
|
|
136
136
|
|
|
137
137
|
## 🚀 Démarrage rapide
|
|
138
138
|
|
|
@@ -148,13 +148,13 @@ illisibles au redémarrage). La commande la génère et te dit exactement où la
|
|
|
148
148
|
```bash
|
|
149
149
|
npx nodefony security:secrets --write
|
|
150
150
|
# 🔐 Secrets security — 3 étapes, 3 FICHIERS
|
|
151
|
-
# 1. Fichier .env
|
|
152
|
-
# ✓ écrit dans .env
|
|
151
|
+
# 1. Fichier .env — les valeurs
|
|
152
|
+
# ✓ écrit dans .env (NF_TOTP_KEY, NF_WEBHOOK_KEY, NF_CSRF_SECRET)
|
|
153
153
|
# 2. Fichier env.ts — la déclaration typée
|
|
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:156`) 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é
|
|
@@ -190,8 +190,9 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
190
190
|
|
|
191
191
|
### 3. Les endpoints sont FOURNIS — tu n'écris aucun controller
|
|
192
192
|
|
|
193
|
-
`mountTotpRoutes()` (`TotpController.ts:
|
|
194
|
-
le service `totp`
|
|
193
|
+
`mountTotpRoutes()` (`TotpController.ts:161`) monte quatre routes self-service dès que
|
|
194
|
+
`@nodefony/security` est chargé (le service `totp` est alors toujours enregistré). 2FA désactivé, ou
|
|
195
|
+
service non initialisé : elles répondent `503 2FA unavailable` :
|
|
195
196
|
|
|
196
197
|
| Route | Corps | Réponse |
|
|
197
198
|
| ------------------------------------------ | ---------- | ---------------------------------------------- |
|
|
@@ -201,17 +202,23 @@ le service `totp` existe (security chargé + 2FA activé) — sinon zéro surfac
|
|
|
201
202
|
| `GET /nodefony/security/api/totp/status` | — | `{ enabled, pending, recoveryCodesRemaining }` |
|
|
202
203
|
|
|
203
204
|
Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
|
|
204
|
-
`SessionAuthController.ts:
|
|
205
|
+
`SessionAuthController.ts:214`) :
|
|
205
206
|
|
|
206
207
|
| Route | Corps | Réponse |
|
|
207
208
|
| --------------------------------------------- | ------------------------ | ------------------------------------------ |
|
|
208
209
|
| `POST /nodefony/security/api/auth/login` | `{ username, password }` | `200` + identité, **ou** `202 mfaRequired` |
|
|
209
210
|
| `POST /nodefony/security/api/auth/login/totp` | `{ code }` | `200` + identité, `401`, ou `429` |
|
|
210
211
|
|
|
212
|
+
Le `202` est un **défi**, pas un succès : `{ mfaRequired: true, methods: [...] }`. La liste
|
|
213
|
+
`methods` est **ouverte** — aujourd'hui `["totp"]` (`MFA_METHOD_TOTP`, exporté par `nodefony`),
|
|
214
|
+
demain une passkey ou un code par courriel s'y ajouteront sans rupture. Un client ne retient que
|
|
215
|
+
les méthodes qu'il sait conduire ; `NodefonyLogin` traite un défi sans aucune méthode connue comme
|
|
216
|
+
une réponse inattendue (erreur `server`), jamais comme un écran de code.
|
|
217
|
+
|
|
211
218
|
> [!WARNING]
|
|
212
219
|
> Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : elles vivent dans la
|
|
213
220
|
> zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
|
|
214
|
-
> depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:
|
|
221
|
+
> depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:145`) — jamais un
|
|
215
222
|
> paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
|
|
216
223
|
|
|
217
224
|
### 4. Ce qu'on observe
|
|
@@ -301,7 +308,7 @@ Ce que le code garantit à chaque étape :
|
|
|
301
308
|
|
|
302
309
|
> [!TIP]
|
|
303
310
|
> 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:
|
|
311
|
+
> tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts:82`).
|
|
305
312
|
> La cause fine reste côté serveur.
|
|
306
313
|
|
|
307
314
|
### La vérification au login
|
|
@@ -333,13 +340,13 @@ sequenceDiagram
|
|
|
333
340
|
A-->>U: 200 { user }
|
|
334
341
|
```
|
|
335
342
|
|
|
336
|
-
Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:
|
|
343
|
+
Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:297`) :
|
|
337
344
|
|
|
338
345
|
1. **Le défi vit en session, pas dans l'URL ni dans un jeton client** — clé `mfa:pending`
|
|
339
|
-
(`authFlow.ts:
|
|
346
|
+
(`authFlow.ts:283`), posée par le login, **consommée** avant l'ouverture de session
|
|
340
347
|
(`authFlow.ts:301`).
|
|
341
348
|
2. **Le code à 6 chiffres est throttlé** comme un mot de passe — même backoff partagé
|
|
342
|
-
(`AuthFlow.#resolveThrottler()`, `authFlow.ts:
|
|
349
|
+
(`AuthFlow.#resolveThrottler()`, `authFlow.ts:630`) : 10⁶ combinaisons se forcent brute en
|
|
343
350
|
quelques minutes sans lui. Trop de tentatives → `429` + `Retry-After`.
|
|
344
351
|
3. **Un échec ne détruit pas le défi** — l'utilisateur qui s'est trompé de chiffre ressaisit ; il
|
|
345
352
|
n'a pas à refaire son mot de passe.
|
|
@@ -452,20 +459,20 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
|
|
|
452
459
|
|
|
453
460
|
## ⚙️ Configuration et mises en situation
|
|
454
461
|
|
|
455
|
-
La section `totp` du schéma Zod (`config.ts:
|
|
462
|
+
La section `totp` du schéma Zod (`config.ts:1461`) — validée au boot, donc une valeur hors bornes
|
|
456
463
|
échoue **au démarrage**, pas au premier login :
|
|
457
464
|
|
|
458
|
-
| Option | Type | Défaut | Effet
|
|
459
|
-
| --------------- | ---------------------------- | ------ |
|
|
460
|
-
| `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes
|
|
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:
|
|
465
|
+
| Option | Type | Défaut | Effet |
|
|
466
|
+
| --------------- | ---------------------------- | ------ | --------------------------------------------------------------- |
|
|
467
|
+
| `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts:101`). |
|
|
468
|
+
| `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
|
|
469
|
+
| `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:618`). |
|
|
470
|
+
| `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
|
|
471
|
+
| `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
|
|
472
|
+
| `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:639`). |
|
|
473
|
+
| `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:586`). |
|
|
474
|
+
| `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:655`). |
|
|
475
|
+
| `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:595`). |
|
|
469
476
|
|
|
470
477
|
### Situation 1 — un utilisateur active la 2FA sur son compte
|
|
471
478
|
|
|
@@ -491,8 +498,24 @@ Le second facteur validé au login ne dit rien de **qui est devant l'écran dix
|
|
|
491
498
|
(poste laissé ouvert, session volée). Pour une suppression de compte ou une rotation de clés, on
|
|
492
499
|
redemande le code : c'est le _sudo mode_.
|
|
493
500
|
|
|
494
|
-
|
|
495
|
-
|
|
501
|
+
**La voie déclarative** — une étiquette sur la route, qui lit ce que la session a retenu à son
|
|
502
|
+
ouverture (quand, et avec quels moyens) :
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
import { Post, RequireAuthentication } from "@nodefony/framework";
|
|
506
|
+
|
|
507
|
+
@Post("/delete")
|
|
508
|
+
@RequireAuthentication({ maxAge: 300, amr: ["otp"] }) // connexion < 5 min, avec le code
|
|
509
|
+
async remove() { … }
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Une connexion trop ancienne ou sans le code reçoit un **401** dont le `WWW-Authenticate` porte
|
|
513
|
+
`error="insufficient_user_authentication"` (RFC 9470) : le client redemande une connexion — mot de
|
|
514
|
+
passe puis code — et rejoue l'action. Évaluée APRÈS `@IsGranted` : un rôle absent reste un 403.
|
|
515
|
+
Une session qui ne dit pas comment elle a été ouverte est refusée.
|
|
516
|
+
|
|
517
|
+
**La voie composée** — quand le code doit être saisi DANS l'action, sans repasser par la page de
|
|
518
|
+
connexion, à partir du service public `TotpService.verifyLogin()` (`totp.ts:300`) :
|
|
496
519
|
|
|
497
520
|
```typescript
|
|
498
521
|
import { controller, Controller, Post } from "@nodefony/framework";
|
|
@@ -542,9 +565,22 @@ throttle bornent déjà les tentatives bien avant l'espace des codes.
|
|
|
542
565
|
## L'entité de persistance — un secret par utilisateur
|
|
543
566
|
|
|
544
567
|
Le modèle est volontairement minimal : **clé naturelle = `userId`**, `save` est un upsert
|
|
545
|
-
(`ITotpSecretStore`, `ITotpSecretStore.ts:
|
|
568
|
+
(`ITotpSecretStore`, `ITotpSecretStore.ts:75`). Pas d'identifiant de ligne, pas d'index secondaire —
|
|
546
569
|
tout accès passe par la clé primaire.
|
|
547
570
|
|
|
571
|
+
**`userId` vaut `IUser.identifier`, jamais `IUser.id`** — la même clé que la session et les jetons,
|
|
572
|
+
et elle est immuable. Les deux sont des chaînes : un appelant qui passe l'`id` ne se fait pas
|
|
573
|
+
refuser, il vise une clé vide et reçoit un succès qui n'a rien touché. Le plan d'administration,
|
|
574
|
+
qui adresse les comptes par leur `id`, traduit donc d'abord l'`id` en `identifier`
|
|
575
|
+
(`resolveSubject`, `SecurityAdminApi.ts:270`).
|
|
576
|
+
|
|
577
|
+
**Le secret disparaît avec le compte.** Supprimer un compte (plan d'administration ou
|
|
578
|
+
`security:user:delete`) passe par `UserService.delete`, qui émet `onUserRevoked`
|
|
579
|
+
(`reason: "deleted"`) ; la cascade retire le secret
|
|
580
|
+
TOTP et les passkeys (`cascadeUserRevocation`, `userRevocationCascade.ts:51`). Sans elle, un compte
|
|
581
|
+
recréé sous le même identifiant hériterait du second facteur de l'ancien titulaire. Un compte
|
|
582
|
+
seulement désactivé ou verrouillé garde ses facteurs.
|
|
583
|
+
|
|
548
584
|
Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts:36`), déclinée par
|
|
549
585
|
dialecte via le colKit :
|
|
550
586
|
|
|
@@ -631,7 +667,7 @@ Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'a
|
|
|
631
667
|
soit le backend, ces champs ne peuvent pas remonter par ce chemin, même si un appelant les demandait
|
|
632
668
|
(`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:19`). C'est ce qu'exerce le banc de contrat partagé.
|
|
633
669
|
|
|
634
|
-
`countEnrollments()` (`ITotpSecretStore.ts:
|
|
670
|
+
`countEnrollments()` (`ITotpSecretStore.ts:96`) donne le KPI de couverture sans énumérer une seule
|
|
635
671
|
ligne.
|
|
636
672
|
|
|
637
673
|
## 🧰 API publique
|
|
@@ -645,12 +681,12 @@ Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.a
|
|
|
645
681
|
| `isEnabled()` (`totp.ts:254`) | 2FA opérationnel (activé en config **et** boot réussi). |
|
|
646
682
|
| `beginEnrollment()` (`totp.ts:259`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
|
|
647
683
|
| `confirmEnrollment()` (`totp.ts:264`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
|
|
648
|
-
| `verifyLogin()` (`totp.ts:
|
|
649
|
-
| `disable()` (`totp.ts:
|
|
650
|
-
| `status()` (`totp.ts:
|
|
651
|
-
| `isEnabledFor()` (`totp.ts:
|
|
652
|
-
| `listPage()` (`totp.ts:
|
|
653
|
-
| `countEnrollments()` (`totp.ts:
|
|
684
|
+
| `verifyLogin()` (`totp.ts:300`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
|
|
685
|
+
| `disable()` (`totp.ts:316`) | Retire secret et codes. |
|
|
686
|
+
| `status()` (`totp.ts:331`) | `{ enabled, pending, recoveryCodesRemaining }`. |
|
|
687
|
+
| `isEnabledFor()` (`totp.ts:358`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
|
|
688
|
+
| `listPage()` (`totp.ts:342`) | Page d'enrôlements (data plane admin). |
|
|
689
|
+
| `countEnrollments()` (`totp.ts:353`) | Compte filtré, sans énumération. |
|
|
654
690
|
|
|
655
691
|
**Les opérations pures**, si tu veux le 2FA **sans** le service (test, script, autre transport) —
|
|
656
692
|
elles prennent leurs dépendances en argument : `beginTotpEnrollment()`, `confirmTotpEnrollment()`,
|
|
@@ -688,19 +724,19 @@ vérifie que ta projection n'expose ni secret ni condensat.
|
|
|
688
724
|
|
|
689
725
|
## 📜 Normes appliquées
|
|
690
726
|
|
|
691
|
-
| Domaine | Norme | Ancrage dans le code
|
|
692
|
-
| --------------------------- | ------------------------ |
|
|
693
|
-
| TOTP (algorithme) | RFC 6238 §4 | `totpCode()` (`totpCrypto.ts:174`)
|
|
694
|
-
| HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts:129`), masque `0x7f` (`:141`)
|
|
695
|
-
| Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts:33`)
|
|
696
|
-
| Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts:207`)
|
|
697
|
-
| Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts:173`)
|
|
698
|
-
| Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts:58`)
|
|
699
|
-
| Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`)
|
|
700
|
-
| Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`)
|
|
701
|
-
| Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts:330`)
|
|
702
|
-
| Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:
|
|
703
|
-
| Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts:
|
|
727
|
+
| Domaine | Norme | Ancrage dans le code |
|
|
728
|
+
| --------------------------- | ------------------------ | ---------------------------------------------------------- |
|
|
729
|
+
| TOTP (algorithme) | RFC 6238 §4 | `totpCode()` (`totpCrypto.ts:174`) |
|
|
730
|
+
| HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts:129`), masque `0x7f` (`:141`) |
|
|
731
|
+
| Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts:33`) |
|
|
732
|
+
| Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts:207`) |
|
|
733
|
+
| Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts:173`) |
|
|
734
|
+
| Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts:58`) |
|
|
735
|
+
| Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`) |
|
|
736
|
+
| Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`) |
|
|
737
|
+
| Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts:330`) |
|
|
738
|
+
| Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:297`) |
|
|
739
|
+
| Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts:164-172`) |
|
|
704
740
|
|
|
705
741
|
Les **vecteurs de test de la RFC 6238 (Appendix B)** sont rejoués en test sur les trois fonctions de
|
|
706
742
|
hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
|
|
@@ -709,7 +745,7 @@ hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
|
|
|
709
745
|
|
|
710
746
|
Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas.
|
|
711
747
|
|
|
712
|
-
- **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:
|
|
748
|
+
- **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:642`)
|
|
713
749
|
résout le service **une seule fois** puis met le résultat en cache. Service absent ⇒ `null` ⇒
|
|
714
750
|
**zéro accès au store**, zéro allocation par login.
|
|
715
751
|
- **Aucun coût par requête** : le TOTP n'est pas un authenticator du firewall, il ne s'exécute donc
|
|
@@ -734,37 +770,43 @@ Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas
|
|
|
734
770
|
|
|
735
771
|
Le data plane admin correspondant, gardé par `ROLE_NODEFONY_ADMIN` :
|
|
736
772
|
|
|
737
|
-
- `GET /nodefony/security/api/totp/list` — couverture 2FA paginée
|
|
773
|
+
- `GET /nodefony/security/api/totp/list` — couverture 2FA paginée, `?q=` = préfixe d'`userId`
|
|
774
|
+
(`SecurityAdminApi.ts:601`).
|
|
738
775
|
Réponse **honnête** si le 2FA est désactivé : `{ enabled: false, items: [] }`, jamais une erreur —
|
|
739
776
|
la console doit pouvoir afficher « 2FA désactivé ».
|
|
740
|
-
- `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:
|
|
777
|
+
- `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:748`).
|
|
741
778
|
- `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
|
|
742
|
-
(`SecurityAdminApi.ts:
|
|
779
|
+
(`SecurityAdminApi.ts:767`).
|
|
780
|
+
|
|
781
|
+
Ces deux routes répondent `503` si le service `users` manque : sans lui, l'`id` ne se traduit pas en
|
|
782
|
+
clé de sujet, et agir quand même rendrait un faux succès.
|
|
743
783
|
|
|
744
784
|
L'admin peut **désactiver**, jamais **activer** pour autrui : le secret se scanne sur l'appareil de
|
|
745
785
|
l'utilisateur, lui seul peut l'armer.
|
|
746
786
|
|
|
747
|
-
Côté journal d'audit,
|
|
787
|
+
Côté journal d'audit, cinq actions tracent le cycle : `login.mfa_required` (`authFlow.ts:177`),
|
|
748
788
|
`login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:232`), `login.failure` avec
|
|
749
|
-
`reason: "mfa_invalid"` (`authFlow.ts:291`),
|
|
750
|
-
(`
|
|
789
|
+
`reason: "mfa_invalid"` (`authFlow.ts:291`), `user.totp_enabled` quand le titulaire active son
|
|
790
|
+
facteur (`totp.ts:290`), et `user.totp_disabled` — par le titulaire (`totp.ts:321`,
|
|
791
|
+
`viaAdmin: false`) ou par un administrateur (`SecurityAdminApi.ts:789`, `viaAdmin: true`). Les
|
|
792
|
+
deux gestes du titulaire portent l'IP, le navigateur et l'identifiant de la requête.
|
|
751
793
|
|
|
752
794
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
753
795
|
|
|
754
|
-
| Symptôme
|
|
755
|
-
|
|
|
756
|
-
| 2FA inactif en production, `CRITIC` au boot
|
|
757
|
-
| Tous les secrets illisibles après déploiement
|
|
758
|
-
| Secrets perdus à chaque redémarrage
|
|
759
|
-
| Code « juste » systématiquement refusé
|
|
760
|
-
| Le QR est scanné mais aucun code ne passe
|
|
761
|
-
| `202` au login au lieu de `200`
|
|
762
|
-
|
|
|
763
|
-
| `429` pendant la saisie du code
|
|
764
|
-
| `503 2FA unavailable` sur `…/totp/*`
|
|
765
|
-
| Utilisateur bloqué, plus aucun code
|
|
766
|
-
| Même code accepté deux fois
|
|
767
|
-
| Code de récupération réutilisable
|
|
796
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
797
|
+
| ------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
798
|
+
| 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` |
|
|
799
|
+
| 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 |
|
|
800
|
+
| Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
|
|
801
|
+
| 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 |
|
|
802
|
+
| Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
|
|
803
|
+
| `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
|
|
804
|
+
| `…/auth/me` rend `{ user: null }` 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 |
|
|
805
|
+
| `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:297`) | Respecter `Retry-After` — attendu sous attaque |
|
|
806
|
+
| `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:21`) | Vérifier `totp.enabled` + la clé + les logs de boot |
|
|
807
|
+
| Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
|
|
808
|
+
| Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts:173`) | — |
|
|
809
|
+
| 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 |
|
|
768
810
|
|
|
769
811
|
## 🧪 Tests & couverture
|
|
770
812
|
|