@nodefony/security 10.0.0-beta.1 → 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/README.md +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.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/securityHeaders.js +12 -1
- 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/securityHeaders.d.ts +2 -1
- 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 +18 -18
- package/docs/audit.md +24 -22
- package/docs/authenticators.md +62 -29
- package/docs/authorization.md +16 -16
- package/docs/cors.md +30 -30
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +68 -5
- package/docs/firewall.md +78 -45
- package/docs/headers.md +35 -35
- package/docs/index.md +15 -7
- package/docs/keycloak.md +818 -0
- package/docs/lexique.md +15 -15
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +242 -125
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +138 -86
- package/docs/totp.md +113 -71
- package/docs/webauthn.md +115 -115
- package/docs/webhooks.md +21 -21
- 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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Vue minimale du contexte : seul le scheme EFFECTIF compte. */
|
|
2
|
+
export interface ICredentialTransportContext {
|
|
3
|
+
/**
|
|
4
|
+
* Scheme vu par le client — `https` derrière un proxy de confiance qui
|
|
5
|
+
* termine TLS (`trustProxy` + `X-Forwarded-Proto`), jamais le transport brut.
|
|
6
|
+
*/
|
|
7
|
+
readonly scheme?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Réglages de la politique, résolus UNE fois au boot du firewall. */
|
|
10
|
+
export interface ICredentialTransportPolicyOptions {
|
|
11
|
+
/** `true` = un secret reçu hors TLS est refusé (production, sans échappement). */
|
|
12
|
+
readonly enforce: boolean;
|
|
13
|
+
/** Journal du premier refus (WARNING) — absent = silencieux. */
|
|
14
|
+
readonly log?: (message: string) => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Dit si un secret de connexion se refuse : en production, sans échappement
|
|
18
|
+
* explicite. Seule source de la décision — le firewall la compose au boot.
|
|
19
|
+
*
|
|
20
|
+
* @param environment - environnement normalisé du kernel.
|
|
21
|
+
* @param allowInsecureCredentials - échappement `security.allowInsecureCredentials`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function shouldEnforceCredentialTransport(environment: string | undefined, allowInsecureCredentials: boolean): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Refuse, avant toute vérification, un mot de passe ou un code de second
|
|
26
|
+
* facteur reçu en clair. Une instance partagée au conteneur (`credentialTransport`)
|
|
27
|
+
* sert TOUTES les portes qui reçoivent un secret : formulaire de session, second
|
|
28
|
+
* facteur, Basic, émission de jeton — une porte oubliée serait le canal clair.
|
|
29
|
+
*
|
|
30
|
+
* La décision se prend sur le scheme EFFECTIF (`context.scheme`) : derrière un
|
|
31
|
+
* proxy qui termine TLS et déclaré de confiance, il vaut `https` alors que le
|
|
32
|
+
* pod écoute en HTTP. C'est le cas de la plupart des déploiements ; c'est donc
|
|
33
|
+
* `trustProxy` que nomme le journal, pas « passez en HTTPS ».
|
|
34
|
+
*/
|
|
35
|
+
export declare class CredentialTransportPolicy {
|
|
36
|
+
#private;
|
|
37
|
+
readonly enforce: boolean;
|
|
38
|
+
constructor(options: ICredentialTransportPolicyOptions);
|
|
39
|
+
/**
|
|
40
|
+
* Laisse passer un secret reçu sur TLS, ou n'importe lequel hors production.
|
|
41
|
+
*
|
|
42
|
+
* @param context - contexte de la requête qui porte le secret.
|
|
43
|
+
* @param door - porte qui le reçoit, nommée dans le journal.
|
|
44
|
+
* @throws InsecureTransportError (403) — secret reçu en clair en production.
|
|
45
|
+
*/
|
|
46
|
+
assert(context: ICredentialTransportContext, door: string): void;
|
|
47
|
+
}
|
package/docs/api-keys.md
CHANGED
|
@@ -137,7 +137,7 @@ protégerait que des secrets faibles, et coûterait sur le chemin chaud.
|
|
|
137
137
|
|
|
138
138
|
### 1. Déclarer la zone machine et les règles d'émission
|
|
139
139
|
|
|
140
|
-
Dans une app générée par `nodefony create app`, tout se déclare dans `nodefony.config.ts`. Une zone
|
|
140
|
+
Dans une app générée par `nodefony create app`, tout se déclare dans la config du module — `nodefony/config/security.ts`, importé par `nodefony.config.ts`. Une zone
|
|
141
141
|
dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
|
|
142
142
|
|
|
143
143
|
```typescript
|
|
@@ -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,8 @@ 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
|
+
générée, la mémoire seulement si aucun ORM n'est chargé. Voici le parcours complet : configurer, émettre,
|
|
139
140
|
relire.
|
|
140
141
|
|
|
141
142
|
### 1. Choisir où le journal est écrit
|
|
@@ -265,7 +266,7 @@ Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
|
|
|
265
266
|
| `requestId` | tous les événements d'**une seule requête** | `requestId=req-7c1e` |
|
|
266
267
|
| `since` | borne basse d'horodatage, epoch ms **inclus** | `since=1763503200000` |
|
|
267
268
|
| `until` | borne haute d'horodatage, epoch ms **inclus** | `until=1763524800000` |
|
|
268
|
-
| `limit` | taille de page (défaut 100, **plafonné à
|
|
269
|
+
| `limit` | taille de page (défaut 100, **plafonné à 200**) | `limit=200` |
|
|
269
270
|
| `cursor` | le `nextCursor` de la page précédente | `cursor=1763548800123:9f-1` |
|
|
270
271
|
|
|
271
272
|
```bash
|
|
@@ -357,7 +358,7 @@ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouvert
|
|
|
357
358
|
<!-- prettier-ignore -->
|
|
358
359
|
| Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
|
|
359
360
|
| --- | --- | --- |
|
|
360
|
-
| `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`) |
|
|
361
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`) |
|
|
362
363
|
| `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
|
|
363
364
|
| `session` | cycle de vie de session | `logout` · `session.revoked` (révocation depuis l'administration, par `@nodefony/http`) |
|
|
@@ -382,15 +383,15 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
|
|
|
382
383
|
### `auth` — la chaîne d'authentification
|
|
383
384
|
|
|
384
385
|
Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
|
|
385
|
-
(`firewall.ts:
|
|
386
|
+
(`firewall.ts:989`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
|
|
386
387
|
|
|
387
|
-
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:
|
|
388
|
-
- `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`) ;
|
|
389
390
|
- `auth.unverifiable` — le vérificateur de jetons est indisponible : réponse 503, sans défi
|
|
390
|
-
(`firewall.ts:
|
|
391
|
+
(`firewall.ts:813`) ;
|
|
391
392
|
- `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
|
|
392
|
-
(`firewall.ts:
|
|
393
|
-
- `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`).
|
|
394
395
|
|
|
395
396
|
Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
|
|
396
397
|
`login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
|
|
@@ -415,7 +416,7 @@ Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le
|
|
|
415
416
|
|
|
416
417
|
### `ws` — le verrou de frame WebSocket
|
|
417
418
|
|
|
418
|
-
`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
|
|
419
420
|
socket tente un `api.request` vers une zone protégée ou s'abonne à un canal interdit. La frame ne
|
|
420
421
|
porte ni IP ni `requestId` — acteur et cible suffisent.
|
|
421
422
|
|
|
@@ -465,7 +466,7 @@ Quatre mécanismes, tous prouvés par les tests.
|
|
|
465
466
|
|
|
466
467
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
467
468
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
468
|
-
(`firewall.ts:
|
|
469
|
+
(`firewall.ts:989`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:379`).
|
|
469
470
|
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
|
|
470
471
|
|
|
471
472
|
**2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
|
|
@@ -490,13 +491,13 @@ jamais faire tomber ce qu'on supervise.
|
|
|
490
491
|
|
|
491
492
|
## ⚙️ Configuration
|
|
492
493
|
|
|
493
|
-
Table dérivée du schéma Zod `auditSchema` (`config.ts:
|
|
494
|
-
(`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`).
|
|
495
496
|
|
|
496
497
|
| Option | Type | Défaut | Effet |
|
|
497
498
|
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
498
499
|
| `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
|
|
499
|
-
| `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`
|
|
500
|
+
| `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`, `mongoose` |
|
|
500
501
|
| `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
|
|
501
502
|
| `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
|
|
502
503
|
| `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
|
|
@@ -514,7 +515,8 @@ enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:
|
|
|
514
515
|
|
|
515
516
|
1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
|
|
516
517
|
2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
|
|
517
|
-
3. sinon,
|
|
518
|
+
3. sinon, un backend local persistant réellement chargé (`drizzle` = SQLite, puis `mongoose`) ;
|
|
519
|
+
4. sinon, repli **annoncé** sur `memory` (volatil) — la décision est loggée en INFO, jamais silencieuse.
|
|
518
520
|
|
|
519
521
|
La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
|
|
520
522
|
de Studio : configuré, résolu, disponible, motif, emplacement physique.
|
|
@@ -550,7 +552,7 @@ Quatre pièces, quatre responsabilités :
|
|
|
550
552
|
**`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
|
|
551
553
|
d'audit.
|
|
552
554
|
- **`createAuditBridge()`** (`auditBridge.ts:53`) — le pont vers le canal WS `nodefony:audit`
|
|
553
|
-
(`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
|
|
554
556
|
gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
|
|
555
557
|
|
|
556
558
|
### La lecture paginée — pourquoi un curseur et pas un décalage
|
|
@@ -663,14 +665,14 @@ Deux autres propriétés de sécurité valent d'être connues :
|
|
|
663
665
|
|
|
664
666
|
| Exigence | Norme | Comment le code s'y conforme |
|
|
665
667
|
| ----------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
|
|
666
|
-
| 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`) |
|
|
667
669
|
| Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts:130`) |
|
|
668
670
|
| Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:49`) |
|
|
669
671
|
| Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:61`) |
|
|
670
672
|
| Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts:48`) |
|
|
671
|
-
| 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`) |
|
|
672
674
|
| Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:585`) |
|
|
673
|
-
| 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`) |
|
|
674
676
|
|
|
675
677
|
## 📡 Observabilité — Studio
|
|
676
678
|
|
|
@@ -702,10 +704,10 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
|
|
|
702
704
|
| Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
|
|
703
705
|
| Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
|
|
704
706
|
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:115`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
|
|
705
|
-
| `limit=5000` ne rend que
|
|
707
|
+
| `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts:77`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
|
|
706
708
|
| La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
|
|
707
|
-
|
|
|
708
|
-
| Le paramètre `q`
|
|
709
|
+
| `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
|
|
710
|
+
| Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
|
|
709
711
|
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:55`) | Retirer le rôle admin, ou ne pas exposer le canal |
|
|
710
712
|
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
|
|
711
713
|
| Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
|
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é
|
|
@@ -185,7 +185,7 @@ curl -si http://localhost:5151/api/v1/whoami | grep -E "^(HTTP|WWW)"
|
|
|
185
185
|
|
|
186
186
|
# 2) Web — login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
|
|
187
187
|
curl -si -c /tmp/jar -H 'Content-Type: application/json' \
|
|
188
|
-
-d '{"username":"admin","password":"
|
|
188
|
+
-d '{"username":"admin","password":"nodefony-dev-42"}' \
|
|
189
189
|
http://localhost:5151/nodefony/security/api/auth/login | head -1
|
|
190
190
|
# HTTP/1.1 200 OK
|
|
191
191
|
|
|
@@ -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,28 +339,28 @@ 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
|
|
352
|
-
> frames lisent un cache O(1)) → une révocation prend effet
|
|
356
|
+
> frames lisent un cache O(1)) → une révocation prend effet **en une fenêtre** (tick du hub, et devant chaque `api.request`), pas à la frame
|
|
353
357
|
> suivante. C'est l'état de l'art (Socket.IO/Phoenix figent aussi au handshake) ; la révocation
|
|
354
358
|
> immédiate forte passe par le JWT + un canal « token révoqué ».
|
|
355
359
|
|
|
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
|
|