@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,145 @@
|
|
|
1
|
+
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
2
|
+
import { getAuditStoreFactory, listAuditStores } from "../src/audit/auditStoreRegistry.js";
|
|
3
|
+
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, readStoreLocation, resolveAutoStore } from "nodefony";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
//#region nodefony/service/auditService.ts
|
|
6
|
+
const serviceName = "auditService";
|
|
7
|
+
const GC_INTERVAL_MS = 36e5;
|
|
8
|
+
/**
|
|
9
|
+
* Journal d'audit de sécurité (P6.14) — collecte les **événements** de sécurité
|
|
10
|
+
* (login, refus d'accès, jeton émis/révoqué, défense CSRF/CORS, verrou WS) émis
|
|
11
|
+
* EXPLICITEMENT par le firewall, les authenticators et les controllers. Distinct
|
|
12
|
+
* du log de trafic (`JsonAuditLogger`, P3.1, 1 PDU/requête) : ici on trace les
|
|
13
|
+
* **transitions d'état** de sécurité, jamais le hot-path par requête.
|
|
14
|
+
*
|
|
15
|
+
* Propriétaire du {@link IAuditStore} (référence mémoire append-only) : le pose au
|
|
16
|
+
* container (`auditStore`, consommé par le data plane P6.15) et arme le `gc` de
|
|
17
|
+
* rétention (timer `unref`). Implémente {@link IAuditSink} — `record` est
|
|
18
|
+
* **fire-and-forget** (jamais bloquant) et **no-op à coût nul** si désactivé.
|
|
19
|
+
*
|
|
20
|
+
* Slot : store **pluggable** (ORM/Loki, multi-pod) = lot futur, comme
|
|
21
|
+
* `tokenStoreRegistry`. Le socle n'embarque que la référence mémoire.
|
|
22
|
+
*/
|
|
23
|
+
var AuditService = class extends Service {
|
|
24
|
+
module;
|
|
25
|
+
#store = null;
|
|
26
|
+
#enabled = false;
|
|
27
|
+
/** Abonnés au flux live — `null` tant qu'aucun (lazy, règle hooks). */
|
|
28
|
+
#listeners = null;
|
|
29
|
+
/** Préfixe d'id unique au process (1 seul `randomBytes` au boot) + compteur. */
|
|
30
|
+
#idPrefix = "";
|
|
31
|
+
#seq = 0;
|
|
32
|
+
#gc = null;
|
|
33
|
+
constructor(module) {
|
|
34
|
+
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
35
|
+
this.module = module;
|
|
36
|
+
this.kernel?.once("onBoot", () => this.#build());
|
|
37
|
+
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
38
|
+
}
|
|
39
|
+
#build() {
|
|
40
|
+
let config;
|
|
41
|
+
try {
|
|
42
|
+
config = defineSecurityConfig(this.options);
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!config.audit.enabled) {
|
|
47
|
+
this.log("audit service idle — désactivé par la config", "DEBUG");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let storeName = config.audit.store;
|
|
51
|
+
let reason = `store explicitement configuré ("${storeName}")`;
|
|
52
|
+
if (storeName === AUTO_STORE) {
|
|
53
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listAuditStores());
|
|
54
|
+
storeName = auto.store;
|
|
55
|
+
reason = auto.reason;
|
|
56
|
+
this.log(`audit.store "auto" → "${storeName}" (${auto.reason})`, "INFO");
|
|
57
|
+
}
|
|
58
|
+
const factory = getAuditStoreFactory(storeName);
|
|
59
|
+
if (!factory) {
|
|
60
|
+
const msg = `audit store "${storeName}" inconnu (enregistrés : ${listAuditStores().join(", ") || "aucun"})`;
|
|
61
|
+
if (this.kernel?.environment === "production") throw new Error(`${msg} — journal de sécurité non collecté : boot avorté.`);
|
|
62
|
+
this.log(`${msg} — audit désactivé (journal de sécurité non collecté)`, "CRITIC");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (storeName === "memory" && this.kernel?.environment === "production") this.log("audit.store \"memory\" en PRODUCTION — journal de sécurité volatil et per-pod : perdu au redémarrage, invisible des autres pods, rétention de conformité impossible. Déclarer une infra durable (NF_DATABASE_URL).", "WARNING");
|
|
66
|
+
this.#enabled = true;
|
|
67
|
+
this.#idPrefix = randomBytes(4).toString("hex");
|
|
68
|
+
this.#store = factory({
|
|
69
|
+
container: this.container,
|
|
70
|
+
config
|
|
71
|
+
});
|
|
72
|
+
this.container?.set("auditStore", this.#store);
|
|
73
|
+
this.kernel?.registerStoreResolution({
|
|
74
|
+
brick: "audit",
|
|
75
|
+
nature: "durable",
|
|
76
|
+
configured: config.audit.store,
|
|
77
|
+
resolved: storeName,
|
|
78
|
+
available: listAuditStores(),
|
|
79
|
+
reason,
|
|
80
|
+
configPath: "security.audit.store",
|
|
81
|
+
location: readStoreLocation(this.#store)
|
|
82
|
+
});
|
|
83
|
+
this.#gc = new GcScheduler({
|
|
84
|
+
intervalS: GC_INTERVAL_MS / 1e3,
|
|
85
|
+
jitter: true,
|
|
86
|
+
run: async () => {
|
|
87
|
+
const purged = await this.#store?.gc();
|
|
88
|
+
if (purged && purged > 0) this.log(`audit gc — ${purged} événement(s) purgé(s)`, "DEBUG");
|
|
89
|
+
},
|
|
90
|
+
onError: (e) => this.log(e, "WARNING")
|
|
91
|
+
});
|
|
92
|
+
this.#gc.start();
|
|
93
|
+
this.log(`audit service ready — store "${config.audit.store}", rétention ${config.audit.retentionDays}j`, "DEBUG");
|
|
94
|
+
}
|
|
95
|
+
#shutdown() {
|
|
96
|
+
this.#gc?.stop();
|
|
97
|
+
this.#gc = null;
|
|
98
|
+
this.#listeners = null;
|
|
99
|
+
this.#store = null;
|
|
100
|
+
this.#enabled = false;
|
|
101
|
+
}
|
|
102
|
+
record(draft) {
|
|
103
|
+
if (!this.#enabled || this.#store === null) return;
|
|
104
|
+
const event = {
|
|
105
|
+
...draft,
|
|
106
|
+
id: `${this.#idPrefix}-${(this.#seq++).toString(36)}`,
|
|
107
|
+
ts: Date.now()
|
|
108
|
+
};
|
|
109
|
+
this.#store.append(event).catch((error) => this.log(error, "ERROR"));
|
|
110
|
+
if (this.#listeners !== null) for (let i = 0; i < this.#listeners.length; i++) try {
|
|
111
|
+
this.#listeners[i](event);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
this.log(error, "ERROR");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
subscribe(listener) {
|
|
117
|
+
if (this.#listeners === null) this.#listeners = [];
|
|
118
|
+
this.#listeners.push(listener);
|
|
119
|
+
let active = true;
|
|
120
|
+
return () => {
|
|
121
|
+
if (!active || this.#listeners === null) return;
|
|
122
|
+
active = false;
|
|
123
|
+
const idx = this.#listeners.indexOf(listener);
|
|
124
|
+
if (idx >= 0) this.#listeners.splice(idx, 1);
|
|
125
|
+
if (this.#listeners.length === 0) this.#listeners = null;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Lit une page du journal (délègue au store) ; vide si l'audit est inactif. */
|
|
129
|
+
listPage(query) {
|
|
130
|
+
if (this.#store === null) return Promise.resolve({
|
|
131
|
+
items: [],
|
|
132
|
+
limit: query.limit,
|
|
133
|
+
hasNext: false,
|
|
134
|
+
nextCursor: null,
|
|
135
|
+
total: 0
|
|
136
|
+
});
|
|
137
|
+
return this.#store.listPage(query);
|
|
138
|
+
}
|
|
139
|
+
/** `true` si l'audit est actif (config `audit.enabled`). */
|
|
140
|
+
isEnabled() {
|
|
141
|
+
return this.#enabled;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
//#endregion
|
|
145
|
+
export { AuditService as default };
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
|
+
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
3
|
+
import { resolveSessionIdentity } from "../src/sessionIdentity.js";
|
|
4
|
+
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
5
|
+
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
6
|
+
import { RequestContext, Service } from "nodefony";
|
|
7
|
+
//#region nodefony/service/authFlow.ts
|
|
8
|
+
const serviceName = "authFlow";
|
|
9
|
+
const INVALID_CREDENTIALS = "Invalid credentials";
|
|
10
|
+
const PENDING_MFA_KEY = "mfa:pending";
|
|
11
|
+
/**
|
|
12
|
+
* Flux de session BFF — login/logout/me côté serveur (P6 J3).
|
|
13
|
+
*
|
|
14
|
+
* C'est le GUICHET : le credential est présenté UNE fois (`login`), vérifié par
|
|
15
|
+
* le {@link IPasswordVerifier} (hash, leurre anti-timing, re-hash migration),
|
|
16
|
+
* puis remplacé par un cookie de session opaque `HttpOnly` — le mot de passe ne
|
|
17
|
+
* recircule jamais, le navigateur ne stocke aucun token lisible par JS.
|
|
18
|
+
*
|
|
19
|
+
* Anti session-fixation (OWASP) : l'ID de session est TOUJOURS régénéré au
|
|
20
|
+
* login — un ID pré-posé par un attaquant (cookie forcé avant le guichet) ne
|
|
21
|
+
* survit pas à l'authentification ; l'ancienne entrée storage est détruite.
|
|
22
|
+
*
|
|
23
|
+
* Throttling NIST SP 800-63B : le MÊME `LoginThrottler` que la porte Basic
|
|
24
|
+
* (instance partagée via le container, posée par le firewall au boot) — un
|
|
25
|
+
* attaquant ne contourne pas le backoff en changeant de porte.
|
|
26
|
+
*
|
|
27
|
+
* Les handlers HTTP (`SessionAuthController`, `@nodefony/framework`) sont des
|
|
28
|
+
* adaptateurs minces au-dessus de ce service — la logique reste testable sans
|
|
29
|
+
* transport.
|
|
30
|
+
*/
|
|
31
|
+
var AuthFlow = class extends Service {
|
|
32
|
+
module;
|
|
33
|
+
#users = null;
|
|
34
|
+
#sessions = null;
|
|
35
|
+
#throttler = null;
|
|
36
|
+
#throttlerResolved = false;
|
|
37
|
+
#totp = null;
|
|
38
|
+
#totpResolved = false;
|
|
39
|
+
constructor(module) {
|
|
40
|
+
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
41
|
+
this.module = module;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Authentifie le couple identifiant/mot de passe et OUVRE la session BFF.
|
|
45
|
+
*
|
|
46
|
+
* Ordre NIST : throttle AVANT le verifier (un identifiant bloqué ne coûte
|
|
47
|
+
* aucun hash argon2 — le backoff protège aussi le serveur du DoS), échec
|
|
48
|
+
* compté, succès remis à zéro.
|
|
49
|
+
*
|
|
50
|
+
* @param context - contexte HTTP courant (porte la session/le cookie).
|
|
51
|
+
* @param identifier - identifiant saisi (body JSON, non typé à la frontière).
|
|
52
|
+
* @param password - mot de passe saisi.
|
|
53
|
+
* @returns `authenticated` (identité établie) ou `mfa_required` (2ᵉ facteur requis).
|
|
54
|
+
* @throws ThrottledError (429 + `Retry-After`) — backoff actif.
|
|
55
|
+
* @throws AuthenticationError (401, message uniforme) — credential absent ou
|
|
56
|
+
* invalide.
|
|
57
|
+
*/
|
|
58
|
+
async login(context, identifier, password) {
|
|
59
|
+
const info = readAuditContext(context);
|
|
60
|
+
const who = typeof identifier === "string" ? identifier : null;
|
|
61
|
+
if (typeof identifier !== "string" || identifier.length === 0 || typeof password !== "string" || password.length === 0) {
|
|
62
|
+
recordAudit(this.container, {
|
|
63
|
+
category: "auth",
|
|
64
|
+
action: "login.failure",
|
|
65
|
+
outcome: "failure",
|
|
66
|
+
actor: who,
|
|
67
|
+
reason: "invalid_credentials",
|
|
68
|
+
...info
|
|
69
|
+
});
|
|
70
|
+
throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
71
|
+
}
|
|
72
|
+
const throttler = this.#resolveThrottler();
|
|
73
|
+
if (throttler !== null) {
|
|
74
|
+
const retryAfterS = throttler.check(identifier);
|
|
75
|
+
if (retryAfterS > 0) {
|
|
76
|
+
recordAudit(this.container, {
|
|
77
|
+
category: "auth",
|
|
78
|
+
action: "login.throttled",
|
|
79
|
+
outcome: "failure",
|
|
80
|
+
actor: identifier,
|
|
81
|
+
reason: "throttled",
|
|
82
|
+
...info
|
|
83
|
+
});
|
|
84
|
+
throw new ThrottledError(retryAfterS);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const user = await this.#resolveUsers().authenticate(identifier, password);
|
|
88
|
+
if (user === null) {
|
|
89
|
+
throttler?.recordFailure(identifier);
|
|
90
|
+
recordAudit(this.container, {
|
|
91
|
+
category: "auth",
|
|
92
|
+
action: "login.failure",
|
|
93
|
+
outcome: "failure",
|
|
94
|
+
actor: identifier,
|
|
95
|
+
reason: "invalid_credentials",
|
|
96
|
+
...info
|
|
97
|
+
});
|
|
98
|
+
throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
99
|
+
}
|
|
100
|
+
throttler?.recordSuccess(identifier);
|
|
101
|
+
const totp = this.#resolveTotp();
|
|
102
|
+
if (totp && await totp.isEnabledFor(user.identifier)) {
|
|
103
|
+
const session = await this.ensureSession(context);
|
|
104
|
+
session?.set(PENDING_MFA_KEY, user.identifier);
|
|
105
|
+
await session?.save();
|
|
106
|
+
recordAudit(this.container, {
|
|
107
|
+
category: "auth",
|
|
108
|
+
action: "login.mfa_required",
|
|
109
|
+
outcome: "success",
|
|
110
|
+
actor: user.identifier,
|
|
111
|
+
reason: "totp",
|
|
112
|
+
...info
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
status: "mfa_required",
|
|
116
|
+
methods: ["totp"]
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
await this.#openSession(context, user.identifier);
|
|
120
|
+
context.user = user.identifier;
|
|
121
|
+
RequestContext.set("user", user);
|
|
122
|
+
recordAudit(this.container, {
|
|
123
|
+
category: "auth",
|
|
124
|
+
action: "login.success",
|
|
125
|
+
outcome: "success",
|
|
126
|
+
actor: user.identifier,
|
|
127
|
+
...info
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
status: "authenticated",
|
|
131
|
+
user: toSafeUser(user)
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Ouvre la session BFF pour un utilisateur **déjà authentifié par un autre
|
|
136
|
+
* facteur** (passkey/WebAuthn, OAuth, magic link…) — aucun mot de passe à
|
|
137
|
+
* vérifier ici, la preuve a été apportée en amont par l'appelant.
|
|
138
|
+
*
|
|
139
|
+
* Même anti-fixation que {@link login} (ID régénéré, ancienne entrée
|
|
140
|
+
* détruite). L'identité est re-résolue + revalidée (compte actif/non
|
|
141
|
+
* verrouillé) via la source unique `resolveSessionIdentity` — un compte banni
|
|
142
|
+
* entre la preuve et l'ouverture de session est rejeté.
|
|
143
|
+
*
|
|
144
|
+
* @param identifier - identifiant de l'utilisateur prouvé (ex. `sub` du credential).
|
|
145
|
+
* @throws AuthenticationError (401, message uniforme) — identifiant absent, ou
|
|
146
|
+
* compte disparu/inactif/verrouillé.
|
|
147
|
+
*/
|
|
148
|
+
async establishSessionFor(context, identifier, reason = "federated") {
|
|
149
|
+
if (typeof identifier !== "string" || identifier.length === 0) throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
150
|
+
const user = await resolveSessionIdentity(this.#resolveUsers(), identifier);
|
|
151
|
+
await this.#openSession(context, user.identifier);
|
|
152
|
+
context.user = user.identifier;
|
|
153
|
+
RequestContext.set("user", user);
|
|
154
|
+
recordAudit(this.container, {
|
|
155
|
+
category: "auth",
|
|
156
|
+
action: "login.success",
|
|
157
|
+
outcome: "success",
|
|
158
|
+
actor: user.identifier,
|
|
159
|
+
reason,
|
|
160
|
+
...readAuditContext(context)
|
|
161
|
+
});
|
|
162
|
+
return toSafeUser(user);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Valide le **second facteur** (code TOTP ou code de récupération) après un
|
|
166
|
+
* `login` ayant renvoyé `mfa_required`, puis OUVRE la session BFF. Le défi
|
|
167
|
+
* PENDING déposé en session par `login` est lu, vérifié, puis invalidé (usage
|
|
168
|
+
* unique) ; l'identité n'est établie qu'ICI. **Throttlé** sur l'identité en
|
|
169
|
+
* attente (anti brute-force du code à 6 chiffres).
|
|
170
|
+
*
|
|
171
|
+
* @param context - contexte HTTP (porte la session PENDING).
|
|
172
|
+
* @param code - code présenté (TOTP ou code de récupération).
|
|
173
|
+
* @returns la projection publique de l'utilisateur authentifié.
|
|
174
|
+
* @throws ThrottledError (429) — trop de tentatives.
|
|
175
|
+
* @throws AuthenticationError (401, uniforme) — aucun défi en cours, code absent
|
|
176
|
+
* ou invalide (la session N'est PAS ouverte ; le défi reste pour un retry).
|
|
177
|
+
*/
|
|
178
|
+
async completeMfaLogin(context, code) {
|
|
179
|
+
const info = readAuditContext(context);
|
|
180
|
+
const pending = context.session?.get(PENDING_MFA_KEY);
|
|
181
|
+
if (typeof pending !== "string" || pending.length === 0) throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
182
|
+
const throttler = this.#resolveThrottler();
|
|
183
|
+
if (throttler !== null) {
|
|
184
|
+
const retryAfterS = throttler.check(pending);
|
|
185
|
+
if (retryAfterS > 0) {
|
|
186
|
+
recordAudit(this.container, {
|
|
187
|
+
category: "auth",
|
|
188
|
+
action: "login.throttled",
|
|
189
|
+
outcome: "failure",
|
|
190
|
+
actor: pending,
|
|
191
|
+
reason: "throttled",
|
|
192
|
+
...info
|
|
193
|
+
});
|
|
194
|
+
throw new ThrottledError(retryAfterS);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const totp = this.#resolveTotp();
|
|
198
|
+
const result = typeof code === "string" && code.length > 0 && totp ? await totp.verifyLogin(pending, code) : {
|
|
199
|
+
ok: false,
|
|
200
|
+
method: void 0
|
|
201
|
+
};
|
|
202
|
+
if (!result.ok) {
|
|
203
|
+
throttler?.recordFailure(pending);
|
|
204
|
+
recordAudit(this.container, {
|
|
205
|
+
category: "auth",
|
|
206
|
+
action: "login.failure",
|
|
207
|
+
outcome: "failure",
|
|
208
|
+
actor: pending,
|
|
209
|
+
reason: "mfa_invalid",
|
|
210
|
+
...info
|
|
211
|
+
});
|
|
212
|
+
throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
213
|
+
}
|
|
214
|
+
throttler?.recordSuccess(pending);
|
|
215
|
+
context.session?.set(PENDING_MFA_KEY, null);
|
|
216
|
+
return this.establishSessionFor(context, pending, result.method ?? "totp");
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Garantit une session pour la requête courante — la démarre (+ cookie) si
|
|
220
|
+
* elle n'existe pas encore. Sert aux cérémonies **pré-authentification**
|
|
221
|
+
* (login WebAuthn) : elles doivent porter un challenge côté serveur AVANT que
|
|
222
|
+
* l'utilisateur soit connecté. La session anonyme ainsi créée devient la
|
|
223
|
+
* session authentifiée au login — {@link establishSessionFor} régénère l'ID
|
|
224
|
+
* (anti-fixation préservée), donc un challenge déposé ici n'ouvre aucune brèche.
|
|
225
|
+
*
|
|
226
|
+
* @returns la session (existante ou neuve), ou `null` si le service de session
|
|
227
|
+
* est indisponible.
|
|
228
|
+
*/
|
|
229
|
+
async ensureSession(context) {
|
|
230
|
+
if (context.session) return context.session;
|
|
231
|
+
return this.#resolveSessions().start(context);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Détruit la session courante (storage + cookie). Idempotent : sans session
|
|
235
|
+
* active, ne fait rien.
|
|
236
|
+
*
|
|
237
|
+
* @returns `true` si une session a réellement été détruite.
|
|
238
|
+
*/
|
|
239
|
+
async logout(context) {
|
|
240
|
+
const session = context.session;
|
|
241
|
+
if (!session || session.status !== "active") return false;
|
|
242
|
+
const actor = typeof session.user === "string" ? session.user : null;
|
|
243
|
+
await session.destroy(true);
|
|
244
|
+
context.session = null;
|
|
245
|
+
context.user = null;
|
|
246
|
+
recordAudit(this.container, {
|
|
247
|
+
category: "session",
|
|
248
|
+
action: "logout",
|
|
249
|
+
outcome: "success",
|
|
250
|
+
actor,
|
|
251
|
+
...readAuditContext(context)
|
|
252
|
+
});
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Identité portée par la session courante, re-résolue auprès du provider
|
|
257
|
+
* (mêmes contrôles que le `SessionAuthenticator` — source unique).
|
|
258
|
+
*
|
|
259
|
+
* @returns la projection publique, ou `null` (pas de session, session
|
|
260
|
+
* orpheline, compte verrouillé/désactivé) — le handler répond 401.
|
|
261
|
+
*/
|
|
262
|
+
async me(context) {
|
|
263
|
+
const identifier = context.session?.user;
|
|
264
|
+
if (typeof identifier !== "string" || identifier.length === 0) return null;
|
|
265
|
+
try {
|
|
266
|
+
return toSafeUser(await resolveSessionIdentity(this.#resolveUsers(), identifier));
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error instanceof AuthenticationError) return null;
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async #openSession(context, identifier) {
|
|
273
|
+
const session = context.session ?? await this.#resolveSessions().start(context);
|
|
274
|
+
if (session === null) {
|
|
275
|
+
this.log("login: session service returned no session", "ERROR");
|
|
276
|
+
throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
277
|
+
}
|
|
278
|
+
const oldId = session.id;
|
|
279
|
+
session.regenerateId();
|
|
280
|
+
try {
|
|
281
|
+
await session.storage.destroy(oldId);
|
|
282
|
+
} catch {}
|
|
283
|
+
const provenance = context;
|
|
284
|
+
try {
|
|
285
|
+
const ip = provenance.getRemoteAddress?.();
|
|
286
|
+
if (ip) session.setMetaBag("ip", ip);
|
|
287
|
+
const ua = provenance.getUserAgent?.();
|
|
288
|
+
if (ua) session.setMetaBag("ua", ua);
|
|
289
|
+
} catch {}
|
|
290
|
+
await session.save(identifier);
|
|
291
|
+
}
|
|
292
|
+
#resolveSessions() {
|
|
293
|
+
if (this.#sessions === null) {
|
|
294
|
+
const sessions = this.get("sessions");
|
|
295
|
+
if (!sessions) throw new Error("AuthFlow: aucun service \"sessions\" dans le container — le module @nodefony/http doit être chargé avant @nodefony/security.");
|
|
296
|
+
this.#sessions = sessions;
|
|
297
|
+
}
|
|
298
|
+
return this.#sessions;
|
|
299
|
+
}
|
|
300
|
+
#resolveUsers() {
|
|
301
|
+
if (this.#users === null) {
|
|
302
|
+
const users = this.get("users");
|
|
303
|
+
if (!users) throw new Error("AuthFlow: aucun service \"users\" (IPasswordVerifier & IUserProvider) dans le container — enregistrer un UserService au boot de l'application.");
|
|
304
|
+
this.#users = users;
|
|
305
|
+
}
|
|
306
|
+
return this.#users;
|
|
307
|
+
}
|
|
308
|
+
#resolveThrottler() {
|
|
309
|
+
if (!this.#throttlerResolved) {
|
|
310
|
+
this.#throttler = this.get("loginThrottler") ?? null;
|
|
311
|
+
this.#throttlerResolved = true;
|
|
312
|
+
}
|
|
313
|
+
return this.#throttler;
|
|
314
|
+
}
|
|
315
|
+
#resolveTotp() {
|
|
316
|
+
if (!this.#totpResolved) {
|
|
317
|
+
const svc = this.get("totp") ?? null;
|
|
318
|
+
this.#totp = svc && svc.isEnabled() ? svc : null;
|
|
319
|
+
this.#totpResolved = true;
|
|
320
|
+
}
|
|
321
|
+
return this.#totp;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
function toSafeUser(user) {
|
|
325
|
+
return {
|
|
326
|
+
id: user.id,
|
|
327
|
+
username: user.identifier,
|
|
328
|
+
roles: [...user.roles]
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
export { AuthFlow, AuthFlow as default };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
2
|
+
import "../contracts/IAccessVoter.js";
|
|
3
|
+
import { listVoterFactories } from "../src/voter/voterRegistry.js";
|
|
4
|
+
import { Service, logColor } from "nodefony";
|
|
5
|
+
//#region nodefony/service/authorization.ts
|
|
6
|
+
const serviceName = "authorization";
|
|
7
|
+
/**
|
|
8
|
+
* Service d'autorisation Nodefony (niveau C, P6 J6) — décide d'un accès via un
|
|
9
|
+
* jury de {@link IAccessVoter}.
|
|
10
|
+
*
|
|
11
|
+
* Stratégie **affirmative + DENY veto** : un seul `DENY` bloque (veto) ; sinon un
|
|
12
|
+
* `GRANT` suffit ; **silence total** (tous `ABSTAIN`, ou aucun voter compétent)
|
|
13
|
+
* → `DENY` (**Zero Trust** : fermé par défaut). Tout refus est audité (WARNING) ;
|
|
14
|
+
* les accès accordés restent silencieux (pas de spam — l'audit d'octroi explicite
|
|
15
|
+
* viendra avec `@AuditLog`, P6.14).
|
|
16
|
+
*
|
|
17
|
+
* Les voters sont découverts au boot via le `voterRegistry` (built-in `role` +
|
|
18
|
+
* ceux des apps/plugins) — aucun nom en dur ici. Consommé par les décorateurs
|
|
19
|
+
* (`@IsGranted`, J7) et le verrou de frame WS (« 1 garde = N transports »).
|
|
20
|
+
*
|
|
21
|
+
* Perf : aucune allocation par appel (`decide` itère les voters et teste
|
|
22
|
+
* `supports()` en place) ; les voters sont instanciés UNE fois au boot.
|
|
23
|
+
*/
|
|
24
|
+
var Authorization = class extends Service {
|
|
25
|
+
module;
|
|
26
|
+
#voters = null;
|
|
27
|
+
constructor(module) {
|
|
28
|
+
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
29
|
+
this.module = module;
|
|
30
|
+
this.kernel?.once("onBoot", () => this.#build());
|
|
31
|
+
}
|
|
32
|
+
#build() {
|
|
33
|
+
const list = [];
|
|
34
|
+
const ctx = { container: this.container };
|
|
35
|
+
for (const [, factory] of listVoterFactories()) list.push(factory(ctx));
|
|
36
|
+
this.#voters = list;
|
|
37
|
+
this.log(`Authorization ready — ${list.length} voter(s)`, "DEBUG");
|
|
38
|
+
return list;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Le token a-t-il le droit `attribute` sur `subject` ? Affirmative + DENY veto,
|
|
42
|
+
* défaut `DENY` (Zero Trust). Voir {@link IAuthorizationService.decide}.
|
|
43
|
+
*/
|
|
44
|
+
async decide(token, attribute, subject) {
|
|
45
|
+
const voters = this.#voters ?? this.#build();
|
|
46
|
+
let granted = false;
|
|
47
|
+
let considered = 0;
|
|
48
|
+
for (let i = 0; i < voters.length; i++) {
|
|
49
|
+
const voter = voters[i];
|
|
50
|
+
if (!voter.supports(attribute, subject)) continue;
|
|
51
|
+
considered++;
|
|
52
|
+
let vote;
|
|
53
|
+
try {
|
|
54
|
+
vote = await voter.vote(token, attribute, subject);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.log(error, "ERROR");
|
|
57
|
+
this.#auditDeny(token, attribute, subject, "error");
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (vote === "DENY") {
|
|
61
|
+
this.#auditDeny(token, attribute, subject, "veto");
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (vote === "GRANT") granted = true;
|
|
65
|
+
}
|
|
66
|
+
if (!granted) this.#auditDeny(token, attribute, subject, considered === 0 ? "no-voter" : "abstain");
|
|
67
|
+
return granted;
|
|
68
|
+
}
|
|
69
|
+
#auditDeny(token, attribute, subject, reason) {
|
|
70
|
+
const who = token.getUserIdentifier();
|
|
71
|
+
const on = subject === void 0 ? "" : ` on ${describeSubject(subject)}`;
|
|
72
|
+
this.log(`access denied: "${who}" → "${attribute}"${on} (${reason})`, "WARNING");
|
|
73
|
+
recordAudit(this.container, {
|
|
74
|
+
category: "authz",
|
|
75
|
+
action: "access.denied",
|
|
76
|
+
outcome: "denied",
|
|
77
|
+
actor: who,
|
|
78
|
+
resource: attribute,
|
|
79
|
+
reason,
|
|
80
|
+
metadata: subject === void 0 ? void 0 : { subject: describeSubject(subject) }
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
log(pci, severity, msgid, msg) {
|
|
84
|
+
if (!msgid) msgid = logColor.cyan("AUTHORIZATION");
|
|
85
|
+
return super.log(pci, severity, msgid, msg);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
/** Descripteur léger d'un sujet pour l'audit (jamais de JSON.stringify aveugle). */
|
|
89
|
+
function describeSubject(subject) {
|
|
90
|
+
if (typeof subject === "string") return subject;
|
|
91
|
+
if (subject === null) return "null";
|
|
92
|
+
return subject?.constructor?.name ?? typeof subject;
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
export { Authorization, Authorization as default };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
//#region nodefony/service/cors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Politique CORS de Nodefony — Same-Origin Policy assouplie côté serveur
|
|
4
|
+
* (Fetch Standard / W3C CORS protocol). Logique PURE et synchrone : décide les
|
|
5
|
+
* en-têtes `Access-Control-*` à poser pour une origine donnée. Instanciée une
|
|
6
|
+
* fois au boot par le firewall, testable sans serveur.
|
|
7
|
+
*
|
|
8
|
+
* Invariants de sécurité (OWASP) :
|
|
9
|
+
* - **Jamais `*` + credentials** : interdit au boot (refine Zod). Ici, `*` n'est
|
|
10
|
+
* émis QUE si `credentials=false` ; avec credentials, l'origine est reflétée.
|
|
11
|
+
* - **Reflet d'origine ⇒ `Vary: Origin`** : signalé via {@link reflectsOrigin}
|
|
12
|
+
* pour que l'appelant pose l'en-tête `Vary` (correction de cache).
|
|
13
|
+
* - **Origine non whitelistée ⇒ aucun en-tête** : la réponse n'est pas partageable
|
|
14
|
+
* (le navigateur bloque), zéro information divulguée.
|
|
15
|
+
*
|
|
16
|
+
* @see Fetch Standard (CORS protocol) · OWASP CORS.
|
|
17
|
+
*/
|
|
18
|
+
var Cors = class {
|
|
19
|
+
#origins;
|
|
20
|
+
#wildcard;
|
|
21
|
+
#credentials;
|
|
22
|
+
#methods;
|
|
23
|
+
#allowedHeaders;
|
|
24
|
+
#exposedHeaders;
|
|
25
|
+
#maxAge;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.#origins = new Set(options.origins);
|
|
28
|
+
this.#wildcard = options.origins.includes("*");
|
|
29
|
+
this.#credentials = options.credentials;
|
|
30
|
+
this.#methods = options.methods.join(", ");
|
|
31
|
+
this.#allowedHeaders = options.allowedHeaders.join(", ");
|
|
32
|
+
this.#exposedHeaders = options.exposedHeaders.join(", ");
|
|
33
|
+
this.#maxAge = String(options.maxAgeS);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Valeur de `Access-Control-Allow-Origin` pour cette origine, ou `null` si
|
|
37
|
+
* l'origine n'est pas autorisée. `*` seulement en l'absence de credentials
|
|
38
|
+
* (sinon l'origine est reflétée — `*` + credentials est refusé par le navigateur).
|
|
39
|
+
*/
|
|
40
|
+
#allowOrigin(origin) {
|
|
41
|
+
if (this.#wildcard) return this.#credentials ? origin : "*";
|
|
42
|
+
return this.#origins.has(origin) ? origin : null;
|
|
43
|
+
}
|
|
44
|
+
/** `true` si la valeur Allow-Origin reflète l'origine (⇒ l'appelant doit poser `Vary: Origin`). */
|
|
45
|
+
reflectsOrigin(allowOrigin) {
|
|
46
|
+
return allowOrigin !== "*";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* En-têtes d'une réponse au **preflight** `OPTIONS` (méthodes/headers autorisés
|
|
50
|
+
* + cache + credentials), ou `null` si l'origine n'est pas autorisée (réponse
|
|
51
|
+
* 204 nue → le navigateur bloque).
|
|
52
|
+
*/
|
|
53
|
+
preflightHeaders(origin) {
|
|
54
|
+
const allow = this.#allowOrigin(origin);
|
|
55
|
+
if (allow === null) return null;
|
|
56
|
+
const h = {
|
|
57
|
+
"Access-Control-Allow-Origin": allow,
|
|
58
|
+
"Access-Control-Allow-Methods": this.#methods,
|
|
59
|
+
"Access-Control-Allow-Headers": this.#allowedHeaders,
|
|
60
|
+
"Access-Control-Max-Age": this.#maxAge
|
|
61
|
+
};
|
|
62
|
+
if (this.reflectsOrigin(allow)) h["Vary"] = "Origin";
|
|
63
|
+
if (this.#credentials) h["Access-Control-Allow-Credentials"] = "true";
|
|
64
|
+
return h;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* En-têtes d'une réponse à une **requête réelle** cross-origin (Allow-Origin +
|
|
68
|
+
* credentials + headers exposés au JS), ou `null` si l'origine n'est pas autorisée.
|
|
69
|
+
*/
|
|
70
|
+
actualHeaders(origin) {
|
|
71
|
+
const allow = this.#allowOrigin(origin);
|
|
72
|
+
if (allow === null) return null;
|
|
73
|
+
const h = { "Access-Control-Allow-Origin": allow };
|
|
74
|
+
if (this.reflectsOrigin(allow)) h["Vary"] = "Origin";
|
|
75
|
+
if (this.#credentials) h["Access-Control-Allow-Credentials"] = "true";
|
|
76
|
+
if (this.#exposedHeaders) h["Access-Control-Expose-Headers"] = this.#exposedHeaders;
|
|
77
|
+
return h;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
//#endregion
|
|
81
|
+
export { Cors, Cors as default };
|