@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.4
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.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +48 -16
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
- package/dist/nodefony/command/security-oauth-doctor.js +68 -0
- package/dist/nodefony/command/security-secrets.js +52 -29
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +10 -7
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +9 -14
- package/dist/nodefony/config/config.js +99 -8
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
- package/dist/nodefony/scaffold/contribute.js +19 -0
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +118 -16
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +116 -25
- package/dist/nodefony/service/keycloak.js +208 -0
- package/dist/nodefony/service/oauth2.js +403 -28
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +94 -27
- package/dist/nodefony/service/totp.js +55 -11
- package/dist/nodefony/service/webAuthn.js +24 -7
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +15 -5
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -3
- package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +38 -0
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +28 -12
- package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
- package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
- package/dist/nodefony/src/oauth/providerRoles.js +78 -0
- package/dist/nodefony/src/oauth/providers/github.js +48 -12
- package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
- package/dist/nodefony/src/openToSignup.js +46 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +25 -0
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +89 -48
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +60 -4
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -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-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 +64 -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/ApiKeyError.d.ts +3 -1
- 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/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- 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 +145 -9
- 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/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- 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/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- 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/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -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/scopeGrants.d.ts +77 -0
- 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 +182 -175
- package/docs/audit.md +141 -141
- package/docs/authenticators.md +129 -96
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +77 -72
- package/docs/external-jwt.md +83 -11
- package/docs/firewall.md +119 -83
- package/docs/headers.md +82 -82
- package/docs/index.md +16 -8
- package/docs/keycloak.md +835 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +291 -166
- package/docs/obtenir-un-jeton.md +131 -34
- package/docs/tokens.md +197 -140
- package/docs/totp.md +177 -136
- package/docs/webauthn.md +157 -154
- package/docs/webhooks.md +128 -125
- 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 +19 -17
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
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#AuthFlow.login`).
|
|
70
70
|
|
|
71
71
|
## 📖 Lexique
|
|
72
72
|
|
|
@@ -110,29 +110,29 @@ 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#TotpService`)
|
|
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#TotpService.#build`). 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
|
-
(`ITotpDeps`, `totpOperations.ts
|
|
117
|
+
(`ITotpDeps`, `totpOperations.ts#ITotpDeps`). 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 %.
|
|
119
119
|
|
|
120
120
|
**2. Le secret est CHIFFRÉ, jamais haché.** Un mot de passe se hache (à sens unique) parce que le
|
|
121
121
|
serveur n'a qu'à le **comparer**. Un secret TOTP, lui, doit être **relu en clair** à chaque
|
|
122
122
|
vérification pour recalculer le code → il est **chiffré** en AES-256-GCM
|
|
123
|
-
(`ITotpSecret.secretEnc`, `ITotpSecret.ts
|
|
123
|
+
(`ITotpSecret.secretEnc`, `ITotpSecret.ts#ITotpSecret.secretEnc`). C'est la différence de nature qui commande la
|
|
124
124
|
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#AuthFlow.completeMfaLogin`). 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#ITotpLoginVerifier`). 2FA désactivé
|
|
134
134
|
> ⇒ service absent ⇒ le login nominal **ne paie strictement rien** (`AuthFlow.#resolveTotp()`,
|
|
135
|
-
> `authFlow.ts
|
|
135
|
+
> `authFlow.ts#AuthFlow.#resolveTotp`).
|
|
136
136
|
|
|
137
137
|
## 🚀 Démarrage rapide
|
|
138
138
|
|
|
@@ -143,18 +143,18 @@ 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#SecuritySecrets`) :
|
|
147
147
|
|
|
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#SecuritySecrets.generate`) 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,7 +190,7 @@ 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
|
|
193
|
+
`mountTotpRoutes()` (`TotpController.ts#mountTotpRoutes`) monte quatre routes self-service dès que
|
|
194
194
|
`@nodefony/security` est chargé (le service `totp` est alors toujours enregistré). 2FA désactivé, ou
|
|
195
195
|
service non initialisé : elles répondent `503 2FA unavailable` :
|
|
196
196
|
|
|
@@ -202,17 +202,23 @@ service non initialisé : elles répondent `503 2FA unavailable` :
|
|
|
202
202
|
| `GET /nodefony/security/api/totp/status` | — | `{ enabled, pending, recoveryCodesRemaining }` |
|
|
203
203
|
|
|
204
204
|
Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
|
|
205
|
-
`SessionAuthController.ts
|
|
205
|
+
`SessionAuthController.ts#mountSessionAuthRoutes`) :
|
|
206
206
|
|
|
207
207
|
| Route | Corps | Réponse |
|
|
208
208
|
| --------------------------------------------- | ------------------------ | ------------------------------------------ |
|
|
209
209
|
| `POST /nodefony/security/api/auth/login` | `{ username, password }` | `200` + identité, **ou** `202 mfaRequired` |
|
|
210
210
|
| `POST /nodefony/security/api/auth/login/totp` | `{ code }` | `200` + identité, `401`, ou `429` |
|
|
211
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
|
+
|
|
212
218
|
> [!WARNING]
|
|
213
|
-
> Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts
|
|
219
|
+
> Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts#TotpController`) : elles vivent dans la
|
|
214
220
|
> zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
|
|
215
|
-
> depuis la session (`TotpController.#currentSubject()`, `TotpController.ts
|
|
221
|
+
> depuis la session (`TotpController.#currentSubject()`, `TotpController.ts#TotpController.#currentSubject`) — jamais un
|
|
216
222
|
> paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
|
|
217
223
|
|
|
218
224
|
### 4. Ce qu'on observe
|
|
@@ -291,18 +297,18 @@ sequenceDiagram
|
|
|
291
297
|
|
|
292
298
|
Ce que le code garantit à chaque étape :
|
|
293
299
|
|
|
294
|
-
- **`beginTotpEnrollment()`** (`totpOperations.ts
|
|
300
|
+
- **`beginTotpEnrollment()`** (`totpOperations.ts#beginTotpEnrollment`) écrit le secret **déjà chiffré** avec
|
|
295
301
|
`confirmedAt: null` — l'état « en attente ». Il est **idempotent** : rappeler l'enrôlement écrase
|
|
296
302
|
simplement le précédent non confirmé (l'utilisateur qui a raté son scan recommence, sans support).
|
|
297
|
-
- **`confirmTotpEnrollment()`** (`totpOperations.ts
|
|
303
|
+
- **`confirmTotpEnrollment()`** (`totpOperations.ts#confirmTotpEnrollment`) refuse si aucun enrôlement n'est en cours
|
|
298
304
|
ou s'il est déjà confirmé, et **reste en attente** si le code est faux — aucun état intermédiaire
|
|
299
305
|
bancal.
|
|
300
306
|
- Le pas qui a servi à confirmer est marqué **consommé** (`lastUsedStep: res.step`,
|
|
301
|
-
`totpOperations.ts
|
|
307
|
+
`totpOperations.ts#confirmTotpEnrollment`) : le code de confirmation n'est pas rejouable comme premier code de login.
|
|
302
308
|
|
|
303
309
|
> [!TIP]
|
|
304
310
|
> Le HTTP ne laisse jamais fuir le détail : code faux, enrôlement absent, ou déjà confirmé donnent
|
|
305
|
-
> 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#TotpController.confirm`).
|
|
306
312
|
> La cause fine reste côté serveur.
|
|
307
313
|
|
|
308
314
|
### La vérification au login
|
|
@@ -334,22 +340,22 @@ sequenceDiagram
|
|
|
334
340
|
A-->>U: 200 { user }
|
|
335
341
|
```
|
|
336
342
|
|
|
337
|
-
Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts
|
|
343
|
+
Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts#AuthFlow.completeMfaLogin`) :
|
|
338
344
|
|
|
339
345
|
1. **Le défi vit en session, pas dans l'URL ni dans un jeton client** — clé `mfa:pending`
|
|
340
|
-
(`authFlow.ts
|
|
341
|
-
(`authFlow.ts
|
|
346
|
+
(`authFlow.ts#AuthFlow.completeMfaLogin`), posée par le login, **consommée** avant l'ouverture de session
|
|
347
|
+
(`authFlow.ts#AuthFlow.completeMfaLogin`).
|
|
342
348
|
2. **Le code à 6 chiffres est throttlé** comme un mot de passe — même backoff partagé
|
|
343
|
-
(`AuthFlow.#resolveThrottler()`, `authFlow.ts
|
|
349
|
+
(`AuthFlow.#resolveThrottler()`, `authFlow.ts#AuthFlow.#resolveThrottler`) : 10⁶ combinaisons se forcent brute en
|
|
344
350
|
quelques minutes sans lui. Trop de tentatives → `429` + `Retry-After`.
|
|
345
351
|
3. **Un échec ne détruit pas le défi** — l'utilisateur qui s'est trompé de chiffre ressaisit ; il
|
|
346
352
|
n'a pas à refaire son mot de passe.
|
|
347
353
|
|
|
348
354
|
### La fenêtre de dérive et l'anti-rejeu
|
|
349
355
|
|
|
350
|
-
Les deux horloges ne sont jamais parfaitement synchrones. `verifyTotp()` (`totpCrypto.ts
|
|
356
|
+
Les deux horloges ne sont jamais parfaitement synchrones. `verifyTotp()` (`totpCrypto.ts#verifyTotp`)
|
|
351
357
|
balaie donc les tranches `T-window … T+window` et compare **en temps constant**
|
|
352
|
-
(`timingSafeEqual`, `totpCrypto.ts
|
|
358
|
+
(`timingSafeEqual`, `totpCrypto.ts#verifyTotp`) — une comparaison naïve fuirait le préfixe correct par le
|
|
353
359
|
temps de réponse.
|
|
354
360
|
|
|
355
361
|
| `window` | Tolérance réelle | Codes acceptés simultanément | Verdict |
|
|
@@ -359,8 +365,8 @@ temps de réponse.
|
|
|
359
365
|
| `2` | ±60 s | 5 | Surface d'attaque ×1,7 pour peu de gain. |
|
|
360
366
|
|
|
361
367
|
Le contrepoids obligatoire, c'est l'**anti-rejeu** : la tranche qui a validé est mémorisée
|
|
362
|
-
(`ITotpSecret.lastUsedStep`, `ITotpSecret.ts
|
|
363
|
-
consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts
|
|
368
|
+
(`ITotpSecret.lastUsedStep`, `ITotpSecret.ts#ITotpSecret.lastUsedStep`), et tout code d'une tranche **≤** à la dernière
|
|
369
|
+
consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts#verifyTotpLogin`). Un code intercepté —
|
|
364
370
|
épaule, proxy, phishing en temps réel — est donc **mort dès qu'il a servi une fois**.
|
|
365
371
|
|
|
366
372
|
> [!WARNING]
|
|
@@ -373,52 +379,52 @@ consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts:173`). Un
|
|
|
373
379
|
|
|
374
380
|
La valeur de `totp.encryptionKey` est une chaîne d'application : passphrase, hex, base64, longueur
|
|
375
381
|
quelconque. AES-256 exige exactement **32 octets de haute entropie**. `deriveKey()`
|
|
376
|
-
(`secretCipher.ts
|
|
382
|
+
(`secretCipher.ts#deriveKey`) passe donc le matériel dans **HKDF-SHA256** (RFC 5869) :
|
|
377
383
|
|
|
378
384
|
- **Déterministe** — tous les pods d'un cluster dérivent la **même** clé du même secret : un secret
|
|
379
385
|
écrit par un pod se relit par les autres, sans réplication de clé.
|
|
380
386
|
- **Séparation de domaine** — chaque brique dérive avec un `salt`/`info` distinct. Le contexte TOTP
|
|
381
|
-
est figé (`TOTP_DERIVATION`, `totpCipher.ts
|
|
387
|
+
est figé (`TOTP_DERIVATION`, `totpCipher.ts#TOTP_DERIVATION`) : un blob de webhook ne se déchiffre **jamais**
|
|
382
388
|
avec la clé TOTP, par construction, même si la clé maître de config est la même.
|
|
383
389
|
- **Longueur libre en entrée** — une passphrase courte ne devient jamais une clé AES faible.
|
|
384
390
|
|
|
385
391
|
### Le format du blob
|
|
386
392
|
|
|
387
|
-
`encryptSecret()` (`secretCipher.ts
|
|
393
|
+
`encryptSecret()` (`secretCipher.ts#encryptSecret`) produit une chaîne opaque, préfixée par sa version :
|
|
388
394
|
|
|
389
395
|
```
|
|
390
396
|
gcm1.<base64url( iv‖tag‖ciphertext )>
|
|
391
397
|
└ 12 o ┘└16 o┘
|
|
392
398
|
```
|
|
393
399
|
|
|
394
|
-
- **IV de 12 octets tiré à chaque chiffrement** (`secretCipher.ts
|
|
400
|
+
- **IV de 12 octets tiré à chaque chiffrement** (`secretCipher.ts#IV_BYTES`) — deux enrôlements du même
|
|
395
401
|
secret donnent deux blobs différents.
|
|
396
402
|
- **GCM = chiffrement authentifié** : le tag de 16 octets fait échouer `decryptSecret()`
|
|
397
|
-
(`secretCipher.ts
|
|
403
|
+
(`secretCipher.ts#decryptSecret`) si le blob a été altéré **ou** si la clé est la mauvaise. GCM ne distingue
|
|
398
404
|
pas les deux cas, par construction — toute manipulation du secret stocké est donc détectée.
|
|
399
405
|
- **Préfixe versionné `gcm1`** : une rotation d'algorithme future pourra cohabiter avec les secrets
|
|
400
406
|
existants.
|
|
401
407
|
|
|
402
408
|
Le store, lui, ne voit que des octets : il ne déchiffre jamais rien (`ITotpSecret.secretEnc`,
|
|
403
|
-
`ITotpSecret.ts
|
|
409
|
+
`ITotpSecret.ts#ITotpSecret.secretEnc`).
|
|
404
410
|
|
|
405
411
|
### La politique de clé — bruyante en dev, fail-closed en production
|
|
406
412
|
|
|
407
|
-
`TotpService.#resolveKey()` (`totp.ts
|
|
413
|
+
`TotpService.#resolveKey()` (`totp.ts#TotpService.#resolveKey`) tranche au boot :
|
|
408
414
|
|
|
409
|
-
| Situation | Environnement | Comportement
|
|
410
|
-
| ---------------------------- | ------------- |
|
|
411
|
-
| `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts
|
|
412
|
-
| Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts
|
|
413
|
-
| Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts
|
|
414
|
-
| `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot.
|
|
415
|
+
| Situation | Environnement | Comportement |
|
|
416
|
+
| ---------------------------- | ------------- | ------------------------------------------------------------------- |
|
|
417
|
+
| `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts#TotpService.#resolveKey`). |
|
|
418
|
+
| Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts#TotpService.#resolveKey`). |
|
|
419
|
+
| Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts#TotpService.#resolveKey`). |
|
|
420
|
+
| `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot. |
|
|
415
421
|
|
|
416
422
|
Le refus en production est délibéré : une clé éphémère chiffrerait des secrets **illisibles au
|
|
417
423
|
redémarrage suivant** et sur les autres pods — les utilisateurs seraient enfermés dehors, sans
|
|
418
424
|
message. Mieux vaut un 2FA absent et bruyant qu'un 2FA qui casse silencieusement en pleine nuit.
|
|
419
425
|
|
|
420
426
|
> [!CAUTION]
|
|
421
|
-
> Ne **jamais** modifier `TOTP_DERIVATION` (`totpCipher.ts
|
|
427
|
+
> Ne **jamais** modifier `TOTP_DERIVATION` (`totpCipher.ts#TOTP_DERIVATION`). Changer son sel ou son `info` rend
|
|
422
428
|
> illisibles **tous** les secrets déjà stockés — chaque utilisateur devra ré-enrôler.
|
|
423
429
|
|
|
424
430
|
## Les codes de récupération — perdre son téléphone
|
|
@@ -426,24 +432,24 @@ message. Mieux vaut un 2FA absent et bruyant qu'un 2FA qui casse silencieusement
|
|
|
426
432
|
Un second facteur crée un risque neuf : **s'enfermer dehors**. Les codes de récupération sont la
|
|
427
433
|
sortie de secours — le NIST les classe comme _look-up secrets_ (SP 800-63B §5.1.2).
|
|
428
434
|
|
|
429
|
-
**Comment ils sont fabriqués** (`generateRecoveryCodes()`, `totpCrypto.ts
|
|
435
|
+
**Comment ils sont fabriqués** (`generateRecoveryCodes()`, `totpCrypto.ts#generateRecoveryCodes`) :
|
|
430
436
|
|
|
431
437
|
- 10 codes par défaut (`totp.recoveryCodes`), au format lisible `XXXXX-XXXXX` ;
|
|
432
|
-
- alphabet **sans caractères ambigus** — ni `I`, ni `L`, ni `O`, ni `U` (`totpCrypto.ts
|
|
438
|
+
- alphabet **sans caractères ambigus** — ni `I`, ni `L`, ni `O`, ni `U` (`totpCrypto.ts#unbiasedIndices`) : on les
|
|
433
439
|
recopie à la main, souvent sous stress ;
|
|
434
440
|
- ~50 bits d'aléa chacun — non devinable, mais ce n'est **pas** un mot de passe humain.
|
|
435
441
|
|
|
436
|
-
**Comment ils sont stockés** : en condensat `sha256` (`hashRecoveryCode()`, `totpCrypto.ts
|
|
442
|
+
**Comment ils sont stockés** : en condensat `sha256` (`hashRecoveryCode()`, `totpCrypto.ts#hashRecoveryCode`),
|
|
437
443
|
jamais en clair. Un `sha256` simple suffit ici, précisément parce que l'entrée est **aléatoire** (une
|
|
438
444
|
attaque par dictionnaire n'a rien à mordre) — contrairement à un mot de passe, qui exige Argon2id.
|
|
439
445
|
|
|
440
446
|
**Comment ils sont consommés** : au login, si le code présenté n'est pas un TOTP valide,
|
|
441
447
|
`verifyTotpLogin()` cherche une correspondance parmi les condensats — **en temps constant sur chaque
|
|
442
448
|
entrée**, et sans court-circuit à la première trouvaille (`matchRecoveryCode()`,
|
|
443
|
-
`totpCrypto.ts
|
|
449
|
+
`totpCrypto.ts#matchRecoveryCode`). Le code trouvé est **retiré de la liste** (`totpOperations.ts#verifyTotpLogin`) : usage
|
|
444
450
|
unique, strictement.
|
|
445
451
|
|
|
446
|
-
La saisie est tolérante — casse et tirets ignorés à la normalisation (`totpCrypto.ts
|
|
452
|
+
La saisie est tolérante — casse et tirets ignorés à la normalisation (`totpCrypto.ts#unbiasedIndices`) :
|
|
447
453
|
`k7m2p9xq4r` vaut `K7M2P-9XQ4R`.
|
|
448
454
|
|
|
449
455
|
> [!TIP]
|
|
@@ -453,20 +459,20 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
|
|
|
453
459
|
|
|
454
460
|
## ⚙️ Configuration et mises en situation
|
|
455
461
|
|
|
456
|
-
La section `totp` du schéma Zod (`config.ts
|
|
462
|
+
La section `totp` du schéma Zod (`config.ts#securityConfigSchema`) — validée au boot, donc une valeur hors bornes
|
|
457
463
|
échoue **au démarrage**, pas au premier login :
|
|
458
464
|
|
|
459
|
-
| Option | Type | Défaut | Effet
|
|
460
|
-
| --------------- | ---------------------------- | ------ |
|
|
461
|
-
| `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts
|
|
462
|
-
| `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app.
|
|
463
|
-
| `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts
|
|
464
|
-
| `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum).
|
|
465
|
-
| `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes.
|
|
466
|
-
| `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts
|
|
467
|
-
| `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts
|
|
468
|
-
| `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts
|
|
469
|
-
| `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#TotpService.#build`). |
|
|
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#totpSchema`). |
|
|
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#totpSchema`). |
|
|
473
|
+
| `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts#passkeysSchema`). |
|
|
474
|
+
| `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts#totpSchema`). |
|
|
475
|
+
| `store` | `string` | `auto` | Backend de persistance du secret (`config.ts#passkeysSchema`). |
|
|
470
476
|
|
|
471
477
|
### Situation 1 — un utilisateur active la 2FA sur son compte
|
|
472
478
|
|
|
@@ -475,7 +481,7 @@ self-service sont déjà là, la console Studio les consomme déjà (`/nodefony/
|
|
|
475
481
|
|
|
476
482
|
```typescript
|
|
477
483
|
use("@nodefony/security", {
|
|
478
|
-
totp: { encryptionKey:
|
|
484
|
+
totp: { encryptionKey: ctx.env.NF_TOTP_KEY },
|
|
479
485
|
});
|
|
480
486
|
```
|
|
481
487
|
|
|
@@ -492,8 +498,24 @@ Le second facteur validé au login ne dit rien de **qui est devant l'écran dix
|
|
|
492
498
|
(poste laissé ouvert, session volée). Pour une suppression de compte ou une rotation de clés, on
|
|
493
499
|
redemande le code : c'est le _sudo mode_.
|
|
494
500
|
|
|
495
|
-
|
|
496
|
-
|
|
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#TotpService.verifyLogin`) :
|
|
497
519
|
|
|
498
520
|
```typescript
|
|
499
521
|
import { controller, Controller, Post } from "@nodefony/framework";
|
|
@@ -504,7 +526,7 @@ class DangerController extends Controller {
|
|
|
504
526
|
@Post("/delete")
|
|
505
527
|
async remove() {
|
|
506
528
|
const totp = this.get<TotpService>("totp");
|
|
507
|
-
const code = (this.
|
|
529
|
+
const code = (this.body as { code?: unknown }).code;
|
|
508
530
|
// Zone protégée : le firewall a déjà authentifié. On exige la PREUVE FRAÎCHE.
|
|
509
531
|
if (!totp?.isEnabled() || typeof code !== "string") {
|
|
510
532
|
return this.renderJson({ error: "2FA required" }, 403);
|
|
@@ -535,7 +557,7 @@ totp: { algorithm: "SHA512", digits: 8 }, // ❌ Google Authenticator ignore c
|
|
|
535
557
|
```
|
|
536
558
|
|
|
537
559
|
L'URI `otpauth://` transporte bien `algorithm` et `digits` (`buildOtpauthUri()`,
|
|
538
|
-
`totpCrypto.ts
|
|
560
|
+
`totpCrypto.ts#buildOtpauthUri`), mais plusieurs applications grand public les **ignorent** et calculent en
|
|
539
561
|
`SHA1`/6 chiffres. Résultat : le QR est scanné, l'app affiche un code… systématiquement refusé, sans
|
|
540
562
|
que rien ne semble anormal. Le gain de sécurité réel est par ailleurs nul — l'anti-rejeu et le
|
|
541
563
|
throttle bornent déjà les tentatives bien avant l'espace des codes.
|
|
@@ -543,10 +565,23 @@ throttle bornent déjà les tentatives bien avant l'espace des codes.
|
|
|
543
565
|
## L'entité de persistance — un secret par utilisateur
|
|
544
566
|
|
|
545
567
|
Le modèle est volontairement minimal : **clé naturelle = `userId`**, `save` est un upsert
|
|
546
|
-
(`ITotpSecretStore`, `ITotpSecretStore.ts
|
|
568
|
+
(`ITotpSecretStore`, `ITotpSecretStore.ts#ITotpSecretStore`). Pas d'identifiant de ligne, pas d'index secondaire —
|
|
547
569
|
tout accès passe par la clé primaire.
|
|
548
570
|
|
|
549
|
-
|
|
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#resolveSubject`).
|
|
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#cascadeUserRevocation`). 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
|
+
|
|
584
|
+
Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts#TOTP_SECRET_TABLE_SPEC`), déclinée par
|
|
550
585
|
dialecte via le colKit :
|
|
551
586
|
|
|
552
587
|
| Colonne | Rôle | SQLite | PostgreSQL | MySQL / MariaDB |
|
|
@@ -565,18 +600,18 @@ dialecte via le colKit :
|
|
|
565
600
|
Deux pièges de lecture, signalés dans l'entité elle-même :
|
|
566
601
|
|
|
567
602
|
- `lastUsedStep` est un **numéro de tranche RFC 6238**, pas un horodatage — d'où le type `int`
|
|
568
|
-
partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts
|
|
603
|
+
partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts#TotpSecretRow.algorithm`).
|
|
569
604
|
- Un epoch en millisecondes **déborde** un `integer` 32 bits → `bigint` en PostgreSQL et MySQL
|
|
570
605
|
(SQLite, lui, a des INTEGER 64 bits).
|
|
571
606
|
|
|
572
607
|
### Les backends disponibles
|
|
573
608
|
|
|
574
|
-
| Backend | Enregistré par
|
|
575
|
-
| ---------- |
|
|
576
|
-
| `memory` | builtin (`totpSecretStoreRegistry.ts
|
|
577
|
-
| `drizzle` | `@nodefony/drizzle` (`registerStores.ts
|
|
578
|
-
| `mongoose` | `@nodefony/mongoose` (`registerStores.ts`)
|
|
579
|
-
| `redis` | —
|
|
609
|
+
| Backend | Enregistré par | Durabilité | État |
|
|
610
|
+
| ---------- | ------------------------------------------------------------------------ | ----------------------------------- | -------------------- |
|
|
611
|
+
| `memory` | builtin (`totpSecretStoreRegistry.ts`) | **volatile** — perdu au redémarrage | ✅ dev / tests |
|
|
612
|
+
| `drizzle` | `@nodefony/drizzle` (`registerStores.ts#registerDrizzleFrameworkStores`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
|
|
613
|
+
| `mongoose` | `@nodefony/mongoose` (`registerStores.ts`) | durable, partagé entre pods | ✅ MongoDB |
|
|
614
|
+
| `redis` | — | — | ⏳ manquant, à venir |
|
|
580
615
|
|
|
581
616
|
Une application peut donc activer le 2FA **entièrement sur MongoDB**, sans charger d'ORM SQL : une
|
|
582
617
|
application choisit son ORM, elle ne choisit pas de se passer du second facteur.
|
|
@@ -597,12 +632,12 @@ primaire (`_id` = `userId`) — donc l'unicité existe dès le premier document,
|
|
|
597
632
|
en tâche de fond.
|
|
598
633
|
|
|
599
634
|
Côté `drizzle`, les **trois dialectes** sont portés — `TOTP_PORTED` vaut l'ensemble des dialectes
|
|
600
|
-
(`registerStores.ts
|
|
601
|
-
tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts
|
|
635
|
+
(`registerStores.ts#TOTP_PORTED`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
|
|
636
|
+
tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts#DrizzleTotpSecretStore`)
|
|
602
637
|
— c'est ce qui rend la portabilité gratuite.
|
|
603
638
|
|
|
604
639
|
**Comment le backend est choisi.** `store: "auto"` (le défaut) suit l'infra déclarée puis les
|
|
605
|
-
adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts
|
|
640
|
+
adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts#TotpService.#resolveStore`) :
|
|
606
641
|
|
|
607
642
|
1. `NF_STORE` (override global de banc de charge), s'il est enregistré ici ;
|
|
608
643
|
2. infra base de données déclarée (`NF_DATABASE_URL`) → `drizzle` ;
|
|
@@ -610,7 +645,7 @@ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:137`) :
|
|
|
610
645
|
4. sinon **repli `memory`, annoncé** — jamais silencieux.
|
|
611
646
|
|
|
612
647
|
Un `store` **explicite** introuvable, en revanche, ne se replie pas : `CRITIC` en dev, boot avorté en
|
|
613
|
-
production (`totp.ts
|
|
648
|
+
production (`totp.ts#TotpService.#resolveStore`). Une faute de frappe ne dégrade jamais la sécurité en douce.
|
|
614
649
|
|
|
615
650
|
## Le listing paginé des enrôlements
|
|
616
651
|
|
|
@@ -618,48 +653,48 @@ Question d'exploitation : « quelle est la **couverture** 2FA, et qui est resté
|
|
|
618
653
|
confirmation ? » Un secret jamais confirmé ne protège personne, et c'est invisible depuis la fiche
|
|
619
654
|
d'un seul utilisateur.
|
|
620
655
|
|
|
621
|
-
`ITotpSecretStore.listPage()` (`ITotpSecretStore.ts
|
|
656
|
+
`ITotpSecretStore.listPage()` (`ITotpSecretStore.ts#ITotpSecretStore.listPage`) répond, avec trois garanties :
|
|
622
657
|
|
|
623
658
|
- **pagination native au store** — jamais de parcours complet en mémoire ; l'ordre est contractuel
|
|
624
659
|
(`createdAt` DESC, départagé par `userId` ASC) ;
|
|
625
660
|
- **filtres appliqués côté backend** — `confirmed` (activés / en attente) et `q` (préfixe d'`userId`,
|
|
626
|
-
donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts
|
|
627
|
-
- **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts
|
|
661
|
+
donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts#DrizzleTotpSecretStore.#listCriteria`) ;
|
|
662
|
+
- **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts#ITotpEnrollmentSummary`)
|
|
628
663
|
n'a ni `secretEnc` ni les condensats de récupération, seulement leur **nombre**
|
|
629
664
|
(`recoveryCodesLeft`).
|
|
630
665
|
|
|
631
666
|
Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'affichage : quel que
|
|
632
667
|
soit le backend, ces champs ne peuvent pas remonter par ce chemin, même si un appelant les demandait
|
|
633
|
-
(`toTotpEnrollment()`, `MemoryTotpSecretStore.ts
|
|
668
|
+
(`toTotpEnrollment()`, `MemoryTotpSecretStore.ts#toTotpEnrollment`). C'est ce qu'exerce le banc de contrat partagé.
|
|
634
669
|
|
|
635
|
-
`countEnrollments()` (`ITotpSecretStore.ts
|
|
670
|
+
`countEnrollments()` (`ITotpSecretStore.ts#ITotpSecretStore.countEnrollments`) donne le KPI de couverture sans énumérer une seule
|
|
636
671
|
ligne.
|
|
637
672
|
|
|
638
673
|
## 🧰 API publique
|
|
639
674
|
|
|
640
675
|
Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.ai/symbols.json`.
|
|
641
676
|
|
|
642
|
-
**Le service** (`TotpService`, `totp.ts
|
|
677
|
+
**Le service** (`TotpService`, `totp.ts#TotpService`), résolu par nom dans le container (`"totp"`) :
|
|
643
678
|
|
|
644
|
-
| Méthode
|
|
645
|
-
|
|
|
646
|
-
| `isEnabled()` (`totp.ts
|
|
647
|
-
| `beginEnrollment()` (`totp.ts
|
|
648
|
-
| `confirmEnrollment()` (`totp.ts
|
|
649
|
-
| `verifyLogin()` (`totp.ts
|
|
650
|
-
| `disable()` (`totp.ts
|
|
651
|
-
| `status()` (`totp.ts
|
|
652
|
-
| `isEnabledFor()` (`totp.ts
|
|
653
|
-
| `listPage()` (`totp.ts
|
|
654
|
-
| `countEnrollments()` (`totp.ts
|
|
679
|
+
| Méthode | Rôle |
|
|
680
|
+
| --------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
681
|
+
| `isEnabled()` (`totp.ts#TotpService.isEnabled`) | 2FA opérationnel (activé en config **et** boot réussi). |
|
|
682
|
+
| `beginEnrollment()` (`totp.ts#TotpService.beginEnrollment`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
|
|
683
|
+
| `confirmEnrollment()` (`totp.ts#TotpService.confirmEnrollment`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
|
|
684
|
+
| `verifyLogin()` (`totp.ts#TotpService.verifyLogin`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
|
|
685
|
+
| `disable()` (`totp.ts#TotpService.disable`) | Retire secret et codes. |
|
|
686
|
+
| `status()` (`totp.ts#TotpService.status`) | `{ enabled, pending, recoveryCodesRemaining }`. |
|
|
687
|
+
| `isEnabledFor()` (`totp.ts#TotpService.isEnabledFor`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
|
|
688
|
+
| `listPage()` (`totp.ts#TotpService.listPage`) | Page d'enrôlements (data plane admin). |
|
|
689
|
+
| `countEnrollments()` (`totp.ts#TotpService.countEnrollments`) | Compte filtré, sans énumération. |
|
|
655
690
|
|
|
656
691
|
**Les opérations pures**, si tu veux le 2FA **sans** le service (test, script, autre transport) —
|
|
657
692
|
elles prennent leurs dépendances en argument : `beginTotpEnrollment()`, `confirmTotpEnrollment()`,
|
|
658
|
-
`verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts
|
|
693
|
+
`verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts#totpStatus`).
|
|
659
694
|
|
|
660
695
|
**Les primitives crypto**, pour écrire un client ou un banc de test : `totpCode()`
|
|
661
|
-
(`totpCrypto.ts
|
|
662
|
-
(`totpCipher.ts
|
|
696
|
+
(`totpCrypto.ts#totpCode`), `base32Decode()` (`totpCrypto.ts#base32Decode`), `deriveTotpKey()`
|
|
697
|
+
(`totpCipher.ts#deriveTotpKey`).
|
|
663
698
|
|
|
664
699
|
```typescript
|
|
665
700
|
// Calculer le code attendu côté « application d'authentification » — exactement
|
|
@@ -671,7 +706,7 @@ const code = totpCode(base32Decode(secretBase32), { epochMs: Date.now() });
|
|
|
671
706
|
## 🧩 Extension — brancher son propre store
|
|
672
707
|
|
|
673
708
|
Le registre découple le cœur du backend : implémente `ITotpSecretStore`
|
|
674
|
-
(`ITotpSecretStore.ts
|
|
709
|
+
(`ITotpSecretStore.ts#ITotpSecretStore`), enregistre la fabrique, sélectionne-la en config.
|
|
675
710
|
|
|
676
711
|
```typescript
|
|
677
712
|
import { registerTotpStore, type ITotpSecretStore } from "@nodefony/security";
|
|
@@ -689,19 +724,19 @@ vérifie que ta projection n'expose ni secret ni condensat.
|
|
|
689
724
|
|
|
690
725
|
## 📜 Normes appliquées
|
|
691
726
|
|
|
692
|
-
| Domaine | Norme | Ancrage dans le code
|
|
693
|
-
| --------------------------- | ------------------------ |
|
|
694
|
-
| TOTP (algorithme) | RFC 6238 §4 | `totpCode()` (`totpCrypto.ts
|
|
695
|
-
| HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts
|
|
696
|
-
| Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts
|
|
697
|
-
| Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts
|
|
698
|
-
| Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts
|
|
699
|
-
| Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts
|
|
700
|
-
| Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts
|
|
701
|
-
| Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts
|
|
702
|
-
| Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts
|
|
703
|
-
| Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts
|
|
704
|
-
| 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#totpCode`) |
|
|
730
|
+
| HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts#hotp`), masque `0x7f` (`totpCrypto.ts#hotp`) |
|
|
731
|
+
| Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts#TOTP_DEFAULTS`) |
|
|
732
|
+
| Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts#verifyTotp`) |
|
|
733
|
+
| Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts#verifyTotpLogin`) |
|
|
734
|
+
| Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts#base32Encode`) |
|
|
735
|
+
| Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts#deriveKey`) |
|
|
736
|
+
| Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts#IV_BYTES`) |
|
|
737
|
+
| Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts#generateRecoveryCodes`) |
|
|
738
|
+
| Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts#AuthFlow.completeMfaLogin`) |
|
|
739
|
+
| Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts#SessionAuthController.me`) |
|
|
705
740
|
|
|
706
741
|
Les **vecteurs de test de la RFC 6238 (Appendix B)** sont rejoués en test sur les trois fonctions de
|
|
707
742
|
hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
|
|
@@ -710,18 +745,18 @@ hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
|
|
|
710
745
|
|
|
711
746
|
Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas.
|
|
712
747
|
|
|
713
|
-
- **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#AuthFlow.#resolveTotp`)
|
|
714
749
|
résout le service **une seule fois** puis met le résultat en cache. Service absent ⇒ `null` ⇒
|
|
715
750
|
**zéro accès au store**, zéro allocation par login.
|
|
716
751
|
- **Aucun coût par requête** : le TOTP n'est pas un authenticator du firewall, il ne s'exécute donc
|
|
717
752
|
jamais dans le pipeline HTTP/WS.
|
|
718
753
|
- **Allocation paresseuse du store** : la `Map` de `MemoryTotpSecretStore`
|
|
719
|
-
(`MemoryTotpSecretStore.ts
|
|
720
|
-
quand `totp.enabled` est `false` (`totp.ts
|
|
754
|
+
(`MemoryTotpSecretStore.ts#MemoryTotpSecretStore.#byUser`) n'existe que si le 2FA est activé — le service ne construit rien
|
|
755
|
+
quand `totp.enabled` est `false` (`totp.ts#TotpService.#build`).
|
|
721
756
|
- **Le coût réel d'une vérification** : ≤ `2·window + 1` HMAC (3 par défaut) + un déchiffrement
|
|
722
757
|
AES-GCM. De l'ordre de la microseconde — négligeable devant le hachage Argon2id du mot de passe
|
|
723
758
|
qui l'a précédé.
|
|
724
|
-
- **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts
|
|
759
|
+
- **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts#TotpService.#shutdown`)
|
|
725
760
|
le déclenche à `onTerminate` — aucune écriture en attente perdue.
|
|
726
761
|
|
|
727
762
|
## 📡 Observabilité — Studio
|
|
@@ -735,37 +770,43 @@ Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas
|
|
|
735
770
|
|
|
736
771
|
Le data plane admin correspondant, gardé par `ROLE_NODEFONY_ADMIN` :
|
|
737
772
|
|
|
738
|
-
- `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#createSecurityAdminApi`).
|
|
739
775
|
Réponse **honnête** si le 2FA est désactivé : `{ enabled: false, items: [] }`, jamais une erreur —
|
|
740
776
|
la console doit pouvoir afficher « 2FA désactivé ».
|
|
741
|
-
- `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#createSecurityAdminApi`).
|
|
742
778
|
- `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
|
|
743
|
-
(`SecurityAdminApi.ts
|
|
779
|
+
(`SecurityAdminApi.ts#createSecurityAdminApi`).
|
|
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.
|
|
744
783
|
|
|
745
784
|
L'admin peut **désactiver**, jamais **activer** pour autrui : le secret se scanne sur l'appareil de
|
|
746
785
|
l'utilisateur, lui seul peut l'armer.
|
|
747
786
|
|
|
748
|
-
Côté journal d'audit,
|
|
749
|
-
`login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts
|
|
750
|
-
`reason: "mfa_invalid"` (`authFlow.ts
|
|
751
|
-
(`
|
|
787
|
+
Côté journal d'audit, cinq actions tracent le cycle : `login.mfa_required` (`authFlow.ts#AuthFlow.login`),
|
|
788
|
+
`login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts#AuthFlow.login`), `login.failure` avec
|
|
789
|
+
`reason: "mfa_invalid"` (`authFlow.ts#AuthFlow.completeMfaLogin`), `user.totp_enabled` quand le titulaire active son
|
|
790
|
+
facteur (`totp.ts#TotpService.confirmEnrollment`), et `user.totp_disabled` — par le titulaire (`totp.ts#TotpService.disable`,
|
|
791
|
+
`viaAdmin: false`) ou par un administrateur (`SecurityAdminApi.ts#createSecurityAdminApi`, `viaAdmin: true`). Les
|
|
792
|
+
deux gestes du titulaire portent l'IP, le navigateur et l'identifiant de la requête.
|
|
752
793
|
|
|
753
794
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
754
795
|
|
|
755
|
-
| Symptôme
|
|
756
|
-
|
|
|
757
|
-
| 2FA inactif en production, `CRITIC` au boot
|
|
758
|
-
| Tous les secrets illisibles après déploiement
|
|
759
|
-
| Secrets perdus à chaque redémarrage
|
|
760
|
-
| Code « juste » systématiquement refusé
|
|
761
|
-
| Le QR est scanné mais aucun code ne passe
|
|
762
|
-
| `202` au login au lieu de `200`
|
|
763
|
-
|
|
|
764
|
-
| `429` pendant la saisie du code
|
|
765
|
-
| `503 2FA unavailable` sur `…/totp/*`
|
|
766
|
-
| Utilisateur bloqué, plus aucun code
|
|
767
|
-
| Même code accepté deux fois
|
|
768
|
-
| 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#TotpService.#resolveKey`) | `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#AuthFlow.login`) | Terminer le step-up |
|
|
805
|
+
| `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts#AuthFlow.completeMfaLogin`) | Respecter `Retry-After` — attendu sous attaque |
|
|
806
|
+
| `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts#ITotpManager.isEnabled`) | 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#verifyTotpLogin`) | — |
|
|
809
|
+
| Code de récupération réutilisable | Impossible — retiré du stock à l'usage (`totpOperations.ts#verifyTotpLogin`) | Régénérer un lot en ré-enrôlant si le stock est bas |
|
|
769
810
|
|
|
770
811
|
## 🧪 Tests & couverture
|
|
771
812
|
|