@nodefony/security 10.0.0-alpha.1
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/LICENSE +544 -0
- package/README.md +182 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +151 -0
- package/dist/nodefony/command/security-secrets.js +158 -0
- package/dist/nodefony/command/security-token.js +335 -0
- package/dist/nodefony/command/security-user-add.js +131 -0
- package/dist/nodefony/command/security-user-delete.js +102 -0
- package/dist/nodefony/command/security-user-list.js +77 -0
- package/dist/nodefony/config/config.js +366 -0
- package/dist/nodefony/config/defineModuleConfig.js +35 -0
- package/dist/nodefony/contracts/IAccessVoter.js +13 -0
- package/dist/nodefony/contracts/IApiKey.js +1 -0
- package/dist/nodefony/contracts/IAuditEvent.js +1 -0
- package/dist/nodefony/contracts/IAuditStore.js +1 -0
- package/dist/nodefony/contracts/IAuthenticator.js +1 -0
- package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
- package/dist/nodefony/contracts/IFirewall.js +1 -0
- package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
- package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
- package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
- package/dist/nodefony/contracts/ISecuredArea.js +1 -0
- package/dist/nodefony/contracts/IToken.js +1 -0
- package/dist/nodefony/contracts/ITokenStore.js +1 -0
- package/dist/nodefony/contracts/ITotpSecret.js +1 -0
- package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
- package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
- package/dist/nodefony/contracts/IWebhookStore.js +1 -0
- package/dist/nodefony/contracts/index.js +2 -0
- package/dist/nodefony/errors/AccessDeniedError.js +14 -0
- package/dist/nodefony/errors/ApiKeyError.js +21 -0
- package/dist/nodefony/errors/AuthenticationError.js +14 -0
- package/dist/nodefony/errors/CsrfError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +39 -0
- package/dist/nodefony/errors/SsrfError.js +17 -0
- package/dist/nodefony/errors/ThrottledError.js +21 -0
- package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
- package/dist/nodefony/errors/WebAuthnError.js +21 -0
- package/dist/nodefony/errors/index.js +9 -0
- package/dist/nodefony/service/accessTokenVerifier.js +77 -0
- package/dist/nodefony/service/apiKeys.js +310 -0
- package/dist/nodefony/service/auditService.js +145 -0
- package/dist/nodefony/service/authFlow.js +332 -0
- package/dist/nodefony/service/authorization.js +95 -0
- package/dist/nodefony/service/cors.js +81 -0
- package/dist/nodefony/service/csrf.js +97 -0
- package/dist/nodefony/service/firewall.js +699 -0
- package/dist/nodefony/service/oauth2.js +153 -0
- package/dist/nodefony/service/securityHeaders.js +80 -0
- package/dist/nodefony/service/tokenService.js +486 -0
- package/dist/nodefony/service/totp.js +209 -0
- package/dist/nodefony/service/webAuthn.js +343 -0
- package/dist/nodefony/service/webhooks.js +539 -0
- package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
- package/dist/nodefony/src/SecuredArea.js +51 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
- package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
- package/dist/nodefony/src/admin/adminAudit.js +37 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
- package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
- package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
- package/dist/nodefony/src/audit/auditBridge.js +82 -0
- package/dist/nodefony/src/audit/auditFilters.js +60 -0
- package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
- package/dist/nodefony/src/audit/readAuditContext.js +24 -0
- package/dist/nodefony/src/audit/recordAudit.js +16 -0
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
- package/dist/nodefony/src/authenticator/bearer.js +2 -0
- package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
- package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
- package/dist/nodefony/src/crypto/secretCipher.js +79 -0
- package/dist/nodefony/src/csp.js +54 -0
- package/dist/nodefony/src/csrfToken.js +65 -0
- package/dist/nodefony/src/net/ssrfGuard.js +130 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
- package/dist/nodefony/src/oauth/providers/github.js +65 -0
- package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
- package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
- package/dist/nodefony/src/sessionIdentity.js +35 -0
- package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
- package/dist/nodefony/src/token/AnonymousToken.js +40 -0
- package/dist/nodefony/src/token/JwtKeystore.js +160 -0
- package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
- package/dist/nodefony/src/token/UserToken.js +67 -0
- package/dist/nodefony/src/token/jwtRuntime.js +19 -0
- package/dist/nodefony/src/token/secretFile.js +134 -0
- package/dist/nodefony/src/token/tokenCriteria.js +35 -0
- package/dist/nodefony/src/token/tokenFilters.js +72 -0
- package/dist/nodefony/src/token/tokenSort.js +40 -0
- package/dist/nodefony/src/token/tokenStatus.js +35 -0
- package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
- package/dist/nodefony/src/totp/totpCipher.js +30 -0
- package/dist/nodefony/src/totp/totpCrypto.js +226 -0
- package/dist/nodefony/src/totp/totpOperations.js +129 -0
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
- package/dist/nodefony/src/voter/RoleVoter.js +32 -0
- package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
- package/dist/nodefony/src/voter/voterRegistry.js +20 -0
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
- package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
- package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
- package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
- package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
- package/dist/nodefony/src/webhook/webhookSort.js +48 -0
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
- package/dist/types/index.d.ts +157 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
- package/dist/types/nodefony/command/security-token.d.ts +44 -0
- package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
- package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +295 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
- package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
- package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
- package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
- package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
- package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
- package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
- package/dist/types/nodefony/contracts/index.d.ts +9 -0
- package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
- package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
- package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
- package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
- package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +8 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
- package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
- package/dist/types/nodefony/service/auditService.d.ts +30 -0
- package/dist/types/nodefony/service/authFlow.d.ts +123 -0
- package/dist/types/nodefony/service/authorization.d.ts +33 -0
- package/dist/types/nodefony/service/cors.d.ts +48 -0
- package/dist/types/nodefony/service/csrf.d.ts +57 -0
- package/dist/types/nodefony/service/firewall.d.ts +148 -0
- package/dist/types/nodefony/service/oauth2.d.ts +66 -0
- package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
- package/dist/types/nodefony/service/tokenService.d.ts +103 -0
- package/dist/types/nodefony/service/totp.d.ts +58 -0
- package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
- package/dist/types/nodefony/service/webhooks.d.ts +160 -0
- package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
- package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
- package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
- package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
- package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
- package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
- package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
- package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
- package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
- package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
- package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
- package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
- package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
- package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
- package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
- package/dist/types/nodefony/src/csp.d.ts +39 -0
- package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
- package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
- package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
- package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
- package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
- package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
- package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
- package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
- package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
- package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
- package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
- package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
- package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
- package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
- package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
- package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
- package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
- package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
- package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
- package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
- package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
- package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
- package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
- package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
- package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
- package/docs/api-keys.md +691 -0
- package/docs/audit.md +751 -0
- package/docs/authenticators.md +487 -0
- package/docs/authorization.md +497 -0
- package/docs/cors.md +497 -0
- package/docs/csrf.md +392 -0
- package/docs/external-jwt.md +181 -0
- package/docs/firewall.md +546 -0
- package/docs/headers.md +616 -0
- package/docs/index.md +207 -0
- package/docs/lexique.md +190 -0
- package/docs/oauth2.md +575 -0
- package/docs/obtenir-un-jeton.md +225 -0
- package/docs/tokens.md +520 -0
- package/docs/totp.md +804 -0
- package/docs/webauthn.md +733 -0
- package/docs/webhooks.md +1016 -0
- package/package.json +83 -0
package/docs/firewall.md
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Firewall — le pare-feu applicatif"
|
|
3
|
+
navTitle: Firewall
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/security"
|
|
6
|
+
topic: firewall
|
|
7
|
+
coverageModule: security
|
|
8
|
+
coverageFiles: "firewall"
|
|
9
|
+
section: "Sécurité"
|
|
10
|
+
audience: [developer]
|
|
11
|
+
tags:
|
|
12
|
+
[
|
|
13
|
+
firewall,
|
|
14
|
+
securite,
|
|
15
|
+
authentification,
|
|
16
|
+
authenticators,
|
|
17
|
+
zones,
|
|
18
|
+
zero-trust,
|
|
19
|
+
csrf,
|
|
20
|
+
cors,
|
|
21
|
+
csp,
|
|
22
|
+
]
|
|
23
|
+
version: "doc"
|
|
24
|
+
status: stable
|
|
25
|
+
updated: 2026-07-19
|
|
26
|
+
source: "src/packages/@nodefony/security/docs/firewall.md"
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Firewall — le pare-feu applicatif
|
|
30
|
+
|
|
31
|
+
> Pour **chaque** requête (HTTP comme WebSocket), le firewall répond à trois questions dans l'ordre :
|
|
32
|
+
> est-ce une zone protégée ? qui es-tu ? as-tu le droit ? La politique par défaut est **Zero Trust** :
|
|
33
|
+
> sur une zone protégée, pas de preuve d'identité valide = 401. Ancré sur
|
|
34
|
+
> `src/packages/@nodefony/security/nodefony/service/firewall.ts` et les authenticators de
|
|
35
|
+
> `nodefony/src/authenticator/`.
|
|
36
|
+
|
|
37
|
+
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Firewall**
|
|
38
|
+
|
|
39
|
+
## 🧠 Le modèle mental — chemin chaud, chemin froid
|
|
40
|
+
|
|
41
|
+
Le firewall sépare **détecter** (chaud, sur chaque requête) et **décider** (froid, seulement zone
|
|
42
|
+
protégée) — pour ne pas payer l'authentification sur les routes publiques.
|
|
43
|
+
|
|
44
|
+
```mermaid
|
|
45
|
+
flowchart TD
|
|
46
|
+
R["Requête HTTP / WS"] --> IS{"isSecure()<br/>zone protégée ?"}
|
|
47
|
+
IS -->|non| PASS["passe (public)"]
|
|
48
|
+
IS -->|oui| AU["#authenticate()<br/>authenticators de la zone, dans l'ordre"]
|
|
49
|
+
AU -->|ThrottledError| T429["429 + Retry-After"]
|
|
50
|
+
AU -->|credential invalide| C401["401 + challenge"]
|
|
51
|
+
AU -->|aucune preuve| Z401["401 (Zero Trust)"]
|
|
52
|
+
AU -->|succès| OK["user + token dans l'ALS → contrôleur"]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`Firewall.isSecure()` (`firewall.ts:705`) rattache la requête à une **zone** via
|
|
56
|
+
`Firewall.matchPath()` (`firewall.ts:696`) ; `Firewall.handleSecurity()` (`firewall.ts:738`) décide.
|
|
57
|
+
Les zones sont triées par **spécificité** dans `#build()` — `list.sort` par longueur de motif :
|
|
58
|
+
le plus long gagne, pas le premier déclaré (`firewall.ts:191`).
|
|
59
|
+
|
|
60
|
+
## 📖 Lexique
|
|
61
|
+
|
|
62
|
+
| Terme | Sens |
|
|
63
|
+
| ------------- | ------------------------------------------------------------------------------- |
|
|
64
|
+
| Zone | Un motif d'URL (+ host) avec sa politique (`config.areas`, un objet par nom). |
|
|
65
|
+
| Authenticator | Une stratégie d'identification (session, userpassword, jwt, apikey, anonymous). |
|
|
66
|
+
| Zero Trust | Sans preuve valide sur une zone protégée → 401. |
|
|
67
|
+
| Challenge | En-tête `WWW-Authenticate` (RFC 7235) qui dit comment s'authentifier. |
|
|
68
|
+
| BFF | Backend-For-Frontend : le serveur gère session/jetons pour le front web. |
|
|
69
|
+
| PAT | Personal Access Token : une clé d'API opaque, révocable côté serveur. |
|
|
70
|
+
| Bearer | Schéma `Authorization: Bearer <token>` (RFC 6750). |
|
|
71
|
+
|
|
72
|
+
## 🚀 Démarrage rapide
|
|
73
|
+
|
|
74
|
+
### Dans une app `nodefony create app`, le firewall est DÉJÀ actif
|
|
75
|
+
|
|
76
|
+
Le scaffold déclare deux zones dans `nodefony.config.ts` — c'est la forme canonique (un **objet par
|
|
77
|
+
nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:902`) :
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
// nodefony.config.ts (extrait généré par `nodefony create app`)
|
|
81
|
+
use("@nodefony/security", {
|
|
82
|
+
areas: {
|
|
83
|
+
// Zone de TES routes : `session` PUIS `anonymous` → identifié si cookie,
|
|
84
|
+
// sinon visiteur accepté. Hors zone, l'identité n'est JAMAIS résolue.
|
|
85
|
+
main: {
|
|
86
|
+
pattern: "^/api",
|
|
87
|
+
authenticators: ["session", "anonymous"],
|
|
88
|
+
},
|
|
89
|
+
// Zone PROTÉGÉE — pattern PLUS SPÉCIFIQUE que ^/api : le firewall trie
|
|
90
|
+
// par longueur → /api/secure/* tombe ICI. Pas d'`anonymous` : sans
|
|
91
|
+
// session → 401 AVANT ton controller (Zero Trust).
|
|
92
|
+
secure: {
|
|
93
|
+
pattern: "^/api/secure",
|
|
94
|
+
authenticators: ["session"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
roleHierarchy: {
|
|
98
|
+
ROLE_NODEFONY_ADMIN: ["ROLE_ADMIN", "ROLE_SUPERVISOR", "ROLE_DEV"],
|
|
99
|
+
ROLE_ADMIN: ["ROLE_USER"],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
> [!IMPORTANT]
|
|
105
|
+
> **Hors zone, l'identité n'est JAMAIS résolue** — même connecté, une route non couverte par une
|
|
106
|
+
> zone ne sait pas qui tu es. Une route « publique » qui veut connaître l'utilisateur se couvre
|
|
107
|
+
> par `["session", "anonymous"]`.
|
|
108
|
+
|
|
109
|
+
**Le login est FOURNI** : le module security expose le BFF `POST /nodefony/security/api/auth/login`
|
|
110
|
+
(body `{ username, password }` → `Set-Cookie` de session ; `AuthFlow.login` régénère l'ID de session
|
|
111
|
+
— anti-fixation OWASP). Pas de LoginController à écrire.
|
|
112
|
+
|
|
113
|
+
### Ce que TU écris : le controller protégé
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// nodefony/controllers/AccountController.ts — complet, compile tel quel
|
|
117
|
+
import {
|
|
118
|
+
controller,
|
|
119
|
+
Controller,
|
|
120
|
+
Get,
|
|
121
|
+
IsGranted,
|
|
122
|
+
CurrentUser,
|
|
123
|
+
} from "@nodefony/framework";
|
|
124
|
+
import type { ContextType } from "@nodefony/http";
|
|
125
|
+
import type { IUser } from "@nodefony/user";
|
|
126
|
+
|
|
127
|
+
@controller("/api/secure/account")
|
|
128
|
+
class AccountController extends Controller {
|
|
129
|
+
// Zone `secure` : context.user est GARANTI ici (le firewall a authentifié).
|
|
130
|
+
// @IsGranted ajoute l'AUTORISATION : il faut aussi le rôle.
|
|
131
|
+
@IsGranted(["ROLE_USER"])
|
|
132
|
+
@Get("/me")
|
|
133
|
+
async me(@CurrentUser() user: IUser) {
|
|
134
|
+
// identité ré-résolue à chaque requête → rôles frais, révocation immédiate
|
|
135
|
+
return this.renderJson({ identifier: user.identifier, roles: user.roles });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export default AccountController;
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
(Wiring : `@controllers([AccountController])` dans le module de l'app — `nodefony create controller`
|
|
143
|
+
le fait pour toi.)
|
|
144
|
+
|
|
145
|
+
### Ce qu'on observe
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# 1) Sans session : Zero Trust → 401 (aucun code à toi n'a tourné)
|
|
149
|
+
curl -si http://localhost:5151/api/secure/account/me | head -1
|
|
150
|
+
# HTTP/1.1 401 Unauthorized
|
|
151
|
+
|
|
152
|
+
# 2) Login BFF (compte dev seedé admin/admin) → cookie de session
|
|
153
|
+
curl -si -c /tmp/jar -H 'Content-Type: application/json' \
|
|
154
|
+
-d '{"username":"admin","password":"admin"}' \
|
|
155
|
+
http://localhost:5151/nodefony/security/api/auth/login | head -1
|
|
156
|
+
# HTTP/1.1 200 OK
|
|
157
|
+
|
|
158
|
+
# 3) Rejouer avec le cookie → 200, identité résolue
|
|
159
|
+
curl -s -b /tmp/jar http://localhost:5151/api/secure/account/me
|
|
160
|
+
# {"identifier":"admin","roles":["ROLE_NODEFONY_ADMIN", …]}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Protéger une API machine (jwt et/ou apikey)
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
use("@nodefony/security", {
|
|
167
|
+
areas: {
|
|
168
|
+
// jwt et apikey cohabitent : discriminés par la FORME du bearer (voir plus bas)
|
|
169
|
+
api: {
|
|
170
|
+
pattern: "^/api/v1",
|
|
171
|
+
authenticators: ["jwt", "apikey"],
|
|
172
|
+
mode: "first",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Le client envoie l'un ou l'autre :
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
Authorization: Bearer eyJhbGciOiJFZERTQS␣…␣.␣…␣.␣… # un JWT (structure a.b.c)
|
|
182
|
+
Authorization: Bearer nf_9a2c… # une clé API (préfixe nf_)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## 🔐 Les authenticators intégrés
|
|
186
|
+
|
|
187
|
+
Tous respectent le **même contrat** (`IAuthenticator`) : `supports(context)` (test bon marché : la
|
|
188
|
+
requête porte-t-elle ce type de credential ?), `createToken()` (extrait le credential brut),
|
|
189
|
+
`authenticate(token)` (valide + promeut, ou lève un 401), `challenge()` (l'en-tête `WWW-Authenticate`).
|
|
190
|
+
Point commun de sécurité : **message d'échec uniforme** (`"Invalid token"` / `"Invalid credentials"`)
|
|
191
|
+
— la cause fine (expiré, révoqué, sujet banni…) part dans l'audit, jamais au client (anti-énumération).
|
|
192
|
+
|
|
193
|
+
| Nom | Credential | Vérité | Révocable | Pour… |
|
|
194
|
+
| -------------- | --------------------------------------- | ---------- | :-------: | --------------------------------- |
|
|
195
|
+
| `session` | cookie de session (identifiant en blob) | serveur | immédiate | le **web** après login (BFF) |
|
|
196
|
+
| `userpassword` | `Authorization: Basic base64(id:mdp)` | verifier | n/a | outils/scripts, brique login |
|
|
197
|
+
| `jwt` | `Authorization: Bearer <a.b.c>` | auto-porté | via état | API service↔service, agents |
|
|
198
|
+
| `apikey` | `Authorization: Bearer <prefix>_…` | serveur | immédiate | API/CI/scripts d'un user |
|
|
199
|
+
| `anonymous` | (aucun) | — | — | accepter l'anonymat explicitement |
|
|
200
|
+
|
|
201
|
+
### `session` — la preuve du web après login
|
|
202
|
+
|
|
203
|
+
Credential = l'**identifiant** posé dans le blob de session (jamais un secret).
|
|
204
|
+
|
|
205
|
+
- **N'ouvre jamais la session lui-même** : il exige une session reprise portant un user
|
|
206
|
+
(`supports()`, `SessionAuthenticator.ts:43`). C'est `AuthFlow.login()` (BFF) qui ouvre et
|
|
207
|
+
régénère l'ID (anti-fixation).
|
|
208
|
+
- **L'identité est re-résolue à CHAQUE requête** (`SessionAuthenticator.ts:70`) → rôles frais,
|
|
209
|
+
révocation et verrouillage effectifs immédiatement.
|
|
210
|
+
- **Pas de `challenge()`** : session absente = 401 nu → le front redirige vers son écran de login
|
|
211
|
+
(pas de popup Basic).
|
|
212
|
+
|
|
213
|
+
### `userpassword` — HTTP Basic, avec throttle NIST
|
|
214
|
+
|
|
215
|
+
Credential = `Authorization: Basic base64(identifiant:motdepasse)` — RFC 7617, split au **premier**
|
|
216
|
+
`:` (`UserPasswordAuthenticator.ts:74`).
|
|
217
|
+
|
|
218
|
+
- **La vérification est déléguée** au `IPasswordVerifier` (le `UserService`) : hash, comparaison,
|
|
219
|
+
leurre anti-timing, re-hash. L'authenticator ne voit que le verdict.
|
|
220
|
+
- **Le throttle NIST SP 800-63B passe AVANT le verifier** (`UserPasswordAuthenticator.ts:101`) :
|
|
221
|
+
un identifiant bloqué ne coûte **aucun hash argon2** — protège d'un DoS par hachage.
|
|
222
|
+
Échec → backoff ; `ThrottledError` → **429 + `Retry-After`**.
|
|
223
|
+
- Challenge : `Basic realm="nodefony"`.
|
|
224
|
+
- **Piège** : le login par formulaire (JSON) n'est **pas** ici — c'est le BFF
|
|
225
|
+
(`/nodefony/security/api/auth/login`). Basic sert l'outillage (scripts, CLI).
|
|
226
|
+
|
|
227
|
+
### `jwt` — Bearer JWT signé, durci RFC 8725
|
|
228
|
+
|
|
229
|
+
Credential = `Authorization: Bearer <jws>` de structure compacte `a.b.c` (`JwtAuthenticator.ts:14`).
|
|
230
|
+
Réservé API service↔service / agents (le web reste sur la session). Access token **EdDSA** signé par
|
|
231
|
+
le keystore du serveur. Défenses **dures**, prouvées en test (RFC 8725 JWT BCP), toutes dans
|
|
232
|
+
`JwtAuthenticator.authenticate()` :
|
|
233
|
+
|
|
234
|
+
- **allowlist d'algorithmes** `["EdDSA"]` — l'algo n'est **jamais** choisi d'après l'en-tête du
|
|
235
|
+
token ; `alg=none` rejeté (`JwtAuthenticator.ts:120`).
|
|
236
|
+
- **clé par `kid` depuis le JWKS LOCAL** (`createLocalJWKSet`) — jamais `jku`/`jwk` de l'en-tête
|
|
237
|
+
(anti-injection de clé / SSRF, `JwtAuthenticator.ts:155`).
|
|
238
|
+
- **`aud` + `iss` obligatoires** + `typ:"at+jwt"` (un refresh présenté comme access est rejeté) +
|
|
239
|
+
exp/nbf (`JwtAuthenticator.ts:105-108`).
|
|
240
|
+
- **révocation** malgré l'auto-portage : denylist `jti` + `invalidBefore` par sujet
|
|
241
|
+
(`JwtAuthenticator.ts:122-132`).
|
|
242
|
+
- **sujet revérifié** à réception (`loadUserByIdentifier(sub)`) : compte disparu/inactif/verrouillé
|
|
243
|
+
= rejet (`JwtAuthenticator.ts:174-187`).
|
|
244
|
+
|
|
245
|
+
Le token promu porte `scopes`, `jti`, `claims` (`JwtAuthenticator.ts:162-172`).
|
|
246
|
+
|
|
247
|
+
> [!WARNING]
|
|
248
|
+
> Un JWT est **auto-porté** : sans état serveur il n'est **pas** révocable. C'est la denylist
|
|
249
|
+
> `jti` + `invalidBefore` (état serveur) qui le rend révocable — vérifie que ton `tokenStore`
|
|
250
|
+
> les porte.
|
|
251
|
+
|
|
252
|
+
### `apikey` — clé d'API opaque (PAT), révocable
|
|
253
|
+
|
|
254
|
+
Credential = `Authorization: Bearer <prefix>_…` (`ApiKeyAuthenticator.ts:67`). Contrairement au JWT,
|
|
255
|
+
c'est un **bearer opaque** : sa vérité vit côté serveur (`ITokenStore`) → **révocable immédiatement**.
|
|
256
|
+
|
|
257
|
+
Défenses de `ApiKeyAuthenticator.authenticate()` :
|
|
258
|
+
|
|
259
|
+
- **forme + CRC validés AVANT tout accès au store** — anti-DoS (`parseApiKey()`,
|
|
260
|
+
`ApiKeyAuthenticator.ts:98`) ;
|
|
261
|
+
- lookup par **hash sha256** : le secret n'existe nulle part au repos (`:105`) ;
|
|
262
|
+
- révocation (`revokedAt`), expiration (`expiresAt`), **ban en masse** du porteur
|
|
263
|
+
(`invalidBefore` vs `createdAt`, `:117-120`) ;
|
|
264
|
+
- **sujet revérifié** à chaque requête — rôles frais (`:122`) ;
|
|
265
|
+
- `lastUsedAt` écrit en **throttlé** — pas une écriture par requête (`:127-134`).
|
|
266
|
+
|
|
267
|
+
Le token porte `scopes`, `apiKeyId`, `tenantId` (`:138-140`). `jwt` et `apikey` **cohabitent** dans
|
|
268
|
+
une zone : ils se discriminent par la forme (JWT = `a.b.c`, PAT = `prefix_…`).
|
|
269
|
+
|
|
270
|
+
### `anonymous` — accepter l'anonymat, explicitement
|
|
271
|
+
|
|
272
|
+
Le **seul** authenticator qui produit un token non authentifié **sans** déclencher le Zero Trust
|
|
273
|
+
(`AnonymousAuthenticator.ts:6-18`). À lister **volontairement** : `["jwt", "anonymous"]` en mode
|
|
274
|
+
`first` = « identifié si preuve présente, sinon **visiteur anonyme accepté** ». En mode `all`, utile
|
|
275
|
+
en dernier : « le canal doit être prouvé (ex. mTLS), l'identité utilisateur est optionnelle ». Coût
|
|
276
|
+
nul : `supports()` accepte tout, le token porte le singleton gelé `anonymousUser` (0 allocation).
|
|
277
|
+
Sans lui, zone protégée + aucune preuve = 401.
|
|
278
|
+
|
|
279
|
+
### `firewall-realtime` — l'identité du firewall, côté WebSocket (câblé auto)
|
|
280
|
+
|
|
281
|
+
Il promeut en jeton realtime **toute** identité que le firewall a résolue — session BFF comme jeton
|
|
282
|
+
porteur (JWT, clé d'API). **Enregistré automatiquement** par `Firewall.#wireRealtime()` au
|
|
283
|
+
handshake des zones protégées `realtime` (`firewall.ts:289`).
|
|
284
|
+
|
|
285
|
+
- **Perf : il ne relit pas la base.** Handshake et frames tournent dans la même bulle ALS —
|
|
286
|
+
l'identité déjà posée est réutilisée, 2 lectures base économisées par connexion
|
|
287
|
+
(`FirewallRealtimeAuthenticator.ts:24-30`).
|
|
288
|
+
- **Asymétrie HTTP↔WS assumée** : le jeton est **figé au handshake** (les frames lisent un cache
|
|
289
|
+
O(1)) → une révocation prend effet **en une fenêtre** (tick du hub, et devant chaque
|
|
290
|
+
`api.request`), pas à la frame suivante (`FirewallRealtimeAuthenticator.ts:51-55`). C'est l'état
|
|
291
|
+
de l'art (Socket.IO et Phoenix figent aussi).
|
|
292
|
+
- **Filet** : un revalidator re-lit la session avant chaque action data plane ; fail-closed →
|
|
293
|
+
fermeture 4001.
|
|
294
|
+
|
|
295
|
+
## 🗝️ `stateless` — la zone tient-elle un registre ?
|
|
296
|
+
|
|
297
|
+
Une zone dit, par ce drapeau, **où vit l'identité** — et ce n'est pas la même chose que la liste de
|
|
298
|
+
ses authentificateurs.
|
|
299
|
+
|
|
300
|
+
| Valeur | Ce que la zone fait | Pour qui |
|
|
301
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
|
302
|
+
| `false` (défaut) | la zone **peut** tenir un registre serveur : session créée au login, cookie opaque révocable | un **navigateur** (modèle BFF) |
|
|
303
|
+
| `true` | la zone n'ouvre **ni ne reprend** de session — le cookie entrant est ignoré, aucun `Set-Cookie` n'est renvoyé | un **porteur de preuve** (clé, jeton) |
|
|
304
|
+
|
|
305
|
+
Ce que `true` évite concrètement : sans lui, un appelant qui envoie un cookie inconnu — un client
|
|
306
|
+
qui recycle un en-tête, un navigateur qui traîne une vieille session, un attaquant qui en fabrique
|
|
307
|
+
un — fait **reprendre puis réécrire une session serveur** et repartir un `Set-Cookie`, y compris
|
|
308
|
+
quand la réponse est un 401. Un registre pour quelqu'un qui ne le relira jamais.
|
|
309
|
+
|
|
310
|
+
> 🔴 **`stateless: true` et `"session"` dans la même zone est une contradiction, et l'application
|
|
311
|
+
> REFUSE de démarrer** en la nommant (`SessionAuthenticator.validateArea`). Une zone sert un
|
|
312
|
+
> navigateur **ou** un porteur de preuve. Si les deux publics doivent atteindre la même
|
|
313
|
+
> fonctionnalité, ce sont **deux zones** — le firewall trie par longueur de motif, donc la plus
|
|
314
|
+
> spécifique gagne.
|
|
315
|
+
|
|
316
|
+
Une **route** qui demande une session (`@UseSession`) sous une zone stateless ne l'emporte pas : la
|
|
317
|
+
zone est la déclaration de sécurité, elle gagne, et le journal le dit une fois par zone au lieu de
|
|
318
|
+
laisser chercher pourquoi `context.session` est nul.
|
|
319
|
+
|
|
320
|
+
## ⚙️ Ordre et modes (`mode: "first"` vs `"all"`)
|
|
321
|
+
|
|
322
|
+
La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
|
|
323
|
+
(`firewall.ts:1112`). Le `mode` dit comment la parcourir. Trois situations concrètes :
|
|
324
|
+
|
|
325
|
+
### Situation 1 — humains ET machines sur la même API (`first`, le mode courant)
|
|
326
|
+
|
|
327
|
+
Ton back-office est appelé par le **navigateur** des utilisateurs connectés ET par un **script CI**.
|
|
328
|
+
Deux preuves différentes, mêmes routes :
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
back: {
|
|
332
|
+
pattern: "^/api/back",
|
|
333
|
+
authenticators: ["session", "apikey"],
|
|
334
|
+
mode: "first", // (défaut) le PREMIER qui reconnaît la requête authentifie
|
|
335
|
+
},
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Ce qui se passe, requête par requête :
|
|
339
|
+
|
|
340
|
+
<!-- prettier-ignore -->
|
|
341
|
+
| Le client envoie… | `supports()` vrai pour… | Résultat |
|
|
342
|
+
| --- | --- | --- |
|
|
343
|
+
| le cookie de session | `session` | identifié, `apikey` jamais consulté |
|
|
344
|
+
| `Authorization: Bearer nf_…` | `apikey` | identifié (session ne matche pas, on passe) |
|
|
345
|
+
| une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:1112`) |
|
|
346
|
+
| rien | aucun | **401** (Zero Trust) |
|
|
347
|
+
|
|
348
|
+
### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
|
|
349
|
+
|
|
350
|
+
Tu veux « identifié si connecté, sinon visiteur » :
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
authenticators: ["session", "anonymous"], // ✅ session d'abord
|
|
354
|
+
authenticators: ["anonymous", "session"], // ❌ anonymous accepte TOUT le monde
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`AnonymousAuthenticator.supports()` accepte **toutes** les requêtes — placé en premier en mode
|
|
358
|
+
`first`, il court-circuite la liste : **personne n'est jamais identifié**, même avec un cookie
|
|
359
|
+
valide. L'ordre est ta politique.
|
|
360
|
+
|
|
361
|
+
### Situation 3 — le « sudo mode » (`all` : empiler les preuves)
|
|
362
|
+
|
|
363
|
+
Une action destructrice (suppression de compte, rotation des clés) doit exiger la session **ET**
|
|
364
|
+
une re-saisie du mot de passe — même logique que GitHub avant une action sensible :
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
danger: {
|
|
368
|
+
pattern: "^/api/back/danger",
|
|
369
|
+
authenticators: ["session", "userpassword"],
|
|
370
|
+
mode: "all", // CHAQUE maillon est obligatoire
|
|
371
|
+
},
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Le client doit présenter **les deux preuves** dans la même requête (cookie + `Authorization:
|
|
375
|
+
Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte l'identité
|
|
376
|
+
(`firewall.ts:936-939`) — ici la preuve mot de passe, la plus fraîche.
|
|
377
|
+
|
|
378
|
+
> [!TIP]
|
|
379
|
+
> Un nom d'authenticator inconnu en config **fait échouer le boot** —
|
|
380
|
+
> `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:402`) : jamais de zone
|
|
381
|
+
> « protégée » silencieusement ouverte à cause d'une faute de frappe.
|
|
382
|
+
|
|
383
|
+
## 🧑⚖️ Autorisation — rôles, scopes, voters (« as-tu le droit ? »)
|
|
384
|
+
|
|
385
|
+
L'authentification dit **qui** tu es ; l'autorisation dit **ce que tu peux faire**. On déclare
|
|
386
|
+
l'exigence sur l'action, un **jury de voters** tranche. La garde s'applique **avant l'instanciation
|
|
387
|
+
du contrôleur** (seam Resolver) — une action protégée ne s'exécute jamais pour un non-autorisé.
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
@IsGranted(["ROLE_ADMIN"]) // rôle — OR interne : un seul attribut suffit
|
|
391
|
+
@Post("/users") async create() {}
|
|
392
|
+
|
|
393
|
+
@RequireScope("users:write") // scope — pour une clé/JWT délégué
|
|
394
|
+
@Delete("/users/{id}") async remove(@Param("id") id: string) {}
|
|
395
|
+
|
|
396
|
+
@IsGranted("doc.edit", { subject: "id" }) // règle métier — le param de route `id` est passé au voter
|
|
397
|
+
@Put("/docs/{id}") async edit() {}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Le jury et sa stratégie
|
|
401
|
+
|
|
402
|
+
`Authorization.decide(token, attribut, subject?)` (`service/authorization.ts:70`) applique une
|
|
403
|
+
stratégie **affirmative + DENY veto**, fermée par défaut (**Zero Trust**) :
|
|
404
|
+
|
|
405
|
+
- un seul **`DENY`** bloque (veto, court-circuit — inutile de finir le jury,
|
|
406
|
+
`authorization.ts:94-97`) ;
|
|
407
|
+
- sinon un **`GRANT`** suffit ;
|
|
408
|
+
- **silence total** (tous `ABSTAIN`, ou aucun voter compétent) → **`DENY`**
|
|
409
|
+
(`authorization.ts:100-108`) ;
|
|
410
|
+
- un voter qui **throw** → **`DENY`** + log ERROR (fail-closed : jamais 500, jamais octroi,
|
|
411
|
+
`authorization.ts:85-93`).
|
|
412
|
+
|
|
413
|
+
Tout refus est audité (WARNING + `recordAudit`, `authorization.ts:113-142`) ; les octrois restent
|
|
414
|
+
muets (volume, pas un signal). Les voters sont instanciés **une fois au boot** via le registre
|
|
415
|
+
(aucun nom en dur, `authorization.ts:55-64`).
|
|
416
|
+
|
|
417
|
+
### Les voters intégrés — deux axes
|
|
418
|
+
|
|
419
|
+
- **RoleVoter** (`role`, attributs `ROLE_*`) — `GRANT` si l'utilisateur a le rôle, **hiérarchie
|
|
420
|
+
résolue** ; **`ABSTAIN` sinon**, jamais `DENY` (`RoleVoter.vote()`, `RoleVoter.ts:25-39`).
|
|
421
|
+
Constat : l'absence d'un rôle ne doit pas opposer son veto aux autres axes — c'est le
|
|
422
|
+
**default-DENY du jury** qui ferme la porte, pas ce voter. C'est ce qui rend une clause OR
|
|
423
|
+
(`@IsGranted(["A","B"])`) possible.
|
|
424
|
+
- **ScopeVoter** (`scope`, attributs `api:action`) — un scope **ne bride jamais un humain**
|
|
425
|
+
(`ScopeVoter.ts:17-62`) :
|
|
426
|
+
- jeton humain (`session`/`userpassword`/`anonymous`) → `GRANT` no-op : l'autorisation d'un
|
|
427
|
+
humain passe par ses **rôles** ;
|
|
428
|
+
- jeton **machine délégué** (`apikey`/`jwt`/`oauth2`) → `GRANT` si le scope exact est présent,
|
|
429
|
+
`ABSTAIN` sinon ;
|
|
430
|
+
- **fail-closed côté machine** : tout type de jeton hors de la liste « non scopable » — présent
|
|
431
|
+
ou futur (`mtls`, `agent`…) — est traité comme scopable, donc **bridé par défaut**.
|
|
432
|
+
- En une ligne : rôles = qui tu es ; scopes = ce qu'une **clé** a le droit de faire.
|
|
433
|
+
|
|
434
|
+
### La hiérarchie de rôles
|
|
435
|
+
|
|
436
|
+
`RoleHierarchyWalker` (`src/RoleHierarchyWalker.ts`) : `ROLE_ADMIN` hérite `ROLE_USER`, etc.
|
|
437
|
+
**Aplatissement précalculé au boot** → `hasRole()` est O(1) sur le hot path
|
|
438
|
+
(`RoleHierarchyWalker.ts:23-30`), et les **cycles sont détectés au boot** (throw avec le chemin
|
|
439
|
+
complet, pas de fail-silent, `RoleHierarchyWalker.ts:69-95`). La hiérarchie est posée au container
|
|
440
|
+
par le firewall au boot ; le `RoleVoter` la lit en lazy.
|
|
441
|
+
|
|
442
|
+
### Voters métier (le vrai pouvoir applicatif)
|
|
443
|
+
|
|
444
|
+
Pour une règle qui dépend des **données** (ownership, tenant, état), on enregistre une fabrique :
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
import { registerVoterFactory } from "@nodefony/security";
|
|
448
|
+
|
|
449
|
+
registerVoterFactory(
|
|
450
|
+
"projectVoter",
|
|
451
|
+
({ container }) => new ProjectVoter(container),
|
|
452
|
+
);
|
|
453
|
+
// ProjectVoter.supports("doc.edit") → true ; vote(token, "doc.edit", subject) → lookup DB async :
|
|
454
|
+
// l'utilisateur est-il propriétaire/membre du `subject` ? GRANT / DENY / ABSTAIN.
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Le voter est **découvert automatiquement** par l'`AuthorizationService` — aucun changement dans le
|
|
458
|
+
cœur (`registerVoterFactory()`, `voterRegistry.ts:40`). Pourquoi un registre et pas un scan DI des
|
|
459
|
+
`@injectable` : les interfaces TS sont **effacées à la compilation** — rien à scanner ; le registre
|
|
460
|
+
**est** le marqueur explicite (TSDoc du registre, `voterRegistry.ts:6-16`). Trois axes (rôles,
|
|
461
|
+
scopes, métier), un même jury, combinables.
|
|
462
|
+
|
|
463
|
+
## 🔌 HTTP et WebSocket — le même firewall
|
|
464
|
+
|
|
465
|
+
`Firewall.#wireRealtime()` (`firewall.ts:268`) câble, pour toute zone protégée `realtime !== false`
|
|
466
|
+
(opt-out, `firewall.ts:277`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:289`)
|
|
467
|
+
**et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:337`). Même résolution de zone que HTTP.
|
|
468
|
+
Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setChallenge()`,
|
|
469
|
+
`firewall.ts:1191`) : le **code de fermeture** suffit.
|
|
470
|
+
|
|
471
|
+
## 🛡️ En-têtes de sécurité, CSRF, CORS
|
|
472
|
+
|
|
473
|
+
- **`Firewall.applySecurityHeaders()`** (`firewall.ts:1029`) : CSP, Referrer-Policy, COOP/COEP/CORP
|
|
474
|
+
au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:855`) :
|
|
475
|
+
alloué seulement si une directive en a besoin.
|
|
476
|
+
- **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:932`) : Fetch Metadata
|
|
477
|
+
(`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:764`), puis double-submit `x-csrf-token` ≡
|
|
478
|
+
cookie + HMAC (`firewall.ts:778`).
|
|
479
|
+
- **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:991`).
|
|
480
|
+
|
|
481
|
+
## 📜 Normes appliquées
|
|
482
|
+
|
|
483
|
+
| Domaine | Norme | Ancrage |
|
|
484
|
+
| ---------------------- | --------------- | ------------------------------------------------------ |
|
|
485
|
+
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1191`) |
|
|
486
|
+
| Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
|
|
487
|
+
| JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
|
|
488
|
+
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
|
|
489
|
+
| Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:764`) |
|
|
490
|
+
| Backoff de login | NIST SP 800-63B | `UserPasswordAuthenticator.ts:43-46,101-104` |
|
|
491
|
+
| CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:932`) |
|
|
492
|
+
| Modèle | Zero Trust | `firewall.ts:611` (aucune preuve → 401) |
|
|
493
|
+
|
|
494
|
+
## ⚡ Performance & mémoire
|
|
495
|
+
|
|
496
|
+
Le découpage chaud/froid EST l'optimisation : `isSecure()` (chaque requête) ne fait qu'un
|
|
497
|
+
`matchPath` ; `handleSecurity()` (throttler, authenticators, nonce CSP, `securityTrace`) n'est payé
|
|
498
|
+
que sur zone protégée. Les dépendances des authenticators (keystore, tokenStore, userProvider,
|
|
499
|
+
verifier, jose) sont résolues **paresseusement** au premier usage (cold path) ; `jose` est importé
|
|
500
|
+
lazy (dep lourde). Le nonce CSP et le `securityTrace` sont alloués à la demande. Une route publique
|
|
501
|
+
ne paie quasiment rien.
|
|
502
|
+
|
|
503
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
504
|
+
|
|
505
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
506
|
+
| ---------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------- |
|
|
507
|
+
| Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }` |
|
|
508
|
+
| Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator |
|
|
509
|
+
| 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu |
|
|
510
|
+
| Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]` |
|
|
511
|
+
| API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`) |
|
|
512
|
+
| JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore` |
|
|
513
|
+
| WS : révocation pas immédiate | Jeton figé au handshake (asymétrie assumée) | Effet à la reconnexion ; pour l'immédiat, canal JWT (J4) |
|
|
514
|
+
| 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque |
|
|
515
|
+
|
|
516
|
+
## 📡 Observabilité — Studio
|
|
517
|
+
|
|
518
|
+
Écran **Firewall** (`/nodefony/firewall`) : zones, authenticators, décisions (`securityTrace`).
|
|
519
|
+
Écran **Roles** (`/nodefony/roles`) : hiérarchie de rôles consommée par les voters. Écran
|
|
520
|
+
**ApiKeys** (`/nodefony/api-keys`) : gestion et révocation des PAT.
|
|
521
|
+
|
|
522
|
+
## 🧪 Tests & couverture
|
|
523
|
+
|
|
524
|
+
Quatre familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
|
|
525
|
+
(régénérée par `gen-counters.mjs` depuis vitest, jamais figée ici) :
|
|
526
|
+
|
|
527
|
+
- **unit** : `firewallChain` (la chaîne d'authenticators + modes first/all), `securedArea` (match
|
|
528
|
+
pattern/host), `firewallIntrospection` (l'écran Studio), `firewallSecurityTrace` (la radiographie
|
|
529
|
+
de décision) ;
|
|
530
|
+
- **intégration** : `firewall-auth` (serveur réel : zones + login BFF), `securityGuard` (la garde
|
|
531
|
+
`@IsGranted` au Resolver) ;
|
|
532
|
+
- **e2e** : `realtimeFirewallWiring` (le câblage WS réel) ;
|
|
533
|
+
- **attaque** : les bancs transverses (csrf, cors, authorization, frames WS) exercent le firewall en
|
|
534
|
+
conditions hostiles — voir [authenticators](./authenticators.md) et
|
|
535
|
+
[authorization](./authorization.md).
|
|
536
|
+
|
|
537
|
+
Couverture : `npm run coverage` dans `@nodefony/security`.
|
|
538
|
+
|
|
539
|
+
## 🔗 Pour aller plus loin
|
|
540
|
+
|
|
541
|
+
- ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
542
|
+
- 🧭 **Pages sœurs** : [Authenticators](authenticators.md) · [Autorisation](authorization.md)
|
|
543
|
+
|
|
544
|
+
- Vue du module → [index](./index.md) · Autorisation (voters, rôles, scopes) → [authorization](./authorization.md)
|
|
545
|
+
- JWT/OAuth2/WebAuthn/TOTP/API keys en détail → pages dédiées du module
|
|
546
|
+
- Où le firewall s'insère → [pipeline-requete](../../../../../docs/architecture/pipeline-requete.md)
|