webspresso 0.0.98 → 0.0.99
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/.agents/ADMIN.md +11 -1
- package/.agents/AGENTS.md +1 -1
- package/.agents/AUTH.md +169 -0
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +73 -5
- package/.agents/QUEUE.md +84 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +8 -0
- package/core/orm/model.js +4 -0
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +82 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/index.d.ts +528 -0
- package/index.js +44 -0
- package/package.json +1 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +6 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/src/api/api-loader.js +1 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +137 -100
- package/src/helpers.js +9 -0
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
package/.agents/ROUTING.md
CHANGED
|
@@ -53,7 +53,10 @@ All routes are automatically discovered and registered from the `pages/` directo
|
|
|
53
53
|
| `pages/index.njk` | `GET` | `/` | Home page (SSR Nunjucks) |
|
|
54
54
|
| `pages/about.njk` | `GET` | `/about` | Static SSR page |
|
|
55
55
|
| `pages/notes/[id].njk` | `GET` | `/notes/:id` | Dynamic SSR route with param |
|
|
56
|
+
| `pages/(marketing)/about.njk` | `GET` | `/about` | Route Group page (omits `(marketing)`) |
|
|
57
|
+
| `pages/(auth)/login.njk` | `GET` | `/login` | Route Group with isolated layout & middleware |
|
|
56
58
|
| `pages/api/notes.get.js` | `GET` | `/api/notes` | List notes API endpoint |
|
|
59
|
+
| `pages/api/(v1)/notes.get.js` | `GET` | `/api/notes` | API route inside Route Group |
|
|
57
60
|
| `pages/api/notes.post.js` | `POST` | `/api/notes` | Create note API endpoint |
|
|
58
61
|
| `pages/api/notes/[id].get.js` | `GET` | `/api/notes/:id` | Get single note API endpoint |
|
|
59
62
|
| `pages/api/notes/[id].put.js` | `PUT` | `/api/notes/:id` | Update note API endpoint |
|
|
@@ -148,7 +151,50 @@ module.exports = {
|
|
|
148
151
|
|
|
149
152
|
---
|
|
150
153
|
|
|
151
|
-
## 4.
|
|
154
|
+
## 4. Route Groups `(group)` Syntax & Layout Isolation
|
|
155
|
+
|
|
156
|
+
Webspresso supports Next.js / Nuxt-style **Route Groups**. Any directory name wrapped in parentheses `(groupName)` is treated as an organizational group:
|
|
157
|
+
- **Clean Public URLs**: The parenthesized directory name is completely omitted from the generated URL path.
|
|
158
|
+
- **Layout & Middleware Isolation**: The directory hierarchy is preserved for cascading middleware (`_middleware.js`) and nested layouts (`_layout.njk`), allowing distinct shells and security guards without polluting public route URLs.
|
|
159
|
+
- **Nesting & Dynamic Route Support**: Route groups can be nested (e.g. `(shop)/(checkout)/cart.njk` → `/cart`), combined with dynamic segments (e.g. `(app)/users/[id].njk` → `/users/:id`), and applied to API routes (`pages/api/(v1)/items.get.js` → `/api/items` or `pages/(marketing)/api/newsletter.post.js` → `/api/newsletter`).
|
|
160
|
+
|
|
161
|
+
### 4.1 Route Group Structure Example
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
pages/
|
|
165
|
+
├── (marketing)/
|
|
166
|
+
│ ├── _layout.njk # Marketing layout (Hero header, footer)
|
|
167
|
+
│ ├── _middleware.js # Track analytics / marketing headers
|
|
168
|
+
│ ├── index.njk -> GET /
|
|
169
|
+
│ ├── about.njk -> GET /about
|
|
170
|
+
│ └── api/
|
|
171
|
+
│ └── newsletter.post.js -> POST /api/newsletter
|
|
172
|
+
├── (auth)/
|
|
173
|
+
│ ├── _layout.njk # Centered card layout (no nav/footer)
|
|
174
|
+
│ ├── _middleware.js # Guest-only guard (redirects authenticated users)
|
|
175
|
+
│ ├── login.njk -> GET /login
|
|
176
|
+
│ └── register.njk -> GET /register
|
|
177
|
+
└── (app)/
|
|
178
|
+
├── _layout.njk # App shell layout with sidebar
|
|
179
|
+
├── _middleware.js # Strict session auth guard
|
|
180
|
+
└── dashboard.njk -> GET /dashboard
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### 4.2 Programmatic Utilities
|
|
184
|
+
Webspresso exports core helpers to inspect and transform route group paths:
|
|
185
|
+
```javascript
|
|
186
|
+
const { isRouteGroupSegment, stripRouteGroups } = require('webspresso');
|
|
187
|
+
|
|
188
|
+
isRouteGroupSegment('(marketing)'); // true
|
|
189
|
+
isRouteGroupSegment('about'); // false
|
|
190
|
+
|
|
191
|
+
stripRouteGroups('/(marketing)/about'); // '/about'
|
|
192
|
+
stripRouteGroups('/(shop)/(checkout)/cart'); // '/cart'
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 5. SSR Page Routes (`load()` Loader)
|
|
152
198
|
|
|
153
199
|
For server-rendered Nunjucks HTML pages, use `.njk` templates and companion `.js` loader files (or inline `async function load` in a `.js` file):
|
|
154
200
|
|
|
@@ -174,7 +220,7 @@ module.exports = {
|
|
|
174
220
|
|
|
175
221
|
---
|
|
176
222
|
|
|
177
|
-
##
|
|
223
|
+
## 6. Module & File-Based Discovery (`src/pages`, `src/api`, `modules/{name}`)
|
|
178
224
|
|
|
179
225
|
Webspresso supports clean, domain-driven module discovery alongside root-level pages and APIs:
|
|
180
226
|
|
|
@@ -204,7 +250,7 @@ src/
|
|
|
204
250
|
auth-guard.js -> module-local named middleware 'authGuard'
|
|
205
251
|
```
|
|
206
252
|
|
|
207
|
-
###
|
|
253
|
+
### 6.1 `definePage()` Contract
|
|
208
254
|
```javascript
|
|
209
255
|
const { definePage } = require('webspresso');
|
|
210
256
|
|
|
@@ -230,7 +276,7 @@ module.exports = definePage({
|
|
|
230
276
|
});
|
|
231
277
|
```
|
|
232
278
|
|
|
233
|
-
###
|
|
279
|
+
### 6.2 `defineApi()` Contract
|
|
234
280
|
```javascript
|
|
235
281
|
const { defineApi, z } = require('webspresso');
|
|
236
282
|
|
|
@@ -252,7 +298,7 @@ module.exports = defineApi({
|
|
|
252
298
|
});
|
|
253
299
|
```
|
|
254
300
|
|
|
255
|
-
###
|
|
301
|
+
### 6.3 `defineModule()` Contract
|
|
256
302
|
```javascript
|
|
257
303
|
const { defineModule } = require('webspresso');
|
|
258
304
|
|
|
@@ -270,7 +316,7 @@ module.exports = defineModule({
|
|
|
270
316
|
});
|
|
271
317
|
```
|
|
272
318
|
|
|
273
|
-
###
|
|
319
|
+
### 6.4 Route Introspection (`app.routes.list()`)
|
|
274
320
|
Compiled routes are accessible at runtime for tooling, CLI inspection, and documentation:
|
|
275
321
|
```javascript
|
|
276
322
|
const routeList = app.routes.list();
|
|
@@ -280,9 +326,83 @@ const routeList = app.routes.list();
|
|
|
280
326
|
// ]
|
|
281
327
|
```
|
|
282
328
|
|
|
329
|
+
### 6.5 Server-Sent Events (SSE) Streaming (`res.sse`)
|
|
330
|
+
For real-time event streaming and LLM token outputs, use the native `res.sse()` helper (or `{ sse }` from `ctx`):
|
|
331
|
+
|
|
332
|
+
```javascript
|
|
333
|
+
// File: pages/api/ai/chat.post.js
|
|
334
|
+
const { defineApi } = require('webspresso');
|
|
335
|
+
|
|
336
|
+
module.exports = defineApi({
|
|
337
|
+
handler: async (req, res, { sse }) => {
|
|
338
|
+
// 1. Controller mode with automatic keepalive & reconnection directives
|
|
339
|
+
const stream = sse({ heartbeat: 15000, retry: 3000 });
|
|
340
|
+
|
|
341
|
+
await stream.send({ delta: 'Hello ' });
|
|
342
|
+
await stream.send({ event: 'delta', data: { text: 'world!' }, id: '1' });
|
|
343
|
+
stream.close();
|
|
344
|
+
|
|
345
|
+
// 2. OR stream an async generator / LLM stream directly:
|
|
346
|
+
// return res.sse(async function*() {
|
|
347
|
+
// for await (const chunk of llmStream) {
|
|
348
|
+
// yield { token: chunk.text };
|
|
349
|
+
// }
|
|
350
|
+
// });
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### 6.6 Cascading Middleware (`_middleware.js`) & Nested Layouts (`_layout.njk`)
|
|
356
|
+
|
|
357
|
+
Webspresso supports folder-level cascading middleware inheritance and nested layouts across directory trees:
|
|
358
|
+
|
|
359
|
+
#### 1. Cascading Middleware (`_middleware.js`)
|
|
360
|
+
Any directory under `pages/` can declare a `_middleware.js` file:
|
|
361
|
+
- **Execution Order**: Middlewares execute in top-down ancestral order (from root down to route leaf) before route handlers and route-specific middleware.
|
|
362
|
+
- **Early Termination**: If any middleware sends headers (e.g. `res.status(401).json(...)` or `res.redirect('/login')`), execution halts immediately and downstream middlewares/handlers are skipped.
|
|
363
|
+
- **Export Formats**: Standard Express middleware function `(req, res, next)`, async function, named middleware string/array `['auth', 'cors']`, or middleware tuples with options `[['rateLimit', { max: 100 }]]`.
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
pages/
|
|
367
|
+
├── _middleware.js # Runs for ALL routes (adds global headers / request ID)
|
|
368
|
+
├── api/
|
|
369
|
+
│ └── admin/
|
|
370
|
+
│ ├── _middleware.js # Protects all /api/admin/* endpoints
|
|
371
|
+
│ └── metrics.get.js # Inherits root + admin middleware
|
|
372
|
+
└── dashboard/
|
|
373
|
+
├── _middleware.js # Auth guard for /dashboard/* pages
|
|
374
|
+
└── settings.njk
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
#### 2. Nested Layouts (`_layout.njk` & `_layout.js`)
|
|
378
|
+
Pages under `pages/` automatically inherit ancestor layouts:
|
|
379
|
+
- **Wrapping Order**: Child template is rendered first, then wrapped by layouts from innermost to outermost using `{{ content | safe }}`.
|
|
380
|
+
- **Layout Loaders (`_layout.js`)**: Companion `_layout.js` files can export `async function load(req, ctx)` to supply shared data (e.g., site title, navigation, breadcrumbs) in top-down order before the page loader runs.
|
|
381
|
+
- **Opt-Out**: A page can opt out of automatic layout wrapping by setting `layout: false` in its companion `.js` config/loader, or by explicitly using `{% extends "..." %}` in its Nunjucks template.
|
|
382
|
+
|
|
383
|
+
```html
|
|
384
|
+
{# pages/_layout.njk #}
|
|
385
|
+
<!DOCTYPE html>
|
|
386
|
+
<html>
|
|
387
|
+
<head><title>{{ siteTitle }}</title></head>
|
|
388
|
+
<body>
|
|
389
|
+
<header>Global Nav</header>
|
|
390
|
+
{{ content | safe }}
|
|
391
|
+
</body>
|
|
392
|
+
</html>
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
```html
|
|
396
|
+
{# pages/dashboard/_layout.njk #}
|
|
397
|
+
<div class="dashboard-shell">
|
|
398
|
+
<aside>Dashboard Sidebar</aside>
|
|
399
|
+
<main>{{ content | safe }}</main>
|
|
400
|
+
</div>
|
|
401
|
+
```
|
|
402
|
+
|
|
283
403
|
---
|
|
284
404
|
|
|
285
|
-
##
|
|
405
|
+
## 7. Summary Checklist for Agents
|
|
286
406
|
|
|
287
407
|
When a user asks to add or modify endpoints:
|
|
288
408
|
1. Identify the URL and method (e.g. `POST /api/auth/register` or `POST /auth/login`).
|
package/AGENTS.md
CHANGED
|
@@ -142,6 +142,7 @@ Plugins expose `name`, `version`, `dependencies`, and lifecycle hooks (`register
|
|
|
142
142
|
- `restResourcePlugin`: Zero-boilerplate RESTful CRUD API generator directly from ORM models with eager loading, sanitization, filtering, pagination, and soft-delete scoping.
|
|
143
143
|
- `xlsxPlugin`: Excel spreadsheet generation, parsing, ORM streaming export (`res.xlsx`), and built-in services (`xlsx.generate`, `xlsx.parse`, `xlsx.exportModel`).
|
|
144
144
|
- `csvPlugin`: RFC 4180 CSV generator, parser, Excel UTF-8 BOM compatibility, formula sanitization, streaming writer (`res.csv`), and built-in services (`csv.generate`, `csv.parse`, `csv.exportModel`).
|
|
145
|
+
- `apiKeyPlugin`: Zero-dependency B2B & Developer API Key Management with SHA-256 secure storage, constant-time verification, granular wildcard scopes, named route middleware (`ctx.middlewares.apiKey`), and non-blocking usage tracking.
|
|
145
146
|
|
|
146
147
|
---
|
|
147
148
|
|
package/core/auth/index.js
CHANGED
|
@@ -15,6 +15,37 @@ const {
|
|
|
15
15
|
dropAuthTokensTable,
|
|
16
16
|
createKnexAuthTokensAdapter,
|
|
17
17
|
} = require('./tokens');
|
|
18
|
+
const {
|
|
19
|
+
OAuthClient,
|
|
20
|
+
OAuthError,
|
|
21
|
+
createOAuthClient,
|
|
22
|
+
generatePKCE,
|
|
23
|
+
createSignedState,
|
|
24
|
+
verifySignedState,
|
|
25
|
+
DEFAULT_PROVIDERS,
|
|
26
|
+
} = require('./oauth');
|
|
27
|
+
const totp = require('./totp');
|
|
28
|
+
const {
|
|
29
|
+
base32Encode,
|
|
30
|
+
base32Decode,
|
|
31
|
+
generateTotpSecret,
|
|
32
|
+
generateHotp,
|
|
33
|
+
verifyHotp,
|
|
34
|
+
generateTotp,
|
|
35
|
+
verifyTotp,
|
|
36
|
+
generateTotpUri,
|
|
37
|
+
generateTotpQrSvg,
|
|
38
|
+
generateTotpQrDataUri,
|
|
39
|
+
generateRecoveryCodes,
|
|
40
|
+
hashRecoveryCode,
|
|
41
|
+
verifyRecoveryCode,
|
|
42
|
+
createTotpSetup,
|
|
43
|
+
} = totp;
|
|
44
|
+
const {
|
|
45
|
+
createQrMatrix,
|
|
46
|
+
generateQrSvg,
|
|
47
|
+
generateQrDataUri,
|
|
48
|
+
} = require('./qrcode');
|
|
18
49
|
|
|
19
50
|
/**
|
|
20
51
|
* Create authentication instance
|
|
@@ -195,6 +226,35 @@ module.exports = {
|
|
|
195
226
|
createKnexAuthTokensAdapter,
|
|
196
227
|
TOKEN_TYPES,
|
|
197
228
|
|
|
229
|
+
// OAuth 2.0 & Social Login
|
|
230
|
+
OAuthClient,
|
|
231
|
+
OAuthError,
|
|
232
|
+
createOAuthClient,
|
|
233
|
+
generatePKCE,
|
|
234
|
+
createSignedState,
|
|
235
|
+
verifySignedState,
|
|
236
|
+
DEFAULT_PROVIDERS,
|
|
237
|
+
|
|
238
|
+
// 2FA / TOTP (RFC 6238, RFC 4226, RFC 4648)
|
|
239
|
+
totp,
|
|
240
|
+
base32Encode,
|
|
241
|
+
base32Decode,
|
|
242
|
+
generateTotpSecret,
|
|
243
|
+
generateHotp,
|
|
244
|
+
verifyHotp,
|
|
245
|
+
generateTotp,
|
|
246
|
+
verifyTotp,
|
|
247
|
+
generateTotpUri,
|
|
248
|
+
generateTotpQrSvg,
|
|
249
|
+
generateTotpQrDataUri,
|
|
250
|
+
generateRecoveryCodes,
|
|
251
|
+
hashRecoveryCode,
|
|
252
|
+
verifyRecoveryCode,
|
|
253
|
+
createTotpSetup,
|
|
254
|
+
createQrMatrix,
|
|
255
|
+
generateQrSvg,
|
|
256
|
+
generateQrDataUri,
|
|
257
|
+
|
|
198
258
|
// Config
|
|
199
259
|
DEFAULT_CONFIG,
|
|
200
260
|
};
|
package/core/auth/manager.js
CHANGED
|
@@ -60,6 +60,7 @@ const DEFAULT_CONFIG = {
|
|
|
60
60
|
logout: '/logout',
|
|
61
61
|
home: '/',
|
|
62
62
|
redirectAfterLogin: '/dashboard',
|
|
63
|
+
twoFactor: '/2fa',
|
|
63
64
|
},
|
|
64
65
|
};
|
|
65
66
|
|
|
@@ -107,6 +108,10 @@ class AuthManager {
|
|
|
107
108
|
welcome: config.notifications?.welcome || null,
|
|
108
109
|
};
|
|
109
110
|
|
|
111
|
+
// TOTP & 2FA initialization
|
|
112
|
+
this.totp = require('./totp');
|
|
113
|
+
this.twoFactor = config.twoFactor || { enabled: false };
|
|
114
|
+
|
|
110
115
|
if (config.jwt) {
|
|
111
116
|
const jwtSecret = typeof config.jwt === 'object' && config.jwt.secret
|
|
112
117
|
? config.jwt.secret
|
|
@@ -329,6 +334,9 @@ class AuthManager {
|
|
|
329
334
|
}
|
|
330
335
|
}
|
|
331
336
|
|
|
337
|
+
// Clear 2FA session state
|
|
338
|
+
this.clear2Fa();
|
|
339
|
+
|
|
332
340
|
// Clear user
|
|
333
341
|
req.user = null;
|
|
334
342
|
|
|
@@ -377,6 +385,119 @@ class AuthManager {
|
|
|
377
385
|
return req.user?.id || null;
|
|
378
386
|
},
|
|
379
387
|
|
|
388
|
+
/**
|
|
389
|
+
* TOTP helper utilities (RFC 6238 / RFC 4226)
|
|
390
|
+
*/
|
|
391
|
+
totp: self.totp,
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Check if two-factor authentication has been verified in current session/request
|
|
395
|
+
* @returns {boolean}
|
|
396
|
+
*/
|
|
397
|
+
is2FaVerified() {
|
|
398
|
+
if (req.session?.twoFactorVerified) return true;
|
|
399
|
+
if (req.jwtPayload?.twoFactorVerified) return true;
|
|
400
|
+
return false;
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Check if two-factor authentication is pending verification
|
|
405
|
+
* @returns {boolean}
|
|
406
|
+
*/
|
|
407
|
+
is2FaPending() {
|
|
408
|
+
return Boolean(req.session?.twoFactorPending);
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Mark two-factor authentication as verified
|
|
413
|
+
* @param {boolean} [verified=true]
|
|
414
|
+
*/
|
|
415
|
+
set2FaVerified(verified = true) {
|
|
416
|
+
if (req.session) {
|
|
417
|
+
req.session.twoFactorVerified = Boolean(verified);
|
|
418
|
+
if (verified) {
|
|
419
|
+
delete req.session.twoFactorPending;
|
|
420
|
+
delete req.session.twoFactorUserId;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Mark two-factor authentication as pending verification for a user
|
|
427
|
+
* @param {Object} [user] - User object or user ID
|
|
428
|
+
*/
|
|
429
|
+
set2FaPending(user) {
|
|
430
|
+
if (req.session) {
|
|
431
|
+
req.session.twoFactorPending = true;
|
|
432
|
+
req.session.twoFactorVerified = false;
|
|
433
|
+
if (user?.id) {
|
|
434
|
+
req.session.twoFactorUserId = user.id;
|
|
435
|
+
} else if (typeof user === 'string' || typeof user === 'number') {
|
|
436
|
+
req.session.twoFactorUserId = user;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Clear two-factor authentication status from session
|
|
443
|
+
*/
|
|
444
|
+
clear2Fa() {
|
|
445
|
+
if (req.session) {
|
|
446
|
+
delete req.session.twoFactorVerified;
|
|
447
|
+
delete req.session.twoFactorPending;
|
|
448
|
+
delete req.session.twoFactorUserId;
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Verify submitted TOTP token or recovery code
|
|
454
|
+
* Automatically marks 2FA as verified upon success
|
|
455
|
+
* @param {string} tokenOrCode - Submitted 6-digit TOTP code or recovery code
|
|
456
|
+
* @param {Object|string} secretOrUser - Secret string or user object with totp_secret / recovery_codes
|
|
457
|
+
* @param {Object} [options]
|
|
458
|
+
* @returns {{ success: boolean, type?: 'totp'|'recovery_code', delta?: number, remainingHashedCodes?: string[], matchedIndex?: number }}
|
|
459
|
+
*/
|
|
460
|
+
verify2Fa(tokenOrCode, secretOrUser, options = {}) {
|
|
461
|
+
if (!tokenOrCode) {
|
|
462
|
+
return { success: false };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let secret = null;
|
|
466
|
+
let recoveryCodes = null;
|
|
467
|
+
|
|
468
|
+
if (typeof secretOrUser === 'string') {
|
|
469
|
+
secret = secretOrUser;
|
|
470
|
+
} else if (secretOrUser && typeof secretOrUser === 'object') {
|
|
471
|
+
secret = secretOrUser.totp_secret || secretOrUser.totpSecret || secretOrUser.two_factor_secret || secretOrUser.twoFactorSecret;
|
|
472
|
+
recoveryCodes = secretOrUser.recovery_codes || secretOrUser.recoveryCodes || secretOrUser.two_factor_recovery_codes;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Try TOTP code first
|
|
476
|
+
if (secret) {
|
|
477
|
+
const totpResult = self.totp.verifyTotp(tokenOrCode, secret, { ...options, detailed: true });
|
|
478
|
+
if (totpResult.valid) {
|
|
479
|
+
this.set2FaVerified(true);
|
|
480
|
+
return { success: true, type: 'totp', delta: totpResult.delta };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Try recovery codes if provided
|
|
485
|
+
if (recoveryCodes && Array.isArray(recoveryCodes) && recoveryCodes.length > 0) {
|
|
486
|
+
const recResult = self.totp.verifyRecoveryCode(tokenOrCode, recoveryCodes);
|
|
487
|
+
if (recResult.valid) {
|
|
488
|
+
this.set2FaVerified(true);
|
|
489
|
+
return {
|
|
490
|
+
success: true,
|
|
491
|
+
type: 'recovery_code',
|
|
492
|
+
matchedIndex: recResult.matchedIndex,
|
|
493
|
+
remainingHashedCodes: recResult.remainingHashedCodes,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { success: false };
|
|
499
|
+
},
|
|
500
|
+
|
|
380
501
|
/**
|
|
381
502
|
* Check if user can perform action (policy check)
|
|
382
503
|
* @param {string} action - Action name
|
|
@@ -801,6 +922,19 @@ class AuthManager {
|
|
|
801
922
|
}
|
|
802
923
|
return this._authMiddleware.requireJwt(options);
|
|
803
924
|
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Get require2Fa guard middleware for Express routes
|
|
928
|
+
* @param {Object} [options]
|
|
929
|
+
* @returns {Function} Express middleware
|
|
930
|
+
*/
|
|
931
|
+
require2Fa(options = {}) {
|
|
932
|
+
if (!this._authMiddleware) {
|
|
933
|
+
const { createAuthMiddleware } = require('./middleware');
|
|
934
|
+
this._authMiddleware = createAuthMiddleware(this);
|
|
935
|
+
}
|
|
936
|
+
return this._authMiddleware.require2Fa(options);
|
|
937
|
+
}
|
|
804
938
|
}
|
|
805
939
|
|
|
806
940
|
module.exports = {
|
package/core/auth/middleware.js
CHANGED
|
@@ -255,6 +255,83 @@ function createAuthMiddleware(authManager) {
|
|
|
255
255
|
};
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Require Two-Factor Authentication (2FA / TOTP) middleware
|
|
260
|
+
* Checks if user has completed 2FA step if 2FA is required or enabled for the user
|
|
261
|
+
* @param {Object} [options] - Options
|
|
262
|
+
* @param {boolean} [options.always=false] - Always enforce 2FA verification regardless of user flag
|
|
263
|
+
* @param {boolean} [options.api=false] - Return JSON error instead of redirect
|
|
264
|
+
* @param {string} [options.redirectTo] - Redirect URL (default: config.routes.twoFactor || '/2fa')
|
|
265
|
+
* @param {string} [options.redirectToLogin] - Redirect URL if unauthenticated (default: config.routes.login)
|
|
266
|
+
* @param {Function} [options.isUser2FaEnabled] - Custom predicate (user) => boolean
|
|
267
|
+
*/
|
|
268
|
+
function require2Fa(options = {}) {
|
|
269
|
+
return (req, res, next) => {
|
|
270
|
+
// 1. Must be logged in or have a pending 2FA authentication session
|
|
271
|
+
if (!req.user && !req.session?.twoFactorPending) {
|
|
272
|
+
if (options.api || req.xhr || req.headers?.accept?.includes('application/json')) {
|
|
273
|
+
return res.status(401).json({
|
|
274
|
+
error: 'Unauthorized',
|
|
275
|
+
message: 'Authentication required',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (req.session) {
|
|
279
|
+
req.session.intendedUrl = req.originalUrl;
|
|
280
|
+
}
|
|
281
|
+
const loginUrl = options.redirectToLogin || config.routes.login;
|
|
282
|
+
return res.redirect(loginUrl);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 2. Check if 2FA is required for this user or route
|
|
286
|
+
let userRequires2Fa = options.always === true;
|
|
287
|
+
if (!userRequires2Fa) {
|
|
288
|
+
if (typeof options.isUser2FaEnabled === 'function') {
|
|
289
|
+
userRequires2Fa = options.isUser2FaEnabled(req.user);
|
|
290
|
+
} else if (req.user) {
|
|
291
|
+
userRequires2Fa = Boolean(
|
|
292
|
+
req.user.two_factor_enabled ||
|
|
293
|
+
req.user.twoFactorEnabled ||
|
|
294
|
+
req.user.totp_enabled ||
|
|
295
|
+
req.user.totpEnabled
|
|
296
|
+
);
|
|
297
|
+
} else if (req.session?.twoFactorPending) {
|
|
298
|
+
userRequires2Fa = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// If user does not have 2FA enabled and route does not enforce it unconditionally, pass
|
|
303
|
+
if (!userRequires2Fa) {
|
|
304
|
+
return next();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 3. Check if 2FA is already verified in this session
|
|
308
|
+
const isVerified = Boolean(
|
|
309
|
+
req.auth?.is2FaVerified?.() ||
|
|
310
|
+
req.session?.twoFactorVerified ||
|
|
311
|
+
req.jwtPayload?.twoFactorVerified
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
if (isVerified) {
|
|
315
|
+
return next();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 4. Not verified -> reject with 403 or redirect to 2FA verification page
|
|
319
|
+
if (options.api || req.xhr || req.headers?.accept?.includes('application/json')) {
|
|
320
|
+
return res.status(403).json({
|
|
321
|
+
error: 'TwoFactorRequired',
|
|
322
|
+
message: 'Two-factor authentication required',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (req.session) {
|
|
327
|
+
req.session.intendedUrl = req.originalUrl;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const redirectTo = options.redirectTo || config.routes.twoFactor || '/2fa';
|
|
331
|
+
return res.redirect(redirectTo);
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
258
335
|
/**
|
|
259
336
|
* Parse middleware string like 'can:edit,post' into middleware function
|
|
260
337
|
* @param {string} middlewareString - Middleware string
|
|
@@ -273,6 +350,13 @@ function createAuthMiddleware(authManager) {
|
|
|
273
350
|
|
|
274
351
|
case 'guest':
|
|
275
352
|
return requireGuest();
|
|
353
|
+
|
|
354
|
+
case '2fa':
|
|
355
|
+
case 'totp':
|
|
356
|
+
return require2Fa({
|
|
357
|
+
api: args.includes('api'),
|
|
358
|
+
always: args.includes('always'),
|
|
359
|
+
});
|
|
276
360
|
|
|
277
361
|
case 'can':
|
|
278
362
|
// can:action,policy
|
|
@@ -297,6 +381,8 @@ function createAuthMiddleware(authManager) {
|
|
|
297
381
|
requireAuth,
|
|
298
382
|
requireJwt,
|
|
299
383
|
requireGuest,
|
|
384
|
+
require2Fa,
|
|
385
|
+
requireTotp: require2Fa,
|
|
300
386
|
requireCan,
|
|
301
387
|
requireVerified,
|
|
302
388
|
|
|
@@ -307,6 +393,8 @@ function createAuthMiddleware(authManager) {
|
|
|
307
393
|
auth: requireAuth,
|
|
308
394
|
jwt: requireJwt,
|
|
309
395
|
guest: requireGuest,
|
|
396
|
+
twoFactor: require2Fa,
|
|
397
|
+
totp: require2Fa,
|
|
310
398
|
};
|
|
311
399
|
}
|
|
312
400
|
|