@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-token.d.ts +9 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +63 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +125 -9
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +17 -17
- package/docs/audit.md +16 -16
- package/docs/authenticators.md +60 -27
- package/docs/authorization.md +16 -16
- package/docs/cors.md +29 -29
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +66 -4
- package/docs/firewall.md +57 -24
- package/docs/headers.md +30 -30
- package/docs/index.md +12 -4
- package/docs/keycloak.md +818 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +236 -122
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +136 -85
- package/docs/totp.md +105 -64
- package/docs/webauthn.md +90 -87
- package/docs/webhooks.md +3 -3
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +17 -15
package/docs/api-keys.md
CHANGED
|
@@ -239,7 +239,7 @@ raison est dans le code (`apiKeyFormat.ts:8-10`) : le charset base64url contient
|
|
|
239
239
|
|
|
240
240
|
| Morceau | Taille | Secret ? | À quoi ça sert |
|
|
241
241
|
| -------- | -------------------- | :------: | -------------------------------------------------------------------------------------------------- |
|
|
242
|
-
| `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:
|
|
242
|
+
| `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:823`) |
|
|
243
243
|
| `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts:92`) |
|
|
244
244
|
| `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts:30`) |
|
|
245
245
|
| `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts:63`) |
|
|
@@ -330,8 +330,8 @@ zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts:205`).
|
|
|
330
330
|
|
|
331
331
|
L'authenticator n'est jamais instancié à la main : le firewall le construit depuis le registre, en
|
|
332
332
|
lui injectant la config effective — `registerAuthenticatorFactory("apikey")`
|
|
333
|
-
(`authenticatorRegistry.ts:
|
|
334
|
-
(`authenticatorRegistry.ts:
|
|
333
|
+
(`authenticatorRegistry.ts:52`), qui lit `prefix` et `lastUsedThrottleS`
|
|
334
|
+
(`authenticatorRegistry.ts:166`). Conséquence pratique : changer `apiKeys.prefix` change **à la
|
|
335
335
|
fois** l'émission et la reconnaissance — les anciennes clés ne sont plus reconnues.
|
|
336
336
|
|
|
337
337
|
## Quatre parcours vécus
|
|
@@ -376,7 +376,7 @@ soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure
|
|
|
376
376
|
4. **Puis** révoquer la v1.
|
|
377
377
|
|
|
378
378
|
Ce qui rend l'étape 3 fiable : `lastUsedAt` est écrit de façon **throttlée**, pas à chaque requête —
|
|
379
|
-
la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:
|
|
379
|
+
la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:839`). Attends donc une minute
|
|
380
380
|
avant de conclure qu'une clé « ne sert plus ».
|
|
381
381
|
|
|
382
382
|
### Révoquer une clé qui a fuité
|
|
@@ -419,7 +419,7 @@ Trois sources, et il faut connaître les limites de chacune :
|
|
|
419
419
|
/nodefony/security/api/apikeys` (`SecurityAdminApi.ts:394`), servi par `listPagePat()`
|
|
420
420
|
(`apiKeys.ts:209`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
|
|
421
421
|
`order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts:126`), plafonnée à 200 entrées
|
|
422
|
-
(`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:
|
|
422
|
+
(`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:122`).
|
|
423
423
|
|
|
424
424
|
Le tri n'est accepté que sur les champs que le backend branché **déclare** savoir trier
|
|
425
425
|
(`sortableFields()`, `apiKeys.ts:102` → `ITokenStore.sortableFields`) : `createdAt`, `name`,
|
|
@@ -446,17 +446,17 @@ applicatif qu'il faut alimenter, pas le store de jetons.
|
|
|
446
446
|
|
|
447
447
|
## ⚙️ Configuration
|
|
448
448
|
|
|
449
|
-
Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:
|
|
450
|
-
module (`config.ts:
|
|
449
|
+
Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:820`), branché à la racine de la config du
|
|
450
|
+
module (`config.ts:739`). Toutes les valeurs ci-dessous sont les **défauts réels**.
|
|
451
451
|
|
|
452
452
|
| Option | Type | Défaut | Effet |
|
|
453
453
|
| ------------------- | ---------------- | ------ | -------------------------------------------------------------------------------------- |
|
|
454
|
-
| `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:
|
|
455
|
-
| `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:
|
|
456
|
-
| `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:
|
|
457
|
-
| `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:
|
|
458
|
-
| `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:
|
|
459
|
-
| `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:
|
|
454
|
+
| `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:811`) |
|
|
455
|
+
| `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:823`) |
|
|
456
|
+
| `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:832`) |
|
|
457
|
+
| `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:839`) |
|
|
458
|
+
| `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:848`) |
|
|
459
|
+
| `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:857`) |
|
|
460
460
|
|
|
461
461
|
Deux réglages méritent une décision consciente :
|
|
462
462
|
|
|
@@ -589,7 +589,7 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
|
|
|
589
589
|
| Domaine | Norme | Ancrage |
|
|
590
590
|
| --------------------------------- | -------------------------------------- | ------------------------------------------------------ |
|
|
591
591
|
| Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts:68`) |
|
|
592
|
-
| `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC
|
|
592
|
+
| `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 9110 §11 | `challenge()` (`ApiKeyAuthenticator.ts:205`) |
|
|
593
593
|
| Secret **jamais** stocké en clair | OWASP ASVS (secret storage) | `hashApiKey()` (`apiKeyFormat.ts:70`) |
|
|
594
594
|
| Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts:46`) |
|
|
595
595
|
| Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts:139-142`) |
|
|
@@ -634,7 +634,7 @@ deux portées dans une seule page :
|
|
|
634
634
|
- **Badge « où on écrit »** — la classe réelle du store et son driver, lu défensivement pour que la
|
|
635
635
|
console affiche toujours un état honnête (`API_KEYS_STATUS_ENDPOINT`,
|
|
636
636
|
`studio/frontend/src/routes/apikeys/apiKeysModel.ts:99` ; handler `IApiKeysStatus`,
|
|
637
|
-
`SecurityAdminApi.ts:
|
|
637
|
+
`SecurityAdminApi.ts:61`).
|
|
638
638
|
|
|
639
639
|
Les types du front sont des **miroirs** du contrat serveur — le secret est exclu par construction,
|
|
640
640
|
pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements `apikey.created` /
|
|
@@ -650,7 +650,7 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
|
|
|
650
650
|
| Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts:70`) | Émettre une nouvelle clé, révoquer l'ancienne |
|
|
651
651
|
| 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:144-147`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
|
|
652
652
|
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:284`) | Ajouter le scope au catalogue, ou corriger la demande |
|
|
653
|
-
| Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:
|
|
653
|
+
| Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:165`) | Garder le `prefix` STABLE après la première émission |
|
|
654
654
|
| Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.ts:50`) | Émettre une clé portant le scope exigé par `@RequireScope` |
|
|
655
655
|
| Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:121-124`) | Réactiver le compte, ou réémettre après le bannissement |
|
|
656
656
|
| 404 en révoquant la clé d'un autre porteur | Anti-énumération volontaire, jamais 403 (`ApiKeyController.ts:139-142`) | Attendu — passer par l'endpoint d'administration |
|
|
@@ -679,7 +679,7 @@ sont dans le banc d'intégration, sur le fil — c'est plus fort, mais elles ne
|
|
|
679
679
|
serveur) ; pas de test de charge ni de mesure mémoire propre à la vérification de clé.
|
|
680
680
|
|
|
681
681
|
Les bancs sur serveur réel se **skippent sans leurs variables d'infra** — et un skip compte comme
|
|
682
|
-
vert : lire le bloc gates (`vitest
|
|
682
|
+
vert : lire le bloc gates (`scripts/test/vitest/gates.ts`, affiché en fin de run) avant de conclure. Skills
|
|
683
683
|
utiles : `nodefony-security-review` (matrice d'attaque), `nodefony-load-test` (charge).
|
|
684
684
|
Couverture : `npm run coverage` dans `@nodefony/security`.
|
|
685
685
|
|
package/docs/audit.md
CHANGED
|
@@ -135,7 +135,7 @@ lourds s'enregistrent depuis **leur** module. Un
|
|
|
135
135
|
## 🚀 Démarrage rapide
|
|
136
136
|
|
|
137
137
|
Dans une app générée par `nodefony create app`, l'audit est **déjà actif** (`enabled: true` par
|
|
138
|
-
défaut, `config.ts:
|
|
138
|
+
défaut, `config.ts:757`) sur le store que résout `auto` : la base SQLite locale de l'application
|
|
139
139
|
générée, la mémoire seulement si aucun ORM n'est chargé. Voici le parcours complet : configurer, émettre,
|
|
140
140
|
relire.
|
|
141
141
|
|
|
@@ -358,7 +358,7 @@ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouvert
|
|
|
358
358
|
<!-- prettier-ignore -->
|
|
359
359
|
| Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
|
|
360
360
|
| --- | --- | --- |
|
|
361
|
-
| `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.unverifiable` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` · `user.password_change_self` (par `@nodefony/user`) |
|
|
361
|
+
| `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.unverifiable` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_enabled` · `user.totp_disabled` · `user.password_change_self` (par `@nodefony/user`) |
|
|
362
362
|
| `authz` | autorisation (voters, `@IsGranted`), administration des comptes | `access.denied` · `user.created` · `user.updated` · `user.password_changed` · `user.deleted` (ces quatre-là par `@nodefony/user`) |
|
|
363
363
|
| `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
|
|
364
364
|
| `session` | cycle de vie de session | `logout` · `session.revoked` (révocation depuis l'administration, par `@nodefony/http`) |
|
|
@@ -383,15 +383,15 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
|
|
|
383
383
|
### `auth` — la chaîne d'authentification
|
|
384
384
|
|
|
385
385
|
Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
|
|
386
|
-
(`firewall.ts:
|
|
386
|
+
(`firewall.ts:989`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
|
|
387
387
|
|
|
388
|
-
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:
|
|
389
|
-
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:
|
|
388
|
+
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:819`) ;
|
|
389
|
+
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:808`) ;
|
|
390
390
|
- `auth.unverifiable` — le vérificateur de jetons est indisponible : réponse 503, sans défi
|
|
391
|
-
(`firewall.ts:
|
|
391
|
+
(`firewall.ts:813`) ;
|
|
392
392
|
- `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
|
|
393
|
-
(`firewall.ts:
|
|
394
|
-
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:
|
|
393
|
+
(`firewall.ts:823`) ;
|
|
394
|
+
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:851`).
|
|
395
395
|
|
|
396
396
|
Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
|
|
397
397
|
`login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
|
|
@@ -416,7 +416,7 @@ Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le
|
|
|
416
416
|
|
|
417
417
|
### `ws` — le verrou de frame WebSocket
|
|
418
418
|
|
|
419
|
-
`frame.denied` est émis par le rapporteur branché sur le verrou de frame (`firewall.ts:
|
|
419
|
+
`frame.denied` est émis par le rapporteur branché sur le verrou de frame (`firewall.ts:308`) quand une
|
|
420
420
|
socket tente un `api.request` vers une zone protégée ou s'abonne à un canal interdit. La frame ne
|
|
421
421
|
porte ni IP ni `requestId` — acteur et cible suffisent.
|
|
422
422
|
|
|
@@ -466,7 +466,7 @@ Quatre mécanismes, tous prouvés par les tests.
|
|
|
466
466
|
|
|
467
467
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
468
468
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
469
|
-
(`firewall.ts:
|
|
469
|
+
(`firewall.ts:989`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:379`).
|
|
470
470
|
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
|
|
471
471
|
|
|
472
472
|
**2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
|
|
@@ -491,8 +491,8 @@ jamais faire tomber ce qu'on supervise.
|
|
|
491
491
|
|
|
492
492
|
## ⚙️ Configuration
|
|
493
493
|
|
|
494
|
-
Table dérivée du schéma Zod `auditSchema` (`config.ts:
|
|
495
|
-
(`config.ts:
|
|
494
|
+
Table dérivée du schéma Zod `auditSchema` (`config.ts:970`), rattaché à la racine sous la clé `audit`
|
|
495
|
+
(`config.ts:1482`).
|
|
496
496
|
|
|
497
497
|
| Option | Type | Défaut | Effet |
|
|
498
498
|
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
@@ -552,7 +552,7 @@ Quatre pièces, quatre responsabilités :
|
|
|
552
552
|
**`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
|
|
553
553
|
d'audit.
|
|
554
554
|
- **`createAuditBridge()`** (`auditBridge.ts:53`) — le pont vers le canal WS `nodefony:audit`
|
|
555
|
-
(`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:
|
|
555
|
+
(`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:388`) et donc
|
|
556
556
|
gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
|
|
557
557
|
|
|
558
558
|
### La lecture paginée — pourquoi un curseur et pas un décalage
|
|
@@ -665,14 +665,14 @@ Deux autres propriétés de sécurité valent d'être connues :
|
|
|
665
665
|
|
|
666
666
|
| Exigence | Norme | Comment le code s'y conforme |
|
|
667
667
|
| ----------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
|
|
668
|
-
| Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts:
|
|
668
|
+
| Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts:711`) |
|
|
669
669
|
| Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts:130`) |
|
|
670
670
|
| Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:49`) |
|
|
671
671
|
| Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:61`) |
|
|
672
672
|
| Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts:48`) |
|
|
673
|
-
| Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:
|
|
673
|
+
| Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:999`) |
|
|
674
674
|
| Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:585`) |
|
|
675
|
-
| Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts:
|
|
675
|
+
| Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts:947`) |
|
|
676
676
|
|
|
677
677
|
## 📡 Observabilité — Studio
|
|
678
678
|
|
package/docs/authenticators.md
CHANGED
|
@@ -49,7 +49,7 @@ flowchart TD
|
|
|
49
49
|
S --> CTRL["→ autorisation → contrôleur"]
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
C'est `Firewall.#authenticate()` (`firewall.ts:
|
|
52
|
+
C'est `Firewall.#authenticate()` (`firewall.ts:1236`) qui déroule ce cycle pour chaque maillon de la
|
|
53
53
|
zone, dans l'ordre déclaré. Le succès pose l'identité dans l'ALS ; l'échec remonte au firewall qui
|
|
54
54
|
pose le 401 et son challenge — l'authenticator, lui, ne touche jamais à la réponse.
|
|
55
55
|
|
|
@@ -67,7 +67,7 @@ pose le 401 et son challenge — l'authenticator, lui, ne touche jamais à la r
|
|
|
67
67
|
| EdDSA | Algorithme de signature asymétrique (Ed25519) — le seul accepté par le vérificateur JWT. |
|
|
68
68
|
| CRC | Somme de contrôle embarquée dans une clé API — filtre les valeurs malformées avant la base. |
|
|
69
69
|
| ALS | _AsyncLocalStorage_ : le contexte ambiant de la requête où le firewall pose `user` + `token`. |
|
|
70
|
-
| Challenge | En-tête `WWW-Authenticate` renvoyé avec un 401 (RFC
|
|
70
|
+
| Challenge | En-tête `WWW-Authenticate` renvoyé avec un 401 (RFC 9110 §11) indiquant comment s'authentifier. |
|
|
71
71
|
| Zero Trust | Sur une zone protégée, **aucune preuve valide ⇒ 401** ; l'anonymat n'est accepté que s'il est déclaré. |
|
|
72
72
|
|
|
73
73
|
## Qu'est-ce qu'un authenticator — et quelle faille il ferme
|
|
@@ -100,23 +100,23 @@ totalement agnostique de la stratégie :
|
|
|
100
100
|
| `authenticate(token)` | **Vérifie** (signature/hash/session), applique la **révocation**, **re-résout le sujet** — ou lève un 401. |
|
|
101
101
|
| `onSuccess(ctx,tok)` | Effet de bord au succès (poser l'identité en session, audit). |
|
|
102
102
|
| `onFailure(ctx,err)` | Slot d'audit (le 401 + challenge sont posés par le firewall). |
|
|
103
|
-
| `challenge()` | **Optionnel** (`IAuthenticator.ts:55`) — la valeur `WWW-Authenticate` (RFC
|
|
103
|
+
| `challenge()` | **Optionnel** (`IAuthenticator.ts:55`) — la valeur `WWW-Authenticate` (RFC 9110 §11) des 401 de la zone. |
|
|
104
104
|
|
|
105
105
|
### Le registre pluggable
|
|
106
106
|
|
|
107
107
|
Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
|
|
108
|
-
(`firewall.ts:
|
|
108
|
+
(`firewall.ts:468`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
|
|
109
109
|
un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
|
|
110
110
|
|
|
111
111
|
- Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
|
|
112
112
|
s'enregistrent à l'import du module via `registerAuthenticatorFactory()`
|
|
113
113
|
(`authenticatorRegistry.ts:73-145`) — donc toujours avant le boot.
|
|
114
114
|
- Le septième, `firewall-realtime`, n'est **pas dans le registre** : c'est le firewall qui le câble
|
|
115
|
-
lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:
|
|
115
|
+
lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:301`).
|
|
116
116
|
- La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
|
|
117
117
|
`tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
|
|
118
118
|
- Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
|
|
119
|
-
(`firewall.ts:
|
|
119
|
+
(`firewall.ts:634`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
|
|
120
120
|
faute de frappe.
|
|
121
121
|
|
|
122
122
|
## 🚀 Démarrage rapide
|
|
@@ -124,7 +124,7 @@ un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggabl
|
|
|
124
124
|
### Une zone, trois preuves — la même API pour le web et les machines
|
|
125
125
|
|
|
126
126
|
Dans une app `nodefony create app`, on déclare quelles preuves une zone accepte — un **objet par
|
|
127
|
-
nom**, validé Zod au boot (`areas: z.record(...)`, `config.ts:
|
|
127
|
+
nom**, validé Zod au boot (`areas: z.record(...)`, `config.ts:1358`) :
|
|
128
128
|
|
|
129
129
|
```typescript
|
|
130
130
|
// nodefony.config.ts (extrait)
|
|
@@ -144,7 +144,7 @@ use("@nodefony/security", {
|
|
|
144
144
|
> [!IMPORTANT]
|
|
145
145
|
> **Le login est FOURNI** : `POST /nodefony/security/api/auth/login` (body `{ username, password }`
|
|
146
146
|
> → `Set-Cookie` de session, ID régénéré anti-fixation), avec `logout` et `me`
|
|
147
|
-
> (`SessionAuthController.ts:
|
|
147
|
+
> (`SessionAuthController.ts:131-148`). Pas de LoginController à écrire — tes routes ne font que
|
|
148
148
|
> consommer l'identité.
|
|
149
149
|
|
|
150
150
|
### Ce que TU écris : le controller qui consomme l'identité
|
|
@@ -203,7 +203,7 @@ Requête par requête, qui répond :
|
|
|
203
203
|
| le cookie de session | `session` | identifié — rôles frais re-résolus en base |
|
|
204
204
|
| `Authorization: Bearer eyJ…` (a.b.c) | `jwt` | identifié — signature EdDSA + claims vérifiés |
|
|
205
205
|
| `Authorization: Bearer nf_…` | `apikey` | identifié — clé vérifiée au store, révocable |
|
|
206
|
-
| rien | aucun | **401** + `WWW-Authenticate: Bearer` (`firewall.ts:
|
|
206
|
+
| rien | aucun | **401** + `WWW-Authenticate: Bearer` (`firewall.ts:1267`) |
|
|
207
207
|
|
|
208
208
|
## 🔐 Les authenticators intégrés
|
|
209
209
|
|
|
@@ -228,7 +228,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
|
|
|
228
228
|
si preuve présente, sinon visiteur anonyme accepté ». En mode `all`, utile en **dernier** :
|
|
229
229
|
« canal prouvé (ex. mTLS), identité utilisateur optionnelle ».
|
|
230
230
|
- Sans lui, zone protégée + aucune preuve = 401 : la défense en profondeur du firewall n'accepte un
|
|
231
|
-
token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:
|
|
231
|
+
token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:929`).
|
|
232
232
|
- **Faille fermée** : l'anonymat _implicite_ — ici il est un choix explicite et auditable, jamais un
|
|
233
233
|
défaut.
|
|
234
234
|
|
|
@@ -236,7 +236,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
|
|
|
236
236
|
|
|
237
237
|
Schéma **HTTP Basic** (RFC 7617) : `Authorization: Basic base64(id:mdp)`, charset UTF-8, scheme
|
|
238
238
|
case-insensitive (`UserPasswordAuthenticator.ts:11`) ; `createToken()` split au **premier** `:` —
|
|
239
|
-
le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:
|
|
239
|
+
le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:81`).
|
|
240
240
|
|
|
241
241
|
- **La vérification est déléguée** au `IPasswordVerifier` (le `UserService`) : hash, comparaison,
|
|
242
242
|
leurre anti-timing, re-hash transparent — l'authenticator ne voit que le verdict.
|
|
@@ -246,9 +246,13 @@ le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:68`).
|
|
|
246
246
|
(`UserPasswordAuthenticator.ts:101-103`) — un identifiant bloqué ne coûte **aucun hash** → le
|
|
247
247
|
throttle protège aussi le serveur du **DoS argon2**. Échec compté, succès remis à zéro
|
|
248
248
|
(`UserPasswordAuthenticator.ts:111-114`). `ThrottledError` → **429 + `Retry-After`**
|
|
249
|
-
(`firewall.ts:
|
|
249
|
+
(`firewall.ts:841`).
|
|
250
250
|
- **Le throttler est PARTAGÉ** avec le login JSON du BFF — même `loginThrottler` du container : un
|
|
251
251
|
attaquant ne contourne pas le backoff en changeant de porte (`authenticatorRegistry.ts:75-79`).
|
|
252
|
+
- **En production, un mot de passe reçu en clair est refusé avant d'être décodé** :
|
|
253
|
+
`createToken()` interroge la politique `credentialTransport`
|
|
254
|
+
(`UserPasswordAuthenticator.ts:82`) → **403**, sans challenge — un `Basic` ferait renvoyer le
|
|
255
|
+
mot de passe par le même canal (`firewall.ts:876`). Voir [Mot de passe en clair](#mot-de-passe-en-clair--refusé-en-production).
|
|
252
256
|
- Challenge : `Basic realm="nodefony", charset="UTF-8"` (`UserPasswordAuthenticator.ts:130`).
|
|
253
257
|
- **Piège** : le login par formulaire (JSON) n'est **pas** ici — c'est le BFF
|
|
254
258
|
(`/nodefony/security/api/auth/login`). Basic sert l'outillage (scripts, CLI).
|
|
@@ -335,17 +339,17 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
335
339
|
- **Perf : il ne relit pas la base.** `authenticate()` réutilise l'identité de l'ALS
|
|
336
340
|
(`FirewallRealtimeAuthenticator.ts:91`) au lieu de refaire deux lectures base par connexion —
|
|
337
341
|
un coût évitable sur le différenciateur temps réel.
|
|
338
|
-
- **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:
|
|
339
|
-
protégée `realtime !== false` — une instance par zone au handshake (`firewall.ts:
|
|
342
|
+
- **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:301`) pour toute zone
|
|
343
|
+
protégée `realtime !== false` — une instance par zone au handshake (`firewall.ts:290`).
|
|
340
344
|
- **Le mode de preuve suit le jeton du firewall**, il n'est pas deviné : absent (zone historique),
|
|
341
345
|
on retombe sur le mode le plus strict, la session (`FirewallRealtimeAuthenticator.ts:101-103`).
|
|
342
346
|
- **Filet Zero Trust** : il câble un revalidateur appelé avant chaque action data plane
|
|
343
347
|
(`FirewallRealtimeAuthenticator.ts:105-107`) — la socket peut survivre à l'identité qui l'a
|
|
344
348
|
ouverte (logout, changement de compte, `jti` denylisté). En mode session,
|
|
345
349
|
`buildSessionRevalidator()` re-lit `storage.read(id)` et compare l'identifiant ; toute erreur de
|
|
346
|
-
lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts:
|
|
350
|
+
lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts:260`). En mode jeton porteur, la
|
|
347
351
|
preuve est autre : `exp`, `jti` denylisté, `invalidBefore`
|
|
348
|
-
(`FirewallRealtimeAuthenticator.ts:
|
|
352
|
+
(`FirewallRealtimeAuthenticator.ts:204`).
|
|
349
353
|
|
|
350
354
|
> [!NOTE]
|
|
351
355
|
> **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
|
|
@@ -356,7 +360,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
356
360
|
## ⚙️ Composer une zone — ordre, mode, cohabitation
|
|
357
361
|
|
|
358
362
|
La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
|
|
359
|
-
(`firewall.ts:
|
|
363
|
+
(`firewall.ts:1236`) selon le `mode` de la zone (`first` par défaut, `config.ts:87-92`).
|
|
360
364
|
|
|
361
365
|
### Situation 1 — humains ET machines sur la même API (`first`)
|
|
362
366
|
|
|
@@ -365,12 +369,12 @@ Deux preuves différentes, mêmes routes — c'est la config du Démarrage rapid
|
|
|
365
369
|
de lecture :
|
|
366
370
|
|
|
367
371
|
- un maillon dont `supports()` est faux est simplement **sauté** en mode `first`
|
|
368
|
-
(`firewall.ts:
|
|
372
|
+
(`firewall.ts:1211`) ;
|
|
369
373
|
- un credential **présenté mais invalide échoue immédiatement** — l'échec d'`authenticate()`
|
|
370
|
-
remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:
|
|
374
|
+
remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:1236`). Une clé
|
|
371
375
|
API révoquée donne un 401 direct, même si un autre maillon aurait pu réussir.
|
|
372
376
|
- aucune preuve présentée sur toute la chaîne → `handleSecurity()` lève l'`AuthenticationError`
|
|
373
|
-
Zero Trust (`firewall.ts:
|
|
377
|
+
Zero Trust (`firewall.ts:825`).
|
|
374
378
|
|
|
375
379
|
### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
|
|
376
380
|
|
|
@@ -389,7 +393,7 @@ authenticators: ["anonymous", "session"], // ❌ anonymous accepte TOUT le mon
|
|
|
389
393
|
### Situation 3 — empiler les preuves (`all`)
|
|
390
394
|
|
|
391
395
|
En mode `all`, **chaque** maillon est obligatoire : `supports()` faux = 401 immédiat
|
|
392
|
-
(`firewall.ts:
|
|
396
|
+
(`firewall.ts:1150-1155`) et le **dernier** token de la chaîne porte l'identité (`firewall.ts:1204-1205`) —
|
|
393
397
|
utile pour exiger une preuve de canal (mTLS) **et** une identité, ou un « sudo mode » session +
|
|
394
398
|
re-saisie du mot de passe. Scénario complet côté zones : [firewall](./firewall.md).
|
|
395
399
|
|
|
@@ -409,7 +413,35 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
|
|
|
409
413
|
Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
|
|
410
414
|
invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
|
|
411
415
|
client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
|
|
412
|
-
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:
|
|
416
|
+
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1345`).
|
|
417
|
+
|
|
418
|
+
## Mot de passe en clair — refusé en production
|
|
419
|
+
|
|
420
|
+
Un mot de passe, ou un code de second facteur, qui arrive sur une requête HTTP en clair a déjà
|
|
421
|
+
traversé le réseau en lisible : le vérifier ne le protège plus. En production, toutes les
|
|
422
|
+
portes qui reçoivent un secret le refusent donc **avant toute vérification** — formulaire de
|
|
423
|
+
session, second facteur, Basic, émission de jeton (`/nodefony/security/api/token`), et les
|
|
424
|
+
endpoints d'administration marqués `credentials: true` (création d'un compte, changement de mot
|
|
425
|
+
de passe — cf la doc du plan d'administration de `@nodefony/framework`) :
|
|
426
|
+
|
|
427
|
+
- **403**, message constant `Credentials must be sent over HTTPS`
|
|
428
|
+
(`INSECURE_TRANSPORT_MESSAGE`, exporté par `nodefony`) ; la page de connexion le reconnaît et
|
|
429
|
+
n'affiche pas « mot de passe incorrect ».
|
|
430
|
+
- Le refus précède le throttle et le verifier : aucun hash calculé, aucun échec compté. Il est
|
|
431
|
+
audité (`reason: "insecure_transport"`).
|
|
432
|
+
- Le **premier** refus du process part en WARNING, les suivants non : un pod mal configuré
|
|
433
|
+
refuserait chaque connexion (`#warned`, `CredentialTransportPolicy.ts:69`).
|
|
434
|
+
- **La décision se prend sur le scheme EFFECTIF** (`context.scheme`), pas sur le transport du
|
|
435
|
+
pod. Derrière un proxy qui termine TLS — le cas de la plupart des déploiements — il suffit de
|
|
436
|
+
déclarer ce proxy de confiance : `trustProxy` dans la config de `@nodefony/http`, pour que
|
|
437
|
+
`X-Forwarded-Proto: https` soit lu.
|
|
438
|
+
- Une seule décision, posée au conteneur par le firewall au boot (`credentialTransport`,
|
|
439
|
+
`firewall.ts:438`) : production ⇒ refus, sauf échappement **écrit**
|
|
440
|
+
`security.allowInsecureCredentials: true` (`CredentialTransportPolicy.ts:31`). Hors
|
|
441
|
+
production, rien n'est refusé.
|
|
442
|
+
|
|
443
|
+
Pourquoi un 403 et pas un 401 : aucune preuve n'a été jugée. Le client ne corrigera rien dans
|
|
444
|
+
son secret — c'est le canal qu'il doit changer.
|
|
413
445
|
|
|
414
446
|
## 🧩 Ajouter un authenticator maison
|
|
415
447
|
|
|
@@ -435,12 +467,12 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
435
467
|
<!-- prettier-ignore -->
|
|
436
468
|
| Domaine | Norme | Ancrage |
|
|
437
469
|
| --- | --- | --- |
|
|
438
|
-
| Challenge d'auth (401) | RFC
|
|
470
|
+
| Challenge d'auth (401) | RFC 9110 §11 | `Firewall.#setChallenge()` (`firewall.ts:1345`) |
|
|
439
471
|
| Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
|
|
440
472
|
| JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
|
|
441
473
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
|
|
442
474
|
| Backoff de login | NIST SP 800-63B | `#throttler.check()` avant le verifier (`UserPasswordAuthenticator.ts:101-103`) |
|
|
443
|
-
| Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:
|
|
475
|
+
| Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:841`) |
|
|
444
476
|
| Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:25`) · `INVALID_CREDENTIALS` (`UserPasswordAuthenticator.ts:16`) |
|
|
445
477
|
|
|
446
478
|
## ⚡ Performance & mémoire
|
|
@@ -448,7 +480,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
448
480
|
- `supports()` est un test **bon marché** (en-tête + regex) — et n'est payé que sur zone protégée
|
|
449
481
|
(le chemin chaud/froid vit dans le [firewall](./firewall.md)).
|
|
450
482
|
- Résolutions **lazy** : le verifier `#resolveVerifier` est mémoïsé au premier login
|
|
451
|
-
(`UserPasswordAuthenticator.ts:
|
|
483
|
+
(`UserPasswordAuthenticator.ts:59`) ; keystore, `tokenStore` et `users` sont résolus du
|
|
452
484
|
container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:112`).
|
|
453
485
|
- `anonymous` : singleton gelé `anonymousUser`, zéro allocation d'utilisateur
|
|
454
486
|
(`AnonymousToken.ts:9`).
|
|
@@ -463,12 +495,13 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
463
495
|
<!-- prettier-ignore -->
|
|
464
496
|
| Symptôme | Cause (dans le code) | Correction |
|
|
465
497
|
| --- | --- | --- |
|
|
466
|
-
| 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:
|
|
498
|
+
| 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:929`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
|
|
467
499
|
| 401 générique + log ERROR « service `users` » | Câblage : pas de `UserService` au container (`authenticatorRegistry.ts:85-88`) | Enregistrer un `UserService` au boot de l'app |
|
|
468
500
|
| JWT rejeté alors qu'il « semble » valide | `aud`/`iss`/`typ` non conformes, ou `alg` ≠ EdDSA (`JwtAuthenticator.ts:103-107`) | Émettre via le `TokenService` (mêmes iss/aud/typ) |
|
|
469
501
|
| Clé API révoquée encore acceptée quelques secondes | Confusion avec un JWT (auto-porté) — `revokedAt` est lu à chaque requête (`ApiKeyAuthenticator.ts:110`) | Un PAT est révoqué immédiatement ; vérifier `revokedAt` |
|
|
470
502
|
| Révocation WS pas immédiate | Identité figée au handshake — asymétrie assumée du `FirewallRealtimeAuthenticator` (`FirewallRealtimeAuthenticator.ts:51-55`) | Attendre la fenêtre de re-validation, ou utiliser JWT + canal révocation |
|
|
471
|
-
| Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:
|
|
503
|
+
| Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:646`) | Configurer `rateLimit` pour poser le throttler |
|
|
504
|
+
| 403 `Credentials must be sent over HTTPS` sur TOUTES les connexions en production | Pod en HTTP derrière un proxy TLS non déclaré de confiance : `context.scheme` reste `http` (`CredentialTransportPolicy.ts:31`) | Déclarer le proxy dans `trustProxy` (`@nodefony/http`) ; `allowInsecureCredentials` seulement si le clair est un choix assumé |
|
|
472
505
|
|
|
473
506
|
## 🧪 Tests & couverture
|
|
474
507
|
|
package/docs/authorization.md
CHANGED
|
@@ -331,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
|
|
|
331
331
|
|
|
332
332
|
**Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
|
|
333
333
|
`@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
|
|
334
|
-
(`Resolver.ts:
|
|
334
|
+
(`Resolver.ts:896-903`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
|
|
335
335
|
consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
|
|
336
336
|
`@IsGranted("doc.edit", { subject: "id" })`.
|
|
337
337
|
|
|
@@ -356,29 +356,29 @@ se voit au premier test.
|
|
|
356
356
|
## 🧰 Déclarer l'exigence — `@IsGranted`, `@RequireScope`, `@Anonymous`
|
|
357
357
|
|
|
358
358
|
Les décorateurs n'écrivent **que des métadonnées** (0 import `@nodefony/security`, 0 cycle) ; le
|
|
359
|
-
moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:
|
|
359
|
+
moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:678-679`) :
|
|
360
360
|
|
|
361
361
|
| Déclaration | Sémantique |
|
|
362
362
|
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
363
363
|
| `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:903`) |
|
|
364
364
|
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:424-433`) |
|
|
365
|
-
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:
|
|
366
|
-
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:
|
|
367
|
-
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:
|
|
365
|
+
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1676`) |
|
|
366
|
+
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1648`) |
|
|
367
|
+
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:941`) |
|
|
368
368
|
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:1002`) |
|
|
369
|
-
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:
|
|
370
|
-
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:
|
|
369
|
+
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:969`) |
|
|
370
|
+
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1308`) |
|
|
371
371
|
|
|
372
372
|
La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
|
|
373
|
-
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:
|
|
373
|
+
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:507-509`). Le
|
|
374
374
|
même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
|
|
375
375
|
transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
|
|
376
|
-
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:
|
|
376
|
+
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:871`).
|
|
377
377
|
|
|
378
378
|
> [!IMPORTANT]
|
|
379
379
|
> **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
|
|
380
380
|
> chargé) OU aucune identité résolue (route **hors zone** firewall) → **403** direct
|
|
381
|
-
> (`Resolver.ts:
|
|
381
|
+
> (`Resolver.ts:683-693`). Une route gardée doit être couverte par une zone — voir
|
|
382
382
|
> [firewall](./firewall.md).
|
|
383
383
|
|
|
384
384
|
## 🧑⚖️ Les voters intégrés — deux axes, un même jury
|
|
@@ -399,7 +399,7 @@ Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts:25-27`) et v
|
|
|
399
399
|
default-DENY du jury qui ferme, pas ce voter. C'est aussi ce qui rend l'OR
|
|
400
400
|
(`@IsGranted(["A","B"])`) possible.
|
|
401
401
|
- La hiérarchie est lue **en lazy** depuis le container — clé `roleHierarchy`
|
|
402
|
-
(`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:
|
|
402
|
+
(`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:210`).
|
|
403
403
|
- Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts:36-38`).
|
|
404
404
|
|
|
405
405
|
#### Deux échelles de rôles — plateforme et organisation
|
|
@@ -511,7 +511,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
511
511
|
|
|
512
512
|
## 🔌 HTTP et WebSocket — une garde, N transports
|
|
513
513
|
|
|
514
|
-
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:
|
|
514
|
+
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:450`) est le point unique
|
|
515
515
|
d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
|
|
516
516
|
`@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
|
|
517
517
|
`ws-isgranted-jwt`).
|
|
@@ -519,7 +519,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
519
519
|
et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
|
|
520
520
|
- **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
|
|
521
521
|
hiérarchie** : `satisfies()` (`frameAuthorizer.ts:290`) délègue à `Firewall.hasRole()`
|
|
522
|
-
(`firewall.ts:
|
|
522
|
+
(`firewall.ts:521`) — les rôles exigés par un canal héritent comme partout ailleurs.
|
|
523
523
|
|
|
524
524
|
## 📜 Normes appliquées
|
|
525
525
|
|
|
@@ -529,7 +529,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
529
529
|
| Modèle de rôles | RBAC **hiérarchique**, au sens des composantes _Core_ et _Hierarchical_ d'ANSI INCITS 359 — sans séparation des devoirs | `RoleHierarchyWalker.reachableRoles()` |
|
|
530
530
|
| Combinaison des votes | _deny-overrides_ : un refus l'emporte, le silence ferme | `Authorization.decide()` |
|
|
531
531
|
| Scopes délégués | **RFC 6749 §3.3** — définis par le serveur d'autorisation ; sans effet sur une session humaine | `ScopeVoter.vote()` |
|
|
532
|
-
| Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:
|
|
532
|
+
| Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:683-693`) |
|
|
533
533
|
| Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts:113-142`) |
|
|
534
534
|
|
|
535
535
|
> Ce tableau dit ce que le code **fait**, pas ce qu'il revendique : Nodefony n'est pas « conforme
|
|
@@ -541,7 +541,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
541
541
|
## ⚡ Performance & mémoire
|
|
542
542
|
|
|
543
543
|
- **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
|
|
544
|
-
alloc (`Resolver.ts:
|
|
544
|
+
alloc (`Resolver.ts:490`) ; l'exigence est **figée une fois** par route et partagée entre
|
|
545
545
|
requêtes (`SecurityRequirement`, `routerDecorators.ts:444`).
|
|
546
546
|
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
|
|
547
547
|
instanciés **une seule fois** au boot (`authorization.ts:70`).
|
|
@@ -560,7 +560,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
560
560
|
| Symptôme | Cause (dans le code) | Correction |
|
|
561
561
|
| -------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
562
562
|
| Accès refusé alors que le rôle existe | Attribut mal formé (pas `ROLE_…`) → le `RoleVoter` n'entre pas | Respecter le préfixe `ROLE_` |
|
|
563
|
-
| 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:
|
|
563
|
+
| 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:683-693`) | Couvrir la route par une zone firewall |
|
|
564
564
|
| Un voter métier bloque tout | Il renvoie `DENY` au lieu d'`ABSTAIN` quand il ne s'applique pas | Renvoyer `ABSTAIN` hors de son domaine |
|
|
565
565
|
| Un `DENY` n'a pas bloqué | Attributs d'une clause = jurys **séparés** (OR) — un autre attribut a accordé | Porter l'interdit en clause AND (empiler les `@IsGranted`) |
|
|
566
566
|
| Clé API accède à une action non prévue | Type de jeton traité comme humain (allowlist) | Vérifier que le type n'est pas dans `NON_SCOPABLE_TOKEN_TYPES` |
|