@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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { NODEFONY_CHANNEL_NAMESPACE, PLATFORM_CHANNELS, PLATFORM_INBOUND, startsWithCI } from "nodefony";
|
|
2
|
+
//#region nodefony/src/realtime/frameAuthorizer.ts
|
|
3
|
+
/**
|
|
4
|
+
* Politique système par défaut des canaux d'**introspection serveur** : réservés
|
|
5
|
+
* aux administrateurs. DURCISSEMENT Zero Trust (P6) : avant, « authentifié
|
|
6
|
+
* suffisait » (tout `ROLE_USER` lisait `nodefony:syslog`) ; désormais `ROLE_ADMIN`.
|
|
7
|
+
* Surchargeable finement par `realtimeChannels` (ex. `ROLE_SECURITY_AUDITOR`).
|
|
8
|
+
*/
|
|
9
|
+
const SYSTEM_CHANNEL_POLICY = {
|
|
10
|
+
authenticated: true,
|
|
11
|
+
roles: ["ROLE_ADMIN"]
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Namespace d'introspection serveur (observabilité) — s'y abonner expose l'état
|
|
15
|
+
* interne du pod : journaux (`nodefony:syslog`), base (`nodefony:orm:*`), métriques
|
|
16
|
+
* et supervision (`nodefony:dashboard`, `nodefony:supervision@<pid>`), sonde de la
|
|
17
|
+
* socket (`nodefony:socket`), contrôle du pod (`nodefony:kernel:gc` force un GC
|
|
18
|
+
* bloquant). Liste extensible via la config. Convention transverse Nodefony :
|
|
19
|
+
* `<module>:health` / `<module>:stats` (gérée à part dans {@link matchSystemPolicy}).
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ Couplage ASSUMÉ : security connaît le namespace système de la plateforme
|
|
22
|
+
* (c'est son rôle de définir la politique) — mais il ne le REDÉCLARE pas : la
|
|
23
|
+
* constante vient du cœur, comme côté hub.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_SYSTEM_PREFIXES = [NODEFONY_CHANNEL_NAMESPACE];
|
|
26
|
+
/**
|
|
27
|
+
* Plancher des canaux de **sécurité** (`nodefony:audit`, P6.14 lot 4) : réservé au
|
|
28
|
+
* super-admin Nodefony (`ROLE_NODEFONY_ADMIN`) — un cran AU-DESSUS du plancher
|
|
29
|
+
* d'observabilité générique (`ROLE_ADMIN`). Le journal d'audit du pod ne se lit
|
|
30
|
+
* pas avec un simple rôle admin applicatif. Cohérent avec le data plane HTTP de
|
|
31
|
+
* l'audit (`SecurityAdminApi`, lot 3, même rôle).
|
|
32
|
+
*
|
|
33
|
+
* Multi-tenant (futur) : `nodefony:audit` reste un canal **plateforme** (pod),
|
|
34
|
+
* jamais exposé à un user tenant ; l'événement portera le `tenantId` (via l'ALS)
|
|
35
|
+
* pour permettre un filtrage par tenant quand le chantier multi-tenant arrivera.
|
|
36
|
+
*/
|
|
37
|
+
const SECURITY_CHANNEL_POLICY = {
|
|
38
|
+
authenticated: true,
|
|
39
|
+
roles: ["ROLE_NODEFONY_ADMIN"]
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Politique du canal **MONTANT** des journaux du navigateur
|
|
43
|
+
* ({@link PLATFORM_INBOUND.syslogUplink}) : authentifié, sans rôle particulier.
|
|
44
|
+
*
|
|
45
|
+
* Pourquoi il échappe à {@link SYSTEM_CHANNEL_POLICY} alors qu'il porte la même marque :
|
|
46
|
+
* ce plancher-là protège la **lecture** de l'état interne du pod — s'abonner à
|
|
47
|
+
* `nodefony:syslog` fait sortir les journaux du serveur. Le canal montant ne rend rien ;
|
|
48
|
+
* il ACCEPTE. Lui demander `ROLE_ADMIN` ne le rendrait pas plus sûr, cela le rendrait
|
|
49
|
+
* inutile : on ne recueillerait que les erreurs survenues chez les administrateurs, quand
|
|
50
|
+
* tout l'intérêt est de voir celles que subissent les utilisateurs.
|
|
51
|
+
*
|
|
52
|
+
* Les dangers propres à une surface d'écriture — noyer le journal, y fabriquer des
|
|
53
|
+
* pistes — se traitent là où ils se posent : origine forcée par le serveur, débit et
|
|
54
|
+
* taille bornés par connexion, sévérité plafonnée (`createSyslogUplinkHandler`).
|
|
55
|
+
*
|
|
56
|
+
* Le plancher irréductible reste respecté, et non contourné : `authenticated` est exigé,
|
|
57
|
+
* donc une connexion ANONYME ne pousse rien. Limite assumée, à connaître avant de
|
|
58
|
+
* chercher un journal qui n'existe pas : **les erreurs d'un visiteur non connecté ne
|
|
59
|
+
* remontent pas** — celles de la page de connexion, notamment.
|
|
60
|
+
*/
|
|
61
|
+
const UPLINK_CHANNEL_POLICY = { authenticated: true };
|
|
62
|
+
/**
|
|
63
|
+
* F2 (revue 0.6) — PLANCHER IRRÉDUCTIBLE du namespace réservé plateforme. Il
|
|
64
|
+
* couvre tout ce qui expose l'état interne du pod (logs, audit, métriques,
|
|
65
|
+
* requêtes, supervision) : une règle de config `realtimeChannels` (placée AVANT
|
|
66
|
+
* les défauts, 1ᵉʳ match gagne) pourrait sinon l'OUVRIR à l'anonyme
|
|
67
|
+
* (`{ authenticated:false }` ou policy vide). Le plancher garantit qu'un canal de
|
|
68
|
+
* ce namespace exige TOUJOURS au moins `authenticated` — la config peut RESSERRER
|
|
69
|
+
* (rôle/scope) ou re-cibler le rôle, jamais DESCENDRE sous authenticated. Le canal
|
|
70
|
+
* d'audit en fait partie (son défaut ROLE_NODEFONY_ADMIN est déjà au-dessus, mais
|
|
71
|
+
* le plancher le blinde contre une surcharge de config). Défense structurelle,
|
|
72
|
+
* fail-closed (cf F1 fail-loud).
|
|
73
|
+
*/
|
|
74
|
+
const RESERVED_FLOOR_PREFIXES = DEFAULT_SYSTEM_PREFIXES;
|
|
75
|
+
/**
|
|
76
|
+
* Règles système par défaut. Le canal d'audit est placé EN TÊTE (1ᵉʳ match gagne)
|
|
77
|
+
* avec son plancher super-admin propre ; le reste du namespace plateforme hérite de
|
|
78
|
+
* {@link SYSTEM_CHANNEL_POLICY}. Le firewall y préfixe les règles issues de la
|
|
79
|
+
* config (qui gagnent par ordre).
|
|
80
|
+
*/
|
|
81
|
+
const DEFAULT_SYSTEM_RULES = buildSystemRules(RESERVED_FLOOR_PREFIXES);
|
|
82
|
+
/**
|
|
83
|
+
* Construit les règles système à partir d'une liste de namespaces réservés.
|
|
84
|
+
*
|
|
85
|
+
* Sépare la LISTE (quels namespaces sont réservés — propriété du hub realtime,
|
|
86
|
+
* qui sert ces canaux) de la POLITIQUE (quels droits — propriété de la sécurité).
|
|
87
|
+
* Le firewall appelle donc cette fabrique avec la liste que le hub lui donne, et
|
|
88
|
+
* ne redéclare rien : un namespace ajouté côté realtime hérite automatiquement
|
|
89
|
+
* d'une politique, au lieu de rester ouvert sans que personne ne le remarque.
|
|
90
|
+
*
|
|
91
|
+
* Le canal d'audit est placé EN TÊTE (premier match gagnant) : son plancher est
|
|
92
|
+
* plus haut que celui du reste de l'observabilité. Il n'est pas un namespace mais
|
|
93
|
+
* un canal précis — sa règle n'est donc posée que si la liste reçue le COUVRE : si
|
|
94
|
+
* le hub cessait un jour de réserver ce territoire, la sécurité cesserait avec lui
|
|
95
|
+
* de prétendre l'arbitrer, au lieu de garder une règle orpheline.
|
|
96
|
+
*
|
|
97
|
+
* @param prefixes - namespaces réservés (ordre indifférent).
|
|
98
|
+
* @returns les règles, canal d'audit d'abord.
|
|
99
|
+
*/
|
|
100
|
+
function buildSystemRules(prefixes) {
|
|
101
|
+
const rules = [];
|
|
102
|
+
const audit = PLATFORM_CHANNELS.audit;
|
|
103
|
+
if (prefixes.some((prefix) => startsWithCI(audit, prefix))) rules.push({
|
|
104
|
+
prefix: audit,
|
|
105
|
+
policy: SECURITY_CHANNEL_POLICY
|
|
106
|
+
});
|
|
107
|
+
const uplink = PLATFORM_INBOUND.syslogUplink;
|
|
108
|
+
if (prefixes.some((prefix) => startsWithCI(uplink, prefix))) rules.push({
|
|
109
|
+
prefix: uplink,
|
|
110
|
+
policy: UPLINK_CHANNEL_POLICY
|
|
111
|
+
});
|
|
112
|
+
for (const prefix of prefixes) rules.push({
|
|
113
|
+
prefix,
|
|
114
|
+
policy: SYSTEM_CHANNEL_POLICY
|
|
115
|
+
});
|
|
116
|
+
return rules;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* `s` contient-il `needle`, insensible à la casse et sans allocation ? Pour les
|
|
120
|
+
* conventions transverses `:health`/`:stats` (après le namespace de module —
|
|
121
|
+
* `mymod:health` — éventuellement suffixées d'une cadence `nodefony:orm:health:5000`).
|
|
122
|
+
*/
|
|
123
|
+
function containsCI(s, needle) {
|
|
124
|
+
const last = s.length - needle.length;
|
|
125
|
+
for (let i = 0; i <= last; i++) {
|
|
126
|
+
let ok = true;
|
|
127
|
+
for (let j = 0; j < needle.length; j++) {
|
|
128
|
+
let a = s.charCodeAt(i + j);
|
|
129
|
+
if (a >= 65 && a <= 90) a += 32;
|
|
130
|
+
let b = needle.charCodeAt(j);
|
|
131
|
+
if (b >= 65 && b <= 90) b += 32;
|
|
132
|
+
if (a !== b) {
|
|
133
|
+
ok = false;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (ok) return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Politique système applicable à un canal, ou `null` si le canal n'est pas
|
|
143
|
+
* réservé. Premier préfixe qui matche gagne (la config est placée AVANT les
|
|
144
|
+
* défauts par le firewall → elle peut surcharger). Les conventions transverses
|
|
145
|
+
* `:health`/`:stats` retombent sur la politique système par défaut. Le match est
|
|
146
|
+
* INSENSIBLE À LA CASSE (cf {@link startsWithCI}) : le plancher ne se contourne
|
|
147
|
+
* pas en altérant la casse du namespace.
|
|
148
|
+
*/
|
|
149
|
+
function matchSystemPolicy(channel, rules) {
|
|
150
|
+
for (let i = 0; i < rules.length; i++) if (startsWithCI(channel, rules[i].prefix)) return floorReserved(channel, rules[i].policy);
|
|
151
|
+
if (containsCI(channel, ":health") || containsCI(channel, ":stats")) return SYSTEM_CHANNEL_POLICY;
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* F2 (revue 0.6) — applique le PLANCHER irréductible : si `channel` est dans un
|
|
156
|
+
* namespace réservé plateforme ({@link RESERVED_FLOOR_PREFIXES}), l'autorisation
|
|
157
|
+
* effective exige AU MOINS `authenticated`, même si la `policy` (issue d'une règle
|
|
158
|
+
* de config) tente de l'ouvrir. Basé sur le namespace du CANAL, PAS sur le prefixe
|
|
159
|
+
* de la règle qui a matché → pas de contournement via un prefixe de config plus
|
|
160
|
+
* court/altéré (`{ prefix:"sec", authenticated:false }` sur `nodefony:audit`).
|
|
161
|
+
* `policy.authenticated` déjà vrai (cas nominal, défauts + `:health`) → retour tel
|
|
162
|
+
* quel, 0 allocation. N'alloue que sur une config qui tente de DESSERRER un
|
|
163
|
+
* namespace réservé (cold path de misconfiguration).
|
|
164
|
+
*/
|
|
165
|
+
function floorReserved(channel, policy) {
|
|
166
|
+
if (policy.authenticated) return policy;
|
|
167
|
+
for (let i = 0; i < RESERVED_FLOOR_PREFIXES.length; i++) if (startsWithCI(channel, RESERVED_FLOOR_PREFIXES[i])) return {
|
|
168
|
+
...policy,
|
|
169
|
+
authenticated: true
|
|
170
|
+
};
|
|
171
|
+
return policy;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Le token satisfait-il la politique ? Contraintes cumulatives (ET) ; un champ
|
|
175
|
+
* absent = pas de contrainte sur cet axe. SYNC, 0 lecture base (lit le token déjà
|
|
176
|
+
* résolu au handshake).
|
|
177
|
+
* - `authenticated` : token non anonyme.
|
|
178
|
+
* - `roles` : l'un des rôles requis, hiérarchie comprise (un anonyme `[]` échoue).
|
|
179
|
+
* - `scopes` : l'un des scopes requis (session BFF n'en porte pas → refus).
|
|
180
|
+
*/
|
|
181
|
+
function satisfies(policy, token, firewall) {
|
|
182
|
+
if (policy.authenticated && !token.isAuthenticated()) return false;
|
|
183
|
+
if (policy.roles && policy.roles.length > 0) {
|
|
184
|
+
const userRoles = token.getRoles();
|
|
185
|
+
let granted = false;
|
|
186
|
+
for (let i = 0; i < policy.roles.length; i++) if (firewall.hasRole(userRoles, policy.roles[i])) {
|
|
187
|
+
granted = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
if (!granted) return false;
|
|
191
|
+
}
|
|
192
|
+
if (policy.scopes && policy.scopes.length > 0) {
|
|
193
|
+
const userScopes = token.getScopes();
|
|
194
|
+
let granted = false;
|
|
195
|
+
for (let i = 0; i < policy.scopes.length; i++) if (userScopes.includes(policy.scopes[i])) {
|
|
196
|
+
granted = true;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
if (!granted) return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Verrou `api.request {path}` — vérifie l'autorisation de ZONE du pathname (le
|
|
205
|
+
* MÊME re-match de zone qu'un `GET {path}` ; ne regarde PAS la méthode logique).
|
|
206
|
+
* Une zone protégée + un token anonyme = refus. Les mutations (POST/PUT/PATCH/
|
|
207
|
+
* DELETE) restent possibles via le pont MAIS seulement si la route déclare le
|
|
208
|
+
* transport WEBSOCKET + `methodOverride` (une route REST HTTP-only = 405,
|
|
209
|
+
* inatteignable), et sont gardées EN PLUS par `@IsGranted` + clé d'idempotence au
|
|
210
|
+
* data plane. Le verrou n'accorde donc que le plancher de ZONE, pas l'action.
|
|
211
|
+
*/
|
|
212
|
+
function authorizeApiRequest(firewall, params, token, onDeny) {
|
|
213
|
+
const path = params?.path;
|
|
214
|
+
if (typeof path !== "string") return true;
|
|
215
|
+
const qi = path.indexOf("?");
|
|
216
|
+
const pathname = qi === -1 ? path : path.slice(0, qi);
|
|
217
|
+
const area = firewall.matchPath(pathname);
|
|
218
|
+
if (area && area.security && !token.isAuthenticated()) {
|
|
219
|
+
onDeny?.("api.request", pathname, "zone_protected", token);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Verrou `subscribe {channel}` (et canaux inbound full-duplex) — applique la
|
|
226
|
+
* politique du canal. PLANCHER système prioritaire (namespace réservé) ; sinon
|
|
227
|
+
* politique métier déclarée (`@RealtimeChannel`) ; sinon canal libre.
|
|
228
|
+
*/
|
|
229
|
+
function authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny) {
|
|
230
|
+
const policy = matchSystemPolicy(channel, systemRules) ?? resolver?.resolveChannelPolicy?.(channel) ?? null;
|
|
231
|
+
if (policy === null) return true;
|
|
232
|
+
if (satisfies(policy, token, firewall)) return true;
|
|
233
|
+
onDeny?.("channel", channel, "channel_policy", token);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Construit le verrou de frame WS branché sur le hub realtime par le firewall au
|
|
238
|
+
* boot (`RealtimeService.setFrameAuthorizer`). SYNC, 0 lecture base : lit le
|
|
239
|
+
* token déjà résolu au handshake et matche la cible de la frame contre la zone
|
|
240
|
+
* (api.request) ou la politique du canal (subscribe/inbound).
|
|
241
|
+
*
|
|
242
|
+
* Trois surfaces gardées :
|
|
243
|
+
* - `api.request {path}` (pont API souverain) : re-match de zone HTTP → zone
|
|
244
|
+
* protégée + anonyme = refus (autorisation de ZONE, identique à `GET {path}` ;
|
|
245
|
+
* la méthode/action est gardée en aval par le router + `@IsGranted`).
|
|
246
|
+
* - `subscribe {channel}` : politique de canal (système plancher + déclaration
|
|
247
|
+
* métier `@RealtimeChannel` → rôles/scopes).
|
|
248
|
+
* - inbound (`method` = canal full-duplex déclaré avec policy) : même politique
|
|
249
|
+
* que `subscribe` — un client ne pousse pas sur un canal protégé sans droit.
|
|
250
|
+
*
|
|
251
|
+
* Toute autre frame (`ping`, `unsubscribe`, action explicitement ouverte) passe — le verrou
|
|
252
|
+
* cible les surfaces qui atteignent le data plane / l'observabilité / un canal
|
|
253
|
+
* protégé.
|
|
254
|
+
*
|
|
255
|
+
* @param firewall - matcher de zone + checker de rôle (le `Firewall`).
|
|
256
|
+
* @param options - `channelResolver` (politiques métier déclarées, via le
|
|
257
|
+
* service realtime) + `systemRules` (défauts + config) +
|
|
258
|
+
* `onDeny` (rapporteur d'audit, invoqué sur refus seulement).
|
|
259
|
+
* @returns un {@link FrameAuthorizer} sync (`true` = frame autorisée).
|
|
260
|
+
*/
|
|
261
|
+
function buildFrameAuthorizer(firewall, options) {
|
|
262
|
+
const resolver = options?.channelResolver ?? null;
|
|
263
|
+
const systemRules = options?.systemRules ?? DEFAULT_SYSTEM_RULES;
|
|
264
|
+
const onDeny = options?.onDeny;
|
|
265
|
+
return (frame, token) => {
|
|
266
|
+
const f = frame;
|
|
267
|
+
const method = f?.method;
|
|
268
|
+
if (method === "api.request") return authorizeApiRequest(firewall, f.params, token, onDeny);
|
|
269
|
+
if (method === "subscribe") {
|
|
270
|
+
const channel = f.params?.channel;
|
|
271
|
+
if (typeof channel !== "string") return true;
|
|
272
|
+
return authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny);
|
|
273
|
+
}
|
|
274
|
+
if (typeof method === "string") return authorizeChannel(method, token, firewall, resolver, systemRules, onDeny);
|
|
275
|
+
return true;
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
export { DEFAULT_SYSTEM_PREFIXES, DEFAULT_SYSTEM_RULES, RESERVED_FLOOR_PREFIXES, SECURITY_CHANNEL_POLICY, SYSTEM_CHANNEL_POLICY, UPLINK_CHANNEL_POLICY, buildFrameAuthorizer, buildFrameAuthorizer as default, buildSystemRules };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
|
+
import { UserNotFoundError } from "@nodefony/user";
|
|
3
|
+
//#region nodefony/src/sessionIdentity.ts
|
|
4
|
+
const INVALID_SESSION = "Invalid session";
|
|
5
|
+
/**
|
|
6
|
+
* Résout l'identité portée par une session (l'identifiant stocké dans le blob)
|
|
7
|
+
* en utilisateur VIVANT — re-fetch systématique auprès du provider.
|
|
8
|
+
*
|
|
9
|
+
* C'est LE choix structurant de la session BFF : la session ne stocke que
|
|
10
|
+
* l'identifiant, jamais l'utilisateur sérialisé. Rôles toujours frais, et un
|
|
11
|
+
* compte verrouillé/désactivé entre deux requêtes est rejeté immédiatement
|
|
12
|
+
* (révocation effective — l'argument décisif face au JWT côté web).
|
|
13
|
+
*
|
|
14
|
+
* Partagé par le `SessionAuthenticator` (requêtes en zone) et `AuthFlow.me()`
|
|
15
|
+
* (hors zone) : une seule source de vérité des contrôles d'état du compte.
|
|
16
|
+
*
|
|
17
|
+
* @param provider - source d'identité (`UserService` via le container).
|
|
18
|
+
* @param identifier - identifiant fonctionnel stocké en session.
|
|
19
|
+
* @returns l'utilisateur actif.
|
|
20
|
+
* @throws AuthenticationError (401, message uniforme) — identifiant inconnu,
|
|
21
|
+
* compte verrouillé ou désactivé.
|
|
22
|
+
*/
|
|
23
|
+
async function resolveSessionIdentity(provider, identifier) {
|
|
24
|
+
let user;
|
|
25
|
+
try {
|
|
26
|
+
user = await provider.loadUserByIdentifier(identifier);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof UserNotFoundError) throw new AuthenticationError(INVALID_SESSION);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
if (user.isLocked() || !user.isActive()) throw new AuthenticationError(INVALID_SESSION);
|
|
32
|
+
return user;
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
export { resolveSessionIdentity };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//#region nodefony/src/throttle/LoginThrottler.ts
|
|
2
|
+
const DEFAULT_FREE_ATTEMPTS = 3;
|
|
3
|
+
const DEFAULT_BASE_DELAY_S = 1;
|
|
4
|
+
const DEFAULT_CAP_DELAY_S = 900;
|
|
5
|
+
const DEFAULT_MAX_TRACKED = 1e4;
|
|
6
|
+
/**
|
|
7
|
+
* Limiteur de tentatives de login **en mémoire, par identifiant saisi**.
|
|
8
|
+
*
|
|
9
|
+
* La clé est l'identifiant TEL QUE SAISI (existant ou non en base) : un
|
|
10
|
+
* identifiant martelé est ralenti qu'il corresponde à un compte ou pas — aucun
|
|
11
|
+
* oracle d'énumération. Par-processus (V1) : en cluster, chaque worker porte son
|
|
12
|
+
* compteur (l'attaquant gagne ×N workers — acceptable car le backoff est
|
|
13
|
+
* exponentiel ; un backend partagé type Redis se branchera derrière la même
|
|
14
|
+
* interface sans toucher l'authenticator).
|
|
15
|
+
*
|
|
16
|
+
* Coût borné (règle perf Nodefony) : `Map` allouée LAZY au premier échec (une
|
|
17
|
+
* app sans échec de login ne paie rien), AUCUN timer — l'expiration est évaluée
|
|
18
|
+
* à la lecture et les entrées mortes sont purgées par balayage opportuniste
|
|
19
|
+
* quand la borne `maxTracked` est atteinte (puis éviction FIFO en dernier
|
|
20
|
+
* recours : ~10 000 × ~100 B ≈ 1 MB au pire).
|
|
21
|
+
*/
|
|
22
|
+
var LoginThrottler = class {
|
|
23
|
+
freeAttempts;
|
|
24
|
+
baseDelayS;
|
|
25
|
+
capDelayS;
|
|
26
|
+
maxTracked;
|
|
27
|
+
#entries = null;
|
|
28
|
+
#now;
|
|
29
|
+
constructor(options = {}, now = Date.now) {
|
|
30
|
+
this.freeAttempts = options.freeAttempts ?? DEFAULT_FREE_ATTEMPTS;
|
|
31
|
+
this.baseDelayS = options.baseDelayS ?? DEFAULT_BASE_DELAY_S;
|
|
32
|
+
this.capDelayS = options.capDelayS ?? DEFAULT_CAP_DELAY_S;
|
|
33
|
+
this.maxTracked = options.maxTracked ?? DEFAULT_MAX_TRACKED;
|
|
34
|
+
this.#now = now;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* L'identifiant peut-il tenter un login maintenant ?
|
|
38
|
+
*
|
|
39
|
+
* @param identifier - identifiant tel que saisi.
|
|
40
|
+
* @returns `0` si autorisé, sinon les secondes restantes (→ `Retry-After`,
|
|
41
|
+
* arrondies à l'entier supérieur).
|
|
42
|
+
*/
|
|
43
|
+
check(identifier) {
|
|
44
|
+
const entry = this.#entries?.get(identifier);
|
|
45
|
+
if (entry === void 0) return 0;
|
|
46
|
+
const remainingMs = entry.blockedUntil - this.#now();
|
|
47
|
+
return remainingMs > 0 ? Math.ceil(remainingMs / 1e3) : 0;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Enregistre un échec : au-delà de `freeAttempts`, arme un délai exponentiel
|
|
51
|
+
* `baseDelayS × 2^(échecs - freeAttempts - 1)`, plafonné à `capDelayS`.
|
|
52
|
+
*
|
|
53
|
+
* @param identifier - identifiant tel que saisi.
|
|
54
|
+
*/
|
|
55
|
+
recordFailure(identifier) {
|
|
56
|
+
const entries = this.#entries ??= /* @__PURE__ */ new Map();
|
|
57
|
+
let entry = entries.get(identifier);
|
|
58
|
+
if (entry === void 0) {
|
|
59
|
+
if (entries.size >= this.maxTracked) this.#evict(entries);
|
|
60
|
+
entry = {
|
|
61
|
+
failures: 0,
|
|
62
|
+
blockedUntil: 0
|
|
63
|
+
};
|
|
64
|
+
entries.set(identifier, entry);
|
|
65
|
+
}
|
|
66
|
+
entry.failures += 1;
|
|
67
|
+
const over = entry.failures - this.freeAttempts;
|
|
68
|
+
if (over > 0) {
|
|
69
|
+
const delayS = Math.min(this.baseDelayS * 2 ** (over - 1), this.capDelayS);
|
|
70
|
+
entry.blockedUntil = this.#now() + delayS * 1e3;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Login réussi : oublie l'identifiant (le délai repart de zéro — NIST :
|
|
75
|
+
* l'utilisateur légitime ne traîne pas la dette d'un attaquant passé).
|
|
76
|
+
*
|
|
77
|
+
* @param identifier - identifiant tel que saisi.
|
|
78
|
+
*/
|
|
79
|
+
recordSuccess(identifier) {
|
|
80
|
+
this.#entries?.delete(identifier);
|
|
81
|
+
}
|
|
82
|
+
/** Nombre d'identifiants actuellement suivis (introspection / tests). */
|
|
83
|
+
get trackedCount() {
|
|
84
|
+
return this.#entries?.size ?? 0;
|
|
85
|
+
}
|
|
86
|
+
#evict(entries) {
|
|
87
|
+
const now = this.#now();
|
|
88
|
+
for (const [key, entry] of entries) if (entry.blockedUntil <= now) entries.delete(key);
|
|
89
|
+
let excess = entries.size - this.maxTracked + 1;
|
|
90
|
+
if (excess > 0) for (const key of entries.keys()) {
|
|
91
|
+
entries.delete(key);
|
|
92
|
+
if ((excess -= 1) <= 0) break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
export { LoginThrottler, LoginThrottler as default };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { anonymousUser } from "@nodefony/user";
|
|
2
|
+
//#region nodefony/src/token/AnonymousToken.ts
|
|
3
|
+
/**
|
|
4
|
+
* Token du visiteur non authentifié — Zero Trust : un visiteur EST un utilisateur
|
|
5
|
+
* anonyme (jamais `null`).
|
|
6
|
+
*
|
|
7
|
+
* Porte le singleton gelé `anonymousUser` → **zéro allocation d'utilisateur** par
|
|
8
|
+
* requête non authentifiée (hot path). Les attributs sont lazy (`null` tant qu'on
|
|
9
|
+
* n'en pose pas).
|
|
10
|
+
*/
|
|
11
|
+
var AnonymousToken = class {
|
|
12
|
+
type = "anonymous";
|
|
13
|
+
#attributes = null;
|
|
14
|
+
getUser() {
|
|
15
|
+
return anonymousUser;
|
|
16
|
+
}
|
|
17
|
+
getUserIdentifier() {
|
|
18
|
+
return anonymousUser.identifier;
|
|
19
|
+
}
|
|
20
|
+
isAuthenticated() {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
getRoles() {
|
|
24
|
+
return [...anonymousUser.roles];
|
|
25
|
+
}
|
|
26
|
+
getCredentials() {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
getScopes() {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
getAttribute(key) {
|
|
33
|
+
return this.#attributes?.get(key);
|
|
34
|
+
}
|
|
35
|
+
setAttribute(key, value) {
|
|
36
|
+
(this.#attributes ??= /* @__PURE__ */ new Map()).set(key, value);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { AnonymousToken, AnonymousToken as default };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { messageNonRestreint, modeNonRestreintAsync, readIfPresent, writeSecret } from "./secretFile.js";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
//#region nodefony/src/token/JwtKeystore.ts
|
|
4
|
+
/**
|
|
5
|
+
* Keystore Ed25519 — implémentation de référence d'{@link IJwtKeystore}.
|
|
6
|
+
*
|
|
7
|
+
* Résout la clé de signature selon une **priorité** (jamais d'auto-génération en
|
|
8
|
+
* clair « par défaut » en prod) :
|
|
9
|
+
* 1. **env** — `config.jwt.keystore.keySetJson` (JWK Set injecté par l'app depuis
|
|
10
|
+
* son catalogue d'env) : prod cloud, secret géré hors-app, même clé sur tous
|
|
11
|
+
* les pods.
|
|
12
|
+
* 2. **fichier** — `config.jwt.keystore.dir/keyset.json` (écrit en mode 600,
|
|
13
|
+
* généré si absent) : opt-in dev/VPS mono-machine. Le mode effectif est
|
|
14
|
+
* **constaté** après coup : un système de fichiers qui n'applique pas les
|
|
15
|
+
* permissions POSIX (NTFS, FAT, NFS sans mapping) déclenche un **warning**
|
|
16
|
+
* plutôt qu'une garantie silencieusement fausse.
|
|
17
|
+
* 3. **mémoire** — aucune source → clé éphémère générée au 1ᵉʳ usage + **warning**
|
|
18
|
+
* (perdue au redémarrage = refresh invalidés, incohérente en cluster).
|
|
19
|
+
*
|
|
20
|
+
* jose est importé **paresseusement** (dep lourde — règle perf P6) au premier
|
|
21
|
+
* usage ; le boot ne paie rien si le JWT n'est jamais sollicité. Le chargement
|
|
22
|
+
* est mémoïsé (une seule résolution concurrente).
|
|
23
|
+
*
|
|
24
|
+
* @remarks Race au 1ᵉʳ boot d'un **cluster** sans clé pré-provisionnée : deux
|
|
25
|
+
* workers peuvent générer puis écrire des clés différentes (le dernier `rename`
|
|
26
|
+
* gagne). En prod, provisionner la clé hors-bande (`keySetJson`/SecretProvider
|
|
27
|
+
* P16) élimine ce cas — c'est précisément la source recommandée.
|
|
28
|
+
*/
|
|
29
|
+
var JwtKeystore = class {
|
|
30
|
+
#source;
|
|
31
|
+
#log;
|
|
32
|
+
#keys = [];
|
|
33
|
+
#activeKid = "";
|
|
34
|
+
#ready = null;
|
|
35
|
+
constructor(source, log) {
|
|
36
|
+
this.#source = source;
|
|
37
|
+
this.#log = log;
|
|
38
|
+
}
|
|
39
|
+
async getSigningKey() {
|
|
40
|
+
await this.#ensureLoaded();
|
|
41
|
+
const active = this.#keys.find((k) => k.kid === this.#activeKid);
|
|
42
|
+
if (!active) throw new Error("JwtKeystore: aucune clé de signature active");
|
|
43
|
+
return {
|
|
44
|
+
key: active.privateKey,
|
|
45
|
+
kid: active.kid,
|
|
46
|
+
alg: "EdDSA"
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
async getPublicJWKS() {
|
|
50
|
+
await this.#ensureLoaded();
|
|
51
|
+
return { keys: this.#keys.map((k) => k.publicJwk) };
|
|
52
|
+
}
|
|
53
|
+
/** Charge le keyset une seule fois (mémoïsation de la promesse). */
|
|
54
|
+
#ensureLoaded() {
|
|
55
|
+
return this.#ready ??= this.#load();
|
|
56
|
+
}
|
|
57
|
+
async #load() {
|
|
58
|
+
const jose = await import("jose");
|
|
59
|
+
if (this.#source.keySetJson) {
|
|
60
|
+
await this.#importKeyset(jose, this.#parseKeyset(this.#source.keySetJson));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (this.#source.dir) {
|
|
64
|
+
const file = join(this.#source.dir, "keyset.json");
|
|
65
|
+
const existing = await this.#readFile(file);
|
|
66
|
+
if (existing) {
|
|
67
|
+
await this.#checkRestricted(file);
|
|
68
|
+
await this.#importKeyset(jose, this.#parseKeyset(existing));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const keyset = await this.#generate(jose);
|
|
72
|
+
await this.#writeAtomic(file, JSON.stringify(keyset));
|
|
73
|
+
await this.#importKeyset(jose, keyset);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.#log("JWT keystore: clé de signature ÉPHÉMÈRE en mémoire (perdue au redémarrage → refresh tokens invalidés, incohérente en cluster). Configurez jwt.keystore.dir (dev/VPS) ou jwt.keystore.keySetJson depuis l'env (prod).", "WARNING");
|
|
77
|
+
await this.#importKeyset(jose, await this.#generate(jose));
|
|
78
|
+
}
|
|
79
|
+
/** Génère une nouvelle paire Ed25519 (extractable pour persistance JWK). */
|
|
80
|
+
async #generate(jose) {
|
|
81
|
+
const { publicKey, privateKey } = await jose.generateKeyPair("Ed25519", { extractable: true });
|
|
82
|
+
const publicJwk = await jose.exportJWK(publicKey);
|
|
83
|
+
const kid = await jose.calculateJwkThumbprint(publicJwk, "sha256");
|
|
84
|
+
return {
|
|
85
|
+
active: kid,
|
|
86
|
+
keys: [{
|
|
87
|
+
...await jose.exportJWK(privateKey),
|
|
88
|
+
kid,
|
|
89
|
+
alg: "EdDSA",
|
|
90
|
+
use: "sig",
|
|
91
|
+
createdAt: Date.now()
|
|
92
|
+
}]
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Importe un keyset persisté : privée → `CryptoKey`, public → JWK sans `d`. */
|
|
96
|
+
async #importKeyset(jose, keyset) {
|
|
97
|
+
const loaded = [];
|
|
98
|
+
for (const stored of keyset.keys) {
|
|
99
|
+
const imported = await jose.importJWK(stored, "Ed25519");
|
|
100
|
+
if (!(imported instanceof CryptoKey)) throw new Error(`JwtKeystore: clé asymétrique attendue pour le kid "${stored.kid}"`);
|
|
101
|
+
loaded.push({
|
|
102
|
+
kid: stored.kid,
|
|
103
|
+
privateKey: imported,
|
|
104
|
+
publicJwk: {
|
|
105
|
+
kty: stored.kty,
|
|
106
|
+
crv: stored.crv,
|
|
107
|
+
x: stored.x,
|
|
108
|
+
kid: stored.kid,
|
|
109
|
+
use: "sig",
|
|
110
|
+
alg: "EdDSA"
|
|
111
|
+
},
|
|
112
|
+
createdAt: stored.createdAt ?? Date.now()
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (loaded.length === 0) throw new Error("JwtKeystore: keyset vide");
|
|
116
|
+
this.#keys = loaded;
|
|
117
|
+
this.#activeKid = keyset.active && loaded.some((k) => k.kid === keyset.active) ? keyset.active : loaded[0].kid;
|
|
118
|
+
}
|
|
119
|
+
#parseKeyset(json) {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(json);
|
|
123
|
+
} catch {
|
|
124
|
+
throw new Error("JwtKeystore: keyset JSON invalide");
|
|
125
|
+
}
|
|
126
|
+
const keyset = parsed;
|
|
127
|
+
if (!keyset || !Array.isArray(keyset.keys) || keyset.keys.length === 0 || keyset.keys.some((k) => typeof k.kid !== "string")) throw new Error("JwtKeystore: keyset malformé (champ `keys` non vide avec `kid` requis)");
|
|
128
|
+
return keyset;
|
|
129
|
+
}
|
|
130
|
+
/** Lit un fichier ; `null` si absent (ENOENT), relance toute autre erreur. */
|
|
131
|
+
async #readFile(file) {
|
|
132
|
+
return readIfPresent(file);
|
|
133
|
+
}
|
|
134
|
+
/** Écriture atomique (tmp + rename) en mode 600 — pas de fichier partiel lu. */
|
|
135
|
+
async #writeAtomic(file, data) {
|
|
136
|
+
await writeSecret(file, data);
|
|
137
|
+
this.#log(`JWT keystore: clé Ed25519 générée et persistée (${file}).`, "INFO");
|
|
138
|
+
await this.#checkRestricted(file);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Constate le mode EFFECTIF du fichier de clés et avertit s'il n'est pas 0600.
|
|
142
|
+
*
|
|
143
|
+
* Le mode demandé à l'écriture est une intention, pas une garantie : NTFS
|
|
144
|
+
* (Windows) l'ignore, tout comme un montage FAT/exFAT ou NFS sans mapping
|
|
145
|
+
* d'identité — sous Linux comme ailleurs. Le fichier porte la clé PRIVÉE :
|
|
146
|
+
* si la restriction n'a pas pris, la confidentialité repose sur les droits du
|
|
147
|
+
* dossier, ce qui doit être DIT plutôt que supposé. La capacité se constate,
|
|
148
|
+
* elle ne se déduit pas de `process.platform`.
|
|
149
|
+
*
|
|
150
|
+
* Un `stat` par résolution de keystore (une fois par process, source fichier
|
|
151
|
+
* uniquement) — hors hot-path.
|
|
152
|
+
*/
|
|
153
|
+
async #checkRestricted(file) {
|
|
154
|
+
const mode = await modeNonRestreintAsync(file);
|
|
155
|
+
if (mode === null || mode === void 0) return;
|
|
156
|
+
this.#log(`JWT keystore: ${messageNonRestreint(file, mode)} La clé PRIVÉE y réside : provisionnez-la plutôt hors-bande via jwt.keystore.keySetJson (recommandé en production).`, "WARNING");
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
//#endregion
|
|
160
|
+
export { JwtKeystore, JwtKeystore as default };
|