@nodefony/framework 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- package/package.json +83 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import Controller from "../src/Controller.js";
|
|
2
|
+
import router_default from "../service/router.js";
|
|
3
|
+
//#region nodefony/controller/SessionAuthController.ts
|
|
4
|
+
let mounted = false;
|
|
5
|
+
/**
|
|
6
|
+
* Endpoints HTTP du flux de session BFF — adaptateurs MINCES au-dessus du
|
|
7
|
+
* service `authFlow` (`@nodefony/security`) :
|
|
8
|
+
*
|
|
9
|
+
* - `POST /nodefony/security/api/auth/login` — body JSON `{username, password}`
|
|
10
|
+
* - `POST /nodefony/security/api/auth/logout` — idempotent
|
|
11
|
+
* - `GET /nodefony/security/api/auth/me` — identité de la session courante
|
|
12
|
+
*
|
|
13
|
+
* Montés par le module framework UNIQUEMENT si le service `authFlow` existe
|
|
14
|
+
* (module security chargé) — sans lui, les routes n'existent pas (404, zéro
|
|
15
|
+
* surface). Remplace les mocks `/nodefony/studio/api/auth/*` de Studio.
|
|
16
|
+
*
|
|
17
|
+
* Erreurs : mappées par DUCK-TYPING sur `code` (401/429) — framework ne peut
|
|
18
|
+
* pas importer les classes d'erreur de security. Un 429 reporte le
|
|
19
|
+
* `Retry-After` (RFC 6585) posé par le throttler NIST ; tout le reste remonte
|
|
20
|
+
* au pipeline d'erreurs standard (500, détail loggé jamais fuité).
|
|
21
|
+
*
|
|
22
|
+
* @remarks Dérogation RFC 7235 §3.1 DÉLIBÉRÉE : les 401 de `login`/`me` ne
|
|
23
|
+
* portent PAS de `WWW-Authenticate` — aucun scheme HTTP ne décrit un cookie de
|
|
24
|
+
* session, et un challenge `Basic` mensonger déclencherait le popup natif du
|
|
25
|
+
* navigateur (l'anti-pattern que le BFF élimine). Les zones du firewall, elles,
|
|
26
|
+
* restent strictement conformes (challenge du premier authenticator qui en
|
|
27
|
+
* déclare un).
|
|
28
|
+
*/
|
|
29
|
+
var SessionAuthController = class extends Controller {
|
|
30
|
+
constructor(context) {
|
|
31
|
+
super("SessionAuthController", context);
|
|
32
|
+
}
|
|
33
|
+
/** Login BFF : credential JSON présenté UNE fois → cookie de session opaque. */
|
|
34
|
+
async login() {
|
|
35
|
+
const flow = this.#flow();
|
|
36
|
+
if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
|
|
37
|
+
const body = this.queryPost ?? {};
|
|
38
|
+
try {
|
|
39
|
+
const outcome = await flow.login(this.context, body.username, body.password);
|
|
40
|
+
if (outcome.status === "mfa_required") return this.renderJson({
|
|
41
|
+
mfaRequired: true,
|
|
42
|
+
methods: outcome.methods
|
|
43
|
+
}, 202);
|
|
44
|
+
return this.renderJson({ user: outcome.user });
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return this.#renderAuthError(e);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Second facteur (TOTP ou code de récupération) après un `login` ayant répondu
|
|
51
|
+
* `202 mfaRequired`. Body JSON `{ code }`. Succès → 200 + identité ; code
|
|
52
|
+
* absent/invalide → 401 (uniforme) ; trop de tentatives → 429 (Retry-After).
|
|
53
|
+
*/
|
|
54
|
+
async loginTotp() {
|
|
55
|
+
const flow = this.#flow();
|
|
56
|
+
if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
|
|
57
|
+
const body = this.queryPost ?? {};
|
|
58
|
+
try {
|
|
59
|
+
const user = await flow.completeMfaLogin(this.context, body.code);
|
|
60
|
+
return this.renderJson({ user });
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return this.#renderAuthError(e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Détruit la session (storage + cookie). Toujours 200 (idempotent). */
|
|
66
|
+
async logout() {
|
|
67
|
+
const flow = this.#flow();
|
|
68
|
+
if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
|
|
69
|
+
await flow.logout(this.context);
|
|
70
|
+
return this.renderJson({ ok: true });
|
|
71
|
+
}
|
|
72
|
+
/** Identité de la session courante, re-résolue (rôles frais), ou 401. */
|
|
73
|
+
async me() {
|
|
74
|
+
const flow = this.#flow();
|
|
75
|
+
if (!flow) return this.renderJson({ error: "Authentication unavailable" }, 503);
|
|
76
|
+
const user = await flow.me(this.context);
|
|
77
|
+
if (user === null) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
78
|
+
return this.renderJson({ user });
|
|
79
|
+
}
|
|
80
|
+
#flow() {
|
|
81
|
+
return this.get("authFlow") ?? null;
|
|
82
|
+
}
|
|
83
|
+
#renderAuthError(e) {
|
|
84
|
+
const code = e.code;
|
|
85
|
+
if (code === 429) {
|
|
86
|
+
const retry = e.retryAfterS;
|
|
87
|
+
return this.renderJson({ error: "Too many attempts" }, 429, { "retry-after": String(typeof retry === "number" ? retry : 1) });
|
|
88
|
+
}
|
|
89
|
+
if (code === 401) return this.renderJson({ error: "Invalid credentials" }, 401);
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Monte les routes du flux de session BFF — appelé par le module framework à
|
|
95
|
+
* `onKernelReady`, seulement si le service `authFlow` est présent.
|
|
96
|
+
*
|
|
97
|
+
* Routes nommées `security.auth.*` (espace data plane
|
|
98
|
+
* `/nodefony/security/api/*`, convention « toujours ≥ 3 segments »). HTTP-only :
|
|
99
|
+
* la sémantique session-par-socket du pont WS-RPC n'est pas conçue (P6+).
|
|
100
|
+
*/
|
|
101
|
+
function mountSessionAuthRoutes(frameworkModule) {
|
|
102
|
+
if (mounted) return;
|
|
103
|
+
const base = "/nodefony/security/api/auth";
|
|
104
|
+
const routes = [
|
|
105
|
+
[
|
|
106
|
+
"security.auth.login",
|
|
107
|
+
`${base}/login`,
|
|
108
|
+
"POST",
|
|
109
|
+
"login"
|
|
110
|
+
],
|
|
111
|
+
[
|
|
112
|
+
"security.auth.login.totp",
|
|
113
|
+
`${base}/login/totp`,
|
|
114
|
+
"POST",
|
|
115
|
+
"loginTotp"
|
|
116
|
+
],
|
|
117
|
+
[
|
|
118
|
+
"security.auth.logout",
|
|
119
|
+
`${base}/logout`,
|
|
120
|
+
"POST",
|
|
121
|
+
"logout"
|
|
122
|
+
],
|
|
123
|
+
[
|
|
124
|
+
"security.auth.me",
|
|
125
|
+
`${base}/me`,
|
|
126
|
+
"GET",
|
|
127
|
+
"me"
|
|
128
|
+
]
|
|
129
|
+
];
|
|
130
|
+
for (const [name, path, method, classMethod] of routes) router_default.createRoute(name, {
|
|
131
|
+
path,
|
|
132
|
+
constructor: SessionAuthController,
|
|
133
|
+
classMethod,
|
|
134
|
+
requirements: { methods: [method] },
|
|
135
|
+
bypassFirewall: true
|
|
136
|
+
});
|
|
137
|
+
if (!Object.prototype.hasOwnProperty.call(SessionAuthController.prototype, "module")) router_default.setController(SessionAuthController, frameworkModule);
|
|
138
|
+
mounted = true;
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
export { SessionAuthController as default, mountSessionAuthRoutes };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import Controller from "../src/Controller.js";
|
|
2
|
+
import router_default from "../service/router.js";
|
|
3
|
+
//#region nodefony/controller/TokenAuthController.ts
|
|
4
|
+
let mounted = false;
|
|
5
|
+
/** Normalise le paramètre `scope` (string OAuth « a b c » ou tableau) → string[]. */
|
|
6
|
+
function parseScope(scope) {
|
|
7
|
+
if (typeof scope === "string" && scope.trim().length > 0) return scope.trim().split(/\s+/);
|
|
8
|
+
if (Array.isArray(scope)) return scope.filter((s) => typeof s === "string");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Endpoints HTTP d'émission/rotation de **JWT** — adaptateurs MINCES au-dessus du
|
|
12
|
+
* service `tokenService` (`@nodefony/security`) :
|
|
13
|
+
*
|
|
14
|
+
* - `POST /nodefony/security/api/token` — body `{username, password, scope?}`
|
|
15
|
+
* → `{access_token, refresh_token, token_type, expires_in, scope}` (RFC 6749 §5.1)
|
|
16
|
+
* - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation
|
|
17
|
+
*
|
|
18
|
+
* Montés par le module framework UNIQUEMENT si le service `tokenService` existe
|
|
19
|
+
* (module security chargé + JWT activé) — sans lui, les routes n'existent pas
|
|
20
|
+
* (404, zéro surface).
|
|
21
|
+
*
|
|
22
|
+
* Le JWT part en **réponse JSON** (Bearer), JAMAIS en cookie ni en URL (anti
|
|
23
|
+
* fuite / non-révocable). Erreurs mappées par DUCK-TYPING sur `code` (401/429) —
|
|
24
|
+
* framework ne peut pas importer les classes d'erreur de security ; un 429
|
|
25
|
+
* reporte le `Retry-After` (RFC 6585) du throttler NIST.
|
|
26
|
+
*
|
|
27
|
+
* @remarks Dérogation RFC 7235 §3.1 (comme `SessionAuthController`) : ces 401 ne
|
|
28
|
+
* portent pas de `WWW-Authenticate` — l'endpoint d'émission n'est pas une
|
|
29
|
+
* ressource protégée par Bearer, il DÉLIVRE le Bearer.
|
|
30
|
+
*/
|
|
31
|
+
var TokenAuthController = class extends Controller {
|
|
32
|
+
constructor(context) {
|
|
33
|
+
super("TokenAuthController", context);
|
|
34
|
+
}
|
|
35
|
+
/** Émission : credential présenté → couple access/refresh. */
|
|
36
|
+
async token() {
|
|
37
|
+
const svc = this.#issuer();
|
|
38
|
+
if (!svc || !svc.isEnabled()) return this.renderJson({ error: "Token issuance unavailable" }, 503);
|
|
39
|
+
const body = this.queryPost ?? {};
|
|
40
|
+
try {
|
|
41
|
+
const tokens = await svc.issueForCredentials(body.username, body.password, parseScope(body.scope), body.resource);
|
|
42
|
+
return this.renderJson(tokens);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return this.#renderAuthError(e);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Rotation : refresh présenté → nouveau couple (ancien révoqué). */
|
|
48
|
+
async refresh() {
|
|
49
|
+
const svc = this.#issuer();
|
|
50
|
+
if (!svc || !svc.isEnabled()) return this.renderJson({ error: "Token issuance unavailable" }, 503);
|
|
51
|
+
const body = this.queryPost ?? {};
|
|
52
|
+
try {
|
|
53
|
+
const tokens = await svc.refresh(body.refresh_token, body.resource);
|
|
54
|
+
return this.renderJson(tokens);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return this.#renderAuthError(e);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
#issuer() {
|
|
60
|
+
return this.get("tokenService") ?? null;
|
|
61
|
+
}
|
|
62
|
+
#renderAuthError(e) {
|
|
63
|
+
const code = e.code;
|
|
64
|
+
if (code === 400) {
|
|
65
|
+
const oauthError = e.oauthError;
|
|
66
|
+
return this.renderJson({
|
|
67
|
+
error: typeof oauthError === "string" ? oauthError : "invalid_request",
|
|
68
|
+
error_description: e.message
|
|
69
|
+
}, 400);
|
|
70
|
+
}
|
|
71
|
+
if (code === 429) {
|
|
72
|
+
const retry = e.retryAfterS;
|
|
73
|
+
return this.renderJson({ error: "Too many attempts" }, 429, { "retry-after": String(typeof retry === "number" ? retry : 1) });
|
|
74
|
+
}
|
|
75
|
+
if (code === 401) return this.renderJson({ error: "invalid_grant" }, 401);
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Monte les routes d'émission/rotation JWT — appelé par le module framework à
|
|
81
|
+
* `onKernelReady`, seulement si le service `tokenService` est présent.
|
|
82
|
+
*
|
|
83
|
+
* Routes nommées `security.token.*` (espace data plane `/nodefony/security/api/*`).
|
|
84
|
+
* `bypassFirewall: true` : ces routes SONT le mécanisme d'émission — l'aire data
|
|
85
|
+
* plane les matcherait sinon (obtenir un token exigerait d'être déjà authentifié,
|
|
86
|
+
* deadlock).
|
|
87
|
+
*/
|
|
88
|
+
function mountTokenAuthRoutes(frameworkModule) {
|
|
89
|
+
if (mounted) return;
|
|
90
|
+
const base = "/nodefony/security/api/token";
|
|
91
|
+
const routes = [[
|
|
92
|
+
"security.token.issue",
|
|
93
|
+
base,
|
|
94
|
+
"POST",
|
|
95
|
+
"token"
|
|
96
|
+
], [
|
|
97
|
+
"security.token.refresh",
|
|
98
|
+
`${base}/refresh`,
|
|
99
|
+
"POST",
|
|
100
|
+
"refresh"
|
|
101
|
+
]];
|
|
102
|
+
for (const [name, path, method, classMethod] of routes) router_default.createRoute(name, {
|
|
103
|
+
path,
|
|
104
|
+
constructor: TokenAuthController,
|
|
105
|
+
classMethod,
|
|
106
|
+
requirements: { methods: [method] },
|
|
107
|
+
bypassFirewall: true
|
|
108
|
+
});
|
|
109
|
+
if (!Object.prototype.hasOwnProperty.call(TokenAuthController.prototype, "module")) router_default.setController(TokenAuthController, frameworkModule);
|
|
110
|
+
mounted = true;
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
export { TokenAuthController as default, mountTokenAuthRoutes };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import Controller from "../src/Controller.js";
|
|
2
|
+
import router_default from "../service/router.js";
|
|
3
|
+
//#region nodefony/controller/TotpController.ts
|
|
4
|
+
let mounted = false;
|
|
5
|
+
/**
|
|
6
|
+
* Endpoints HTTP **self-service 2FA TOTP (P6.17)** — console « ma sécurité »,
|
|
7
|
+
* adaptateurs MINCES au-dessus du service `totp` (`@nodefony/security`) :
|
|
8
|
+
*
|
|
9
|
+
* - `POST /nodefony/security/api/totp/enroll` → `{secretBase32, otpauthUri}`
|
|
10
|
+
* (secret affiché 1× pour le QR ; secret PENDING tant que non confirmé)
|
|
11
|
+
* - `POST /nodefony/security/api/totp/confirm` — body `{code}` → `{recoveryCodes}`
|
|
12
|
+
* (active le 2FA ; codes de récupération affichés 1×)
|
|
13
|
+
* - `POST /nodefony/security/api/totp/disable` → `{ok}` (retire le 2FA)
|
|
14
|
+
* - `GET /nodefony/security/api/totp/status` → `{enabled, pending, recoveryCodesRemaining}`
|
|
15
|
+
*
|
|
16
|
+
* **PAS de `bypassFirewall`** (≠ login/totp) : ces routes vivent DANS la zone data
|
|
17
|
+
* plane `^/nodefony/[^/]+/api(/|$)` → **session BFF requise**. Le sujet est
|
|
18
|
+
* TOUJOURS l'utilisateur courant (`authFlow.me`), jamais un paramètre — on n'active
|
|
19
|
+
* /ne désactive jamais le 2FA d'autrui (anti-IDOR). Montés seulement si le service
|
|
20
|
+
* `totp` existe (security chargé + 2FA activé) → 404, zéro surface, sinon.
|
|
21
|
+
*/
|
|
22
|
+
var TotpController = class extends Controller {
|
|
23
|
+
constructor(context) {
|
|
24
|
+
super("TotpController", context);
|
|
25
|
+
}
|
|
26
|
+
/** Démarre l'enrôlement : secret + URI otpauth affichés une seule fois. */
|
|
27
|
+
async enroll() {
|
|
28
|
+
const svc = this.#service();
|
|
29
|
+
if (!svc) return this.renderJson({ error: "2FA unavailable" }, 503);
|
|
30
|
+
const subject = await this.#currentSubject();
|
|
31
|
+
if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
32
|
+
const enrollment = await svc.beginEnrollment(subject, subject);
|
|
33
|
+
return this.renderJson(enrollment);
|
|
34
|
+
}
|
|
35
|
+
/** Confirme l'enrôlement par un 1ᵉʳ code → active + codes de récupération (1×). */
|
|
36
|
+
async confirm() {
|
|
37
|
+
const svc = this.#service();
|
|
38
|
+
if (!svc) return this.renderJson({ error: "2FA unavailable" }, 503);
|
|
39
|
+
const subject = await this.#currentSubject();
|
|
40
|
+
if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
41
|
+
const code = (this.queryPost ?? {}).code;
|
|
42
|
+
if (typeof code !== "string" || code.length === 0) return this.renderJson({ error: "Invalid code" }, 400);
|
|
43
|
+
try {
|
|
44
|
+
const activation = await svc.confirmEnrollment(subject, code);
|
|
45
|
+
return this.renderJson(activation);
|
|
46
|
+
} catch {
|
|
47
|
+
return this.renderJson({ error: "Invalid or expired code" }, 400);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Désactive le 2FA du porteur courant (retire secret + codes). */
|
|
51
|
+
async disable() {
|
|
52
|
+
const svc = this.#service();
|
|
53
|
+
if (!svc) return this.renderJson({ error: "2FA unavailable" }, 503);
|
|
54
|
+
const subject = await this.#currentSubject();
|
|
55
|
+
if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
56
|
+
await svc.disable(subject);
|
|
57
|
+
return this.renderJson({ ok: true });
|
|
58
|
+
}
|
|
59
|
+
/** État 2FA du porteur courant (alimente la console « ma sécurité »). */
|
|
60
|
+
async status() {
|
|
61
|
+
const svc = this.#service();
|
|
62
|
+
if (!svc) return this.renderJson({ error: "2FA unavailable" }, 503);
|
|
63
|
+
const subject = await this.#currentSubject();
|
|
64
|
+
if (subject === null) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
65
|
+
return this.renderJson(await svc.status(subject));
|
|
66
|
+
}
|
|
67
|
+
#service() {
|
|
68
|
+
const svc = this.get("totp");
|
|
69
|
+
return svc && svc.isEnabled() ? svc : null;
|
|
70
|
+
}
|
|
71
|
+
#flow() {
|
|
72
|
+
return this.get("authFlow") ?? null;
|
|
73
|
+
}
|
|
74
|
+
/** Identifiant du porteur courant (session BFF revalidée), ou `null`. */
|
|
75
|
+
async #currentSubject() {
|
|
76
|
+
const flow = this.#flow();
|
|
77
|
+
if (!flow) return null;
|
|
78
|
+
const me = await flow.me(this.context);
|
|
79
|
+
return me && typeof me.username === "string" ? me.username : null;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Monte les routes self-service 2FA — appelé par le module framework à
|
|
84
|
+
* `onKernelReady`, seulement si le service `totp` est présent.
|
|
85
|
+
*
|
|
86
|
+
* Routes nommées `security.totp.*` (espace data plane `/nodefony/security/api/*`).
|
|
87
|
+
* **Aucun `bypassFirewall`** : l'aire data plane (session BFF) les garde — gérer
|
|
88
|
+
* son 2FA exige d'être authentifié.
|
|
89
|
+
*/
|
|
90
|
+
function mountTotpRoutes(frameworkModule) {
|
|
91
|
+
if (mounted) return;
|
|
92
|
+
const base = "/nodefony/security/api/totp";
|
|
93
|
+
const routes = [
|
|
94
|
+
[
|
|
95
|
+
"security.totp.enroll",
|
|
96
|
+
`${base}/enroll`,
|
|
97
|
+
"POST",
|
|
98
|
+
"enroll"
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
"security.totp.confirm",
|
|
102
|
+
`${base}/confirm`,
|
|
103
|
+
"POST",
|
|
104
|
+
"confirm"
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
"security.totp.disable",
|
|
108
|
+
`${base}/disable`,
|
|
109
|
+
"POST",
|
|
110
|
+
"disable"
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
"security.totp.status",
|
|
114
|
+
`${base}/status`,
|
|
115
|
+
"GET",
|
|
116
|
+
"status"
|
|
117
|
+
]
|
|
118
|
+
];
|
|
119
|
+
for (const [name, path, method, classMethod] of routes) router_default.createRoute(name, {
|
|
120
|
+
path,
|
|
121
|
+
constructor: TotpController,
|
|
122
|
+
classMethod,
|
|
123
|
+
requirements: { methods: [method] }
|
|
124
|
+
});
|
|
125
|
+
if (!Object.prototype.hasOwnProperty.call(TotpController.prototype, "module")) router_default.setController(TotpController, frameworkModule);
|
|
126
|
+
mounted = true;
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
export { TotpController as default, mountTotpRoutes };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import Controller from "../src/Controller.js";
|
|
2
|
+
import router_default from "../service/router.js";
|
|
3
|
+
//#region nodefony/controller/WebAuthnController.ts
|
|
4
|
+
const REG_CHALLENGE = "webauthn:reg:challenge";
|
|
5
|
+
const AUTH_CHALLENGE = "webauthn:auth:challenge";
|
|
6
|
+
let mounted = false;
|
|
7
|
+
/**
|
|
8
|
+
* Endpoints HTTP des cérémonies **WebAuthn / passkeys** (P6 J9) — adaptateurs
|
|
9
|
+
* MINCES au-dessus du service `webauthn` (`@nodefony/security`) :
|
|
10
|
+
*
|
|
11
|
+
* - `POST /nodefony/security/api/webauthn/register/options` — défi de création
|
|
12
|
+
* (utilisateur DÉJÀ connecté : lie un passkey à son compte)
|
|
13
|
+
* - `POST /nodefony/security/api/webauthn/register/verify` — vérifie + stocke
|
|
14
|
+
* - `POST /nodefony/security/api/webauthn/login/options` — défi d'assertion
|
|
15
|
+
* - `POST /nodefony/security/api/webauthn/login/verify` — vérifie + ouvre
|
|
16
|
+
* la session BFF (l'empreinte remplace le mot de passe)
|
|
17
|
+
*
|
|
18
|
+
* Le **challenge** est stocké côté serveur en session (jamais rejouable) entre
|
|
19
|
+
* `options` et `verify`.
|
|
20
|
+
*
|
|
21
|
+
* **Deux conditions distinctes, deux réponses distinctes** : les routes ne sont
|
|
22
|
+
* montées que si le service `webauthn` est dans le container (`framework/index.ts:400`),
|
|
23
|
+
* c'est-à-dire dès que `@nodefony/security` est chargé — sans security, **404**,
|
|
24
|
+
* zéro surface. Le service reste enregistré même passkeys DÉSACTIVÉS
|
|
25
|
+
* (`@services` l'instancie inconditionnellement) : dans ce cas les routes existent
|
|
26
|
+
* et répondent **503** (`isEnabled()` faux). Ne pas lire un 503 comme « route
|
|
27
|
+
* absente », ni un 404 comme « passkeys coupés ».
|
|
28
|
+
*
|
|
29
|
+
* @remarks `bypassFirewall` : `login/*` précède toute authentification ;
|
|
30
|
+
* `register/*` exige une session active, vérifiée ICI (`me()` → 401), pas par le
|
|
31
|
+
* firewall (qui, sur l'aire data plane, déclencherait un deadlock identique au
|
|
32
|
+
* login BFF).
|
|
33
|
+
*/
|
|
34
|
+
var WebAuthnController = class extends Controller {
|
|
35
|
+
constructor(context) {
|
|
36
|
+
super("WebAuthnController", context);
|
|
37
|
+
}
|
|
38
|
+
/** Défi d'enregistrement — l'utilisateur connecté ajoute un passkey. */
|
|
39
|
+
async registerOptions() {
|
|
40
|
+
const svc = this.#service();
|
|
41
|
+
const flow = this.#flow();
|
|
42
|
+
if (!svc || !flow) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
|
|
43
|
+
const me = await flow.me(this.context);
|
|
44
|
+
if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
45
|
+
const options = await svc.generateRegistrationOptions({
|
|
46
|
+
id: me.username,
|
|
47
|
+
name: me.username
|
|
48
|
+
});
|
|
49
|
+
const session = await flow.ensureSession(this.context);
|
|
50
|
+
session?.set(REG_CHALLENGE, options.challenge);
|
|
51
|
+
await session?.save();
|
|
52
|
+
return this.renderJson(options);
|
|
53
|
+
}
|
|
54
|
+
/** Vérifie la réponse d'enregistrement et persiste le credential. */
|
|
55
|
+
async registerVerify() {
|
|
56
|
+
const svc = this.#service();
|
|
57
|
+
if (!svc) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
|
|
58
|
+
const me = await this.#flow()?.me(this.context);
|
|
59
|
+
if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
60
|
+
const challenge = await this.#takeChallenge(REG_CHALLENGE);
|
|
61
|
+
if (!challenge) return this.renderJson({ error: "No challenge" }, 400);
|
|
62
|
+
try {
|
|
63
|
+
const credential = await svc.verifyRegistration(this.#body().response, challenge, me.username, this.#origin());
|
|
64
|
+
return this.renderJson({
|
|
65
|
+
verified: true,
|
|
66
|
+
credentialId: credential.id
|
|
67
|
+
});
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return this.#renderAuthError(e);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Défi d'authentification. **Le ciblage ne vient jamais de la requête** : un
|
|
74
|
+
* appelant anonyme obtient un défi découvrable (`allowCredentials` omis), un
|
|
75
|
+
* appelant déjà authentifié obtient le sien (ré-authentification / step-up).
|
|
76
|
+
*
|
|
77
|
+
* @remarks Route en `bypassFirewall` : n'importe qui peut la poster. Peupler
|
|
78
|
+
* `allowCredentials` depuis un identifiant fourni dirait deux choses à cet
|
|
79
|
+
* inconnu — que le compte porte une passkey, et **lesquelles** (W3C WebAuthn
|
|
80
|
+
* L3, « Privacy leak via credential IDs » : un `credentialId` est un
|
|
81
|
+
* identifiant corrélable, exposé il dés-anonymise entre sites et confirme
|
|
82
|
+
* une hypothèse d'identité avec un accès momentané à l'authenticator). La
|
|
83
|
+
* spec propose deux remèdes, ce sont les deux régimes ci-dessous : les
|
|
84
|
+
* credentials découvrables pour l'anonyme, une **authentification préalable**
|
|
85
|
+
* quand on cible vraiment. Verrouillé par
|
|
86
|
+
* `tests/unit/webauthnLoginOptionsPrivacy.test.ts`.
|
|
87
|
+
*/
|
|
88
|
+
async loginOptions() {
|
|
89
|
+
const svc = this.#service();
|
|
90
|
+
const flow = this.#flow();
|
|
91
|
+
if (!svc || !flow) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
|
|
92
|
+
const me = await flow.me(this.context);
|
|
93
|
+
const options = await svc.generateAuthenticationOptions(me?.username);
|
|
94
|
+
const session = await flow.ensureSession(this.context);
|
|
95
|
+
session?.set(AUTH_CHALLENGE, options.challenge);
|
|
96
|
+
await session?.save();
|
|
97
|
+
return this.renderJson(options);
|
|
98
|
+
}
|
|
99
|
+
/** Vérifie l'assertion (empreinte) et OUVRE la session BFF. */
|
|
100
|
+
async loginVerify() {
|
|
101
|
+
const svc = this.#service();
|
|
102
|
+
const flow = this.#flow();
|
|
103
|
+
if (!svc || !flow) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
|
|
104
|
+
const challenge = await this.#takeChallenge(AUTH_CHALLENGE);
|
|
105
|
+
if (!challenge) return this.renderJson({ error: "No challenge" }, 400);
|
|
106
|
+
try {
|
|
107
|
+
const { userId } = await svc.verifyAuthentication(this.#body().response, challenge, this.#origin());
|
|
108
|
+
const user = await flow.establishSessionFor(this.context, userId, "webauthn");
|
|
109
|
+
return this.renderJson({
|
|
110
|
+
verified: true,
|
|
111
|
+
user
|
|
112
|
+
});
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return this.#renderAuthError(e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Liste les passkeys de l'utilisateur courant (console « mes appareils »). */
|
|
118
|
+
async listCredentials() {
|
|
119
|
+
const svc = this.#service();
|
|
120
|
+
const flow = this.#flow();
|
|
121
|
+
if (!svc || !flow) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
|
|
122
|
+
const me = await flow.me(this.context);
|
|
123
|
+
if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
124
|
+
const creds = await svc.listUserCredentials(me.username);
|
|
125
|
+
return this.renderJson({ credentials: creds.map((c) => ({
|
|
126
|
+
id: c.id,
|
|
127
|
+
transports: c.transports,
|
|
128
|
+
backupState: c.backupState,
|
|
129
|
+
createdAt: c.createdAt,
|
|
130
|
+
lastUsedAt: c.lastUsedAt
|
|
131
|
+
})) });
|
|
132
|
+
}
|
|
133
|
+
/** Supprime une passkey DU porteur courant (sinon 404 — anti-IDOR/anti-énumération). */
|
|
134
|
+
async removeCredential(id) {
|
|
135
|
+
const svc = this.#service();
|
|
136
|
+
const flow = this.#flow();
|
|
137
|
+
if (!svc || !flow) return this.renderJson({ error: "WebAuthn unavailable" }, 503);
|
|
138
|
+
const me = await flow.me(this.context);
|
|
139
|
+
if (!me) return this.renderJson({ error: "Unauthorized" }, 401);
|
|
140
|
+
if (typeof id !== "string" || id.length === 0) return this.renderJson({ error: "Not found" }, 404);
|
|
141
|
+
if (!await svc.removeUserCredential(me.username, id)) return this.renderJson({ error: "Not found" }, 404);
|
|
142
|
+
return this.renderJson({ ok: true });
|
|
143
|
+
}
|
|
144
|
+
#service() {
|
|
145
|
+
const svc = this.get("webauthn");
|
|
146
|
+
return svc && svc.isEnabled() ? svc : null;
|
|
147
|
+
}
|
|
148
|
+
#flow() {
|
|
149
|
+
return this.get("authFlow") ?? null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Corps utile des cérémonies : la réponse de l'authenticator, rien d'autre.
|
|
153
|
+
* Aucun identifiant n'est lu ici — l'identité vient de la session, ou la
|
|
154
|
+
* cérémonie est découvrable (cf {@link WebAuthnController.loginOptions}).
|
|
155
|
+
*/
|
|
156
|
+
#body() {
|
|
157
|
+
return this.queryPost ?? {};
|
|
158
|
+
}
|
|
159
|
+
/** Origine HTTP de la requête (validée par le service contre le rpID). */
|
|
160
|
+
#origin() {
|
|
161
|
+
const origin = this.context.request?.headers?.origin;
|
|
162
|
+
return typeof origin === "string" ? origin : void 0;
|
|
163
|
+
}
|
|
164
|
+
/** Lit le challenge en session puis l'INVALIDE (usage unique, anti-replay). */
|
|
165
|
+
async #takeChallenge(key) {
|
|
166
|
+
const session = this.context.session;
|
|
167
|
+
const value = session?.get(key);
|
|
168
|
+
if (typeof value !== "string" || value.length === 0) return null;
|
|
169
|
+
session?.set(key, null);
|
|
170
|
+
await session?.save();
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
#renderAuthError(e) {
|
|
174
|
+
const code = e.code;
|
|
175
|
+
if (code === 401) return this.renderJson({ error: "WebAuthn verification failed" }, 401);
|
|
176
|
+
if (code === 409) return this.renderJson({ error: e.message ?? "Passkey limit reached" }, 409);
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Monte les routes des cérémonies WebAuthn — appelé par le module framework à
|
|
182
|
+
* `onKernelReady`, seulement si le service `webauthn` est présent.
|
|
183
|
+
*/
|
|
184
|
+
function mountWebAuthnRoutes(frameworkModule) {
|
|
185
|
+
if (mounted) return;
|
|
186
|
+
const base = "/nodefony/security/api/webauthn";
|
|
187
|
+
const routes = [
|
|
188
|
+
[
|
|
189
|
+
"security.webauthn.register.options",
|
|
190
|
+
`${base}/register/options`,
|
|
191
|
+
"POST",
|
|
192
|
+
"registerOptions",
|
|
193
|
+
true
|
|
194
|
+
],
|
|
195
|
+
[
|
|
196
|
+
"security.webauthn.register.verify",
|
|
197
|
+
`${base}/register/verify`,
|
|
198
|
+
"POST",
|
|
199
|
+
"registerVerify",
|
|
200
|
+
true
|
|
201
|
+
],
|
|
202
|
+
[
|
|
203
|
+
"security.webauthn.login.options",
|
|
204
|
+
`${base}/login/options`,
|
|
205
|
+
"POST",
|
|
206
|
+
"loginOptions",
|
|
207
|
+
true
|
|
208
|
+
],
|
|
209
|
+
[
|
|
210
|
+
"security.webauthn.login.verify",
|
|
211
|
+
`${base}/login/verify`,
|
|
212
|
+
"POST",
|
|
213
|
+
"loginVerify",
|
|
214
|
+
true
|
|
215
|
+
],
|
|
216
|
+
[
|
|
217
|
+
"security.webauthn.credentials.list",
|
|
218
|
+
`${base}/credentials`,
|
|
219
|
+
"GET",
|
|
220
|
+
"listCredentials",
|
|
221
|
+
false
|
|
222
|
+
],
|
|
223
|
+
[
|
|
224
|
+
"security.webauthn.credentials.remove",
|
|
225
|
+
`${base}/credentials/{id}`,
|
|
226
|
+
"DELETE",
|
|
227
|
+
"removeCredential",
|
|
228
|
+
false
|
|
229
|
+
]
|
|
230
|
+
];
|
|
231
|
+
for (const [name, path, method, classMethod, bypass] of routes) router_default.createRoute(name, {
|
|
232
|
+
path,
|
|
233
|
+
constructor: WebAuthnController,
|
|
234
|
+
classMethod,
|
|
235
|
+
requirements: { methods: [method] },
|
|
236
|
+
bypassFirewall: bypass
|
|
237
|
+
});
|
|
238
|
+
if (!Object.prototype.hasOwnProperty.call(WebAuthnController.prototype, "module")) router_default.setController(WebAuthnController, frameworkModule);
|
|
239
|
+
mounted = true;
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
export { WebAuthnController as default, mountWebAuthnRoutes };
|