webspresso 0.0.84 → 0.0.85
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 +80 -0
- package/bin/commands/orm-map.js +1 -1
- package/core/auth/index.js +10 -42
- package/core/auth/jwt.js +235 -0
- package/core/auth/manager.js +193 -0
- package/core/auth/middleware.js +66 -4
- package/index.d.ts +22 -0
- package/package.json +3 -3
- package/plugins/admin-panel/index.js +9 -3
- package/plugins/admin-panel/modules/custom-pages.js +9 -1
- package/plugins/content/client/inline-edit.js +33 -1
- package/plugins/cors.js +171 -0
- package/plugins/csrf/index.js +8 -1
- package/plugins/index.js +2 -0
- package/src/server.js +4 -2
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +15 -7
package/README.md
CHANGED
|
@@ -1316,6 +1316,86 @@ createApp({
|
|
|
1316
1316
|
});
|
|
1317
1317
|
```
|
|
1318
1318
|
|
|
1319
|
+
### Authentication & JWT Support (`quickAuth`)
|
|
1320
|
+
|
|
1321
|
+
Webspresso provides a zero-dependency **Dual Authentication** system (`webspresso/core/auth`):
|
|
1322
|
+
- **Session + Cookie**: Stateful auth for browser SSR routes and Admin Panel (`/`, `/_admin`).
|
|
1323
|
+
- **JWT + Refresh Token**: Stateless HS256 auth for REST APIs, Mobile Apps, and SPAs (`/api/*`).
|
|
1324
|
+
|
|
1325
|
+
```javascript
|
|
1326
|
+
// createApp setup with quickAuth
|
|
1327
|
+
const { quickAuth } = require('webspresso/core/auth');
|
|
1328
|
+
|
|
1329
|
+
const auth = quickAuth({
|
|
1330
|
+
db,
|
|
1331
|
+
userModel: 'User',
|
|
1332
|
+
jwt: {
|
|
1333
|
+
expiresIn: '15m', // Access token duration (default: 24h)
|
|
1334
|
+
refreshExpiresIn: '30d', // Refresh token duration (default: 30d)
|
|
1335
|
+
},
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
createApp({
|
|
1339
|
+
pagesDir: './pages',
|
|
1340
|
+
db,
|
|
1341
|
+
auth,
|
|
1342
|
+
});
|
|
1343
|
+
```
|
|
1344
|
+
|
|
1345
|
+
**File-Based API Route Examples (`pages/api/*`):**
|
|
1346
|
+
|
|
1347
|
+
```javascript
|
|
1348
|
+
// 1. pages/api/auth/login.post.js (Login & Token Generation)
|
|
1349
|
+
module.exports = {
|
|
1350
|
+
schema: ({ z }) => ({
|
|
1351
|
+
body: z.object({
|
|
1352
|
+
email: z.string().email(),
|
|
1353
|
+
password: z.string().min(1),
|
|
1354
|
+
}),
|
|
1355
|
+
}),
|
|
1356
|
+
async handler(req, res) {
|
|
1357
|
+
const { email, password } = req.input.body;
|
|
1358
|
+
const user = await req.auth.attempt(email, password);
|
|
1359
|
+
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
|
1360
|
+
|
|
1361
|
+
const accessToken = req.auth.generateUserToken(user);
|
|
1362
|
+
const refreshToken = req.auth.generateRefreshToken(user);
|
|
1363
|
+
|
|
1364
|
+
return res.json({ accessToken, refreshToken, user: { id: user.id, email: user.email } });
|
|
1365
|
+
},
|
|
1366
|
+
};
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
```javascript
|
|
1370
|
+
// 2. pages/api/auth/refresh.post.js (Token Rotation Endpoint)
|
|
1371
|
+
module.exports = {
|
|
1372
|
+
schema: ({ z }) => ({
|
|
1373
|
+
body: z.object({
|
|
1374
|
+
refreshToken: z.string(),
|
|
1375
|
+
}),
|
|
1376
|
+
}),
|
|
1377
|
+
async handler(req, res) {
|
|
1378
|
+
const { refreshToken } = req.input.body;
|
|
1379
|
+
try {
|
|
1380
|
+
const result = await req.auth.refreshAccessToken(refreshToken, { rotate: true });
|
|
1381
|
+
return res.json({ accessToken: result.accessToken, refreshToken: result.refreshToken });
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
return res.status(401).json({ error: err.message });
|
|
1384
|
+
}
|
|
1385
|
+
},
|
|
1386
|
+
};
|
|
1387
|
+
```
|
|
1388
|
+
|
|
1389
|
+
```javascript
|
|
1390
|
+
// 3. pages/api/profile.get.js (Protected REST API Route)
|
|
1391
|
+
module.exports = {
|
|
1392
|
+
middleware: ['jwt'],
|
|
1393
|
+
async handler(req, res) {
|
|
1394
|
+
return res.json({ user: req.user, token: req.token });
|
|
1395
|
+
},
|
|
1396
|
+
};
|
|
1397
|
+
```
|
|
1398
|
+
|
|
1319
1399
|
**With Schema Validation (same object shape):**
|
|
1320
1400
|
|
|
1321
1401
|
```javascript
|
package/bin/commands/orm-map.js
CHANGED
|
@@ -54,7 +54,7 @@ function openFile(filePath, cwd) {
|
|
|
54
54
|
if (process.platform === 'darwin') {
|
|
55
55
|
execFileSync('open', [fp], { stdio: 'ignore' });
|
|
56
56
|
} else if (process.platform === 'win32') {
|
|
57
|
-
execFileSync('
|
|
57
|
+
execFileSync('explorer.exe', [fp.replace(/\//g, '\\')], { stdio: 'ignore' });
|
|
58
58
|
} else {
|
|
59
59
|
execFileSync('xdg-open', [fp], { stdio: 'ignore' });
|
|
60
60
|
}
|
package/core/auth/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const { AuthManager, AuthenticationError, DEFAULT_CONFIG } = require('./manager'
|
|
|
8
8
|
const { PolicyManager, AuthorizationError } = require('./policy');
|
|
9
9
|
const { createAuthMiddleware, setupAuthMiddleware } = require('./middleware');
|
|
10
10
|
const { hash, verify, needsRehash, generateToken, hashToken } = require('./hash');
|
|
11
|
+
const { signJwt, verifyJwt, decodeJwt } = require('./jwt');
|
|
11
12
|
const {
|
|
12
13
|
TOKEN_TYPES,
|
|
13
14
|
createAuthTokensTable,
|
|
@@ -23,50 +24,9 @@ const {
|
|
|
23
24
|
* @param {Object} [config.rememberTokens] - Remember token adapter
|
|
24
25
|
* @param {Object} [config.session] - Session configuration
|
|
25
26
|
* @param {Object} [config.rememberMe] - Remember me configuration
|
|
27
|
+
* @param {Object|boolean} [config.jwt] - JWT configuration
|
|
26
28
|
* @param {Object} [config.routes] - Route configuration
|
|
27
29
|
* @returns {AuthManager}
|
|
28
|
-
*
|
|
29
|
-
* @example
|
|
30
|
-
* const auth = createAuth({
|
|
31
|
-
* findUserById: async (id) => {
|
|
32
|
-
* return await UserRepo.findById(id);
|
|
33
|
-
* },
|
|
34
|
-
*
|
|
35
|
-
* findUserByCredentials: async (email, password) => {
|
|
36
|
-
* const user = await UserRepo.findOne({ email });
|
|
37
|
-
* if (user && await verify(password, user.password)) {
|
|
38
|
-
* return user;
|
|
39
|
-
* }
|
|
40
|
-
* return null;
|
|
41
|
-
* },
|
|
42
|
-
*
|
|
43
|
-
* // Optional: Remember me tokens
|
|
44
|
-
* rememberTokens: {
|
|
45
|
-
* create: async (userId, token, expiresAt) => {
|
|
46
|
-
* await db.knex('remember_tokens').insert({
|
|
47
|
-
* user_id: userId,
|
|
48
|
-
* token,
|
|
49
|
-
* expires_at: expiresAt,
|
|
50
|
-
* });
|
|
51
|
-
* },
|
|
52
|
-
* find: async (token) => {
|
|
53
|
-
* return await db.knex('remember_tokens').where({ token }).first();
|
|
54
|
-
* },
|
|
55
|
-
* delete: async (token) => {
|
|
56
|
-
* await db.knex('remember_tokens').where({ token }).delete();
|
|
57
|
-
* },
|
|
58
|
-
* deleteAllForUser: async (userId) => {
|
|
59
|
-
* await db.knex('remember_tokens').where({ user_id: userId }).delete();
|
|
60
|
-
* },
|
|
61
|
-
* },
|
|
62
|
-
*
|
|
63
|
-
* session: {
|
|
64
|
-
* secret: process.env.SESSION_SECRET,
|
|
65
|
-
* cookie: {
|
|
66
|
-
* maxAge: 24 * 60 * 60 * 1000, // 1 day
|
|
67
|
-
* },
|
|
68
|
-
* },
|
|
69
|
-
* });
|
|
70
30
|
*/
|
|
71
31
|
function createAuth(config) {
|
|
72
32
|
return new AuthManager(config);
|
|
@@ -82,6 +42,7 @@ function createAuth(config) {
|
|
|
82
42
|
* @param {Object} [options.session] - Session config
|
|
83
43
|
* @param {boolean} [options.rememberMe=true] - Enable remember me
|
|
84
44
|
* @param {boolean} [options.authTokens=false] - Enable auth_tokens adapter (password reset / email verify)
|
|
45
|
+
* @param {Object|boolean} [options.jwt=false] - Enable JWT support or JWT config
|
|
85
46
|
* @param {string} [options.verifiedField='email_verified_at'] - Verified timestamp column
|
|
86
47
|
* @returns {AuthManager}
|
|
87
48
|
*/
|
|
@@ -94,6 +55,7 @@ function quickAuth(options) {
|
|
|
94
55
|
session = {},
|
|
95
56
|
rememberMe = true,
|
|
96
57
|
authTokens: enableAuthTokens = false,
|
|
58
|
+
jwt = false,
|
|
97
59
|
verifiedField = 'email_verified_at',
|
|
98
60
|
} = options;
|
|
99
61
|
|
|
@@ -127,6 +89,7 @@ function quickAuth(options) {
|
|
|
127
89
|
identifierField,
|
|
128
90
|
passwordField,
|
|
129
91
|
verifiedField,
|
|
92
|
+
jwt,
|
|
130
93
|
|
|
131
94
|
session: {
|
|
132
95
|
secret: process.env.SESSION_SECRET || session.secret,
|
|
@@ -218,6 +181,11 @@ module.exports = {
|
|
|
218
181
|
needsRehash,
|
|
219
182
|
generateToken,
|
|
220
183
|
hashToken,
|
|
184
|
+
|
|
185
|
+
// JWT utilities
|
|
186
|
+
signJwt,
|
|
187
|
+
verifyJwt,
|
|
188
|
+
decodeJwt,
|
|
221
189
|
|
|
222
190
|
// Migration helpers
|
|
223
191
|
createRememberTokensTable,
|
package/core/auth/jwt.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Auth - Zero-dependency JWT implementation
|
|
3
|
+
* Uses Node.js native crypto module for HS256 HMAC-SHA256 signatures
|
|
4
|
+
* @module core/auth/jwt
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base64URL encode string or Buffer
|
|
11
|
+
* @param {string|Buffer} input
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function base64UrlEncode(input) {
|
|
15
|
+
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input, 'utf8');
|
|
16
|
+
return buf
|
|
17
|
+
.toString('base64')
|
|
18
|
+
.replace(/=/g, '')
|
|
19
|
+
.replace(/\+/g, '-')
|
|
20
|
+
.replace(/\//g, '_');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Base64URL decode string
|
|
25
|
+
* @param {string} input
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function base64UrlDecode(input) {
|
|
29
|
+
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
|
30
|
+
while (base64.length % 4 !== 0) {
|
|
31
|
+
base64 += '=';
|
|
32
|
+
}
|
|
33
|
+
return Buffer.from(base64, 'base64').toString('utf8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse duration string (e.g. '1h', '7d', '15m', '3600') into seconds
|
|
38
|
+
* @param {string|number} duration
|
|
39
|
+
* @returns {number} Seconds
|
|
40
|
+
*/
|
|
41
|
+
function parseDuration(duration) {
|
|
42
|
+
if (typeof duration === 'number') {
|
|
43
|
+
return duration;
|
|
44
|
+
}
|
|
45
|
+
if (typeof duration !== 'string') {
|
|
46
|
+
return 3600; // Default 1 hour
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const match = duration.trim().match(/^(\d+)\s*([smhd])?$/i);
|
|
50
|
+
if (!match) {
|
|
51
|
+
const parsed = parseInt(duration, 10);
|
|
52
|
+
return isNaN(parsed) ? 3600 : parsed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const value = parseInt(match[1], 10);
|
|
56
|
+
const unit = (match[2] || 's').toLowerCase();
|
|
57
|
+
|
|
58
|
+
switch (unit) {
|
|
59
|
+
case 's': return value;
|
|
60
|
+
case 'm': return value * 60;
|
|
61
|
+
case 'h': return value * 3600;
|
|
62
|
+
case 'd': return value * 86400;
|
|
63
|
+
default: return value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sign payload to create HS256 JWT string
|
|
69
|
+
* @param {Object} payload - Payload object
|
|
70
|
+
* @param {string} secret - Secret key for signing
|
|
71
|
+
* @param {Object} [options] - Signing options
|
|
72
|
+
* @param {string|number} [options.expiresIn='24h'] - Expiration time (seconds or '1h', '7d', etc.)
|
|
73
|
+
* @param {string} [options.algorithm='HS256'] - Signature algorithm (HS256 supported)
|
|
74
|
+
* @param {string} [options.issuer] - Token issuer (iss)
|
|
75
|
+
* @param {string} [options.subject] - Token subject (sub)
|
|
76
|
+
* @param {string} [options.audience] - Token audience (aud)
|
|
77
|
+
* @returns {string} JWT string
|
|
78
|
+
*/
|
|
79
|
+
function signJwt(payload, secret, options = {}) {
|
|
80
|
+
if (!secret) {
|
|
81
|
+
throw new Error('Secret is required to sign JWT');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const algorithm = options.algorithm || 'HS256';
|
|
85
|
+
if (algorithm !== 'HS256') {
|
|
86
|
+
throw new Error(`Unsupported algorithm '${algorithm}'. Only HS256 is supported.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const header = {
|
|
90
|
+
alg: algorithm,
|
|
91
|
+
typ: 'JWT',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
95
|
+
const claims = {
|
|
96
|
+
iat: nowSec,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (options.expiresIn !== false) {
|
|
100
|
+
const ttlSec = parseDuration(options.expiresIn ?? '24h');
|
|
101
|
+
claims.exp = nowSec + ttlSec;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options.issuer) claims.iss = options.issuer;
|
|
105
|
+
if (options.subject) claims.sub = options.subject;
|
|
106
|
+
if (options.audience) claims.aud = options.audience;
|
|
107
|
+
|
|
108
|
+
const fullPayload = {
|
|
109
|
+
...claims,
|
|
110
|
+
...payload,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
114
|
+
const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
|
|
115
|
+
|
|
116
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
117
|
+
const signature = crypto
|
|
118
|
+
.createHmac('sha256', secret)
|
|
119
|
+
.update(signingInput)
|
|
120
|
+
.digest();
|
|
121
|
+
|
|
122
|
+
const encodedSignature = base64UrlEncode(signature);
|
|
123
|
+
|
|
124
|
+
return `${signingInput}.${encodedSignature}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Verify and decode HS256 JWT string
|
|
129
|
+
* @param {string} token - JWT string
|
|
130
|
+
* @param {string} secret - Secret key
|
|
131
|
+
* @param {Object} [options] - Verification options
|
|
132
|
+
* @param {boolean} [options.ignoreExpiration=false] - Ignore token expiration
|
|
133
|
+
* @param {string} [options.issuer] - Expected issuer
|
|
134
|
+
* @param {string} [options.audience] - Expected audience
|
|
135
|
+
* @returns {Object} Decoded payload
|
|
136
|
+
* @throws {Error} If token is invalid or expired
|
|
137
|
+
*/
|
|
138
|
+
function verifyJwt(token, secret, options = {}) {
|
|
139
|
+
if (!secret) {
|
|
140
|
+
throw new Error('Secret is required to verify JWT');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!token || typeof token !== 'string') {
|
|
144
|
+
throw new Error('JWT token must be a non-empty string');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const parts = token.split('.');
|
|
148
|
+
if (parts.length !== 3) {
|
|
149
|
+
throw new Error('Invalid JWT format');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
|
153
|
+
|
|
154
|
+
let header;
|
|
155
|
+
let payload;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
header = JSON.parse(base64UrlDecode(encodedHeader));
|
|
159
|
+
} catch (e) {
|
|
160
|
+
throw new Error('Invalid JWT header JSON');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
payload = JSON.parse(base64UrlDecode(encodedPayload));
|
|
165
|
+
} catch (e) {
|
|
166
|
+
throw new Error('Invalid JWT payload JSON');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (header.alg !== 'HS256') {
|
|
170
|
+
throw new Error(`Unsupported algorithm '${header.alg}' in JWT header`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Re-compute signature
|
|
174
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
175
|
+
const expectedSignatureBuf = crypto
|
|
176
|
+
.createHmac('sha256', secret)
|
|
177
|
+
.update(signingInput)
|
|
178
|
+
.digest();
|
|
179
|
+
|
|
180
|
+
const actualSignatureBuf = Buffer.from(encodedSignature.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
expectedSignatureBuf.length !== actualSignatureBuf.length ||
|
|
184
|
+
!crypto.timingSafeEqual(expectedSignatureBuf, actualSignatureBuf)
|
|
185
|
+
) {
|
|
186
|
+
throw new Error('Invalid JWT signature');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check claims
|
|
190
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
191
|
+
|
|
192
|
+
if (!options.ignoreExpiration && typeof payload.exp === 'number') {
|
|
193
|
+
if (nowSec >= payload.exp) {
|
|
194
|
+
throw new Error('JWT token has expired');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (options.issuer && payload.iss !== options.issuer) {
|
|
199
|
+
throw new Error(`Invalid JWT issuer (expected ${options.issuer}, got ${payload.iss})`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (options.audience && payload.aud !== options.audience) {
|
|
203
|
+
throw new Error(`Invalid JWT audience (expected ${options.audience}, got ${payload.aud})`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return payload;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Decode JWT without verifying signature
|
|
211
|
+
* @param {string} token
|
|
212
|
+
* @returns {{ header: Object, payload: Object }|null}
|
|
213
|
+
*/
|
|
214
|
+
function decodeJwt(token) {
|
|
215
|
+
if (!token || typeof token !== 'string') return null;
|
|
216
|
+
const parts = token.split('.');
|
|
217
|
+
if (parts.length !== 3) return null;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const header = JSON.parse(base64UrlDecode(parts[0]));
|
|
221
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]));
|
|
222
|
+
return { header, payload };
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
signJwt,
|
|
230
|
+
verifyJwt,
|
|
231
|
+
decodeJwt,
|
|
232
|
+
base64UrlEncode,
|
|
233
|
+
base64UrlDecode,
|
|
234
|
+
parseDuration,
|
|
235
|
+
};
|
package/core/auth/manager.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const { generateToken, hashToken } = require('./hash');
|
|
8
8
|
const { PolicyManager } = require('./policy');
|
|
9
|
+
const { signJwt, verifyJwt, decodeJwt } = require('./jwt');
|
|
9
10
|
const {
|
|
10
11
|
TOKEN_TYPES,
|
|
11
12
|
createAuthToken,
|
|
@@ -47,6 +48,13 @@ const DEFAULT_CONFIG = {
|
|
|
47
48
|
cookieName: 'remember_token',
|
|
48
49
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
|
49
50
|
},
|
|
51
|
+
jwt: {
|
|
52
|
+
enabled: false,
|
|
53
|
+
secret: null,
|
|
54
|
+
refreshSecret: null,
|
|
55
|
+
expiresIn: '24h',
|
|
56
|
+
refreshExpiresIn: '30d',
|
|
57
|
+
},
|
|
50
58
|
routes: {
|
|
51
59
|
login: '/login',
|
|
52
60
|
logout: '/logout',
|
|
@@ -98,6 +106,30 @@ class AuthManager {
|
|
|
98
106
|
emailVerification: config.notifications?.emailVerification || null,
|
|
99
107
|
welcome: config.notifications?.welcome || null,
|
|
100
108
|
};
|
|
109
|
+
|
|
110
|
+
if (config.jwt) {
|
|
111
|
+
const jwtSecret = typeof config.jwt === 'object' && config.jwt.secret
|
|
112
|
+
? config.jwt.secret
|
|
113
|
+
: (config.session?.secret || process.env.JWT_SECRET || process.env.AUTH_SESSION_SECRET || 'webspresso-jwt-secret');
|
|
114
|
+
const refreshSecret = typeof config.jwt === 'object' && config.jwt.refreshSecret
|
|
115
|
+
? config.jwt.refreshSecret
|
|
116
|
+
: (process.env.JWT_REFRESH_SECRET || jwtSecret);
|
|
117
|
+
const expiresIn = typeof config.jwt === 'object' && config.jwt.expiresIn
|
|
118
|
+
? config.jwt.expiresIn
|
|
119
|
+
: '24h';
|
|
120
|
+
const refreshExpiresIn = typeof config.jwt === 'object' && config.jwt.refreshExpiresIn
|
|
121
|
+
? config.jwt.refreshExpiresIn
|
|
122
|
+
: '30d';
|
|
123
|
+
this.jwt = {
|
|
124
|
+
enabled: true,
|
|
125
|
+
secret: jwtSecret,
|
|
126
|
+
refreshSecret,
|
|
127
|
+
expiresIn,
|
|
128
|
+
refreshExpiresIn,
|
|
129
|
+
};
|
|
130
|
+
} else {
|
|
131
|
+
this.jwt = null;
|
|
132
|
+
}
|
|
101
133
|
|
|
102
134
|
// Policy manager instance
|
|
103
135
|
this.policies = new PolicyManager();
|
|
@@ -144,6 +176,8 @@ class AuthManager {
|
|
|
144
176
|
}
|
|
145
177
|
|
|
146
178
|
const mergedCookie = {
|
|
179
|
+
httpOnly: true,
|
|
180
|
+
sameSite: 'lax',
|
|
147
181
|
...DEFAULT_CONFIG.session.cookie,
|
|
148
182
|
...(config.cookie || {}),
|
|
149
183
|
};
|
|
@@ -170,6 +204,42 @@ class AuthManager {
|
|
|
170
204
|
const self = this;
|
|
171
205
|
|
|
172
206
|
return {
|
|
207
|
+
manager: self,
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Generate JWT access token for user
|
|
211
|
+
*/
|
|
212
|
+
generateUserToken(user, options) {
|
|
213
|
+
return self.generateUserToken(user, options);
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Generate JWT refresh token for user
|
|
218
|
+
*/
|
|
219
|
+
generateRefreshToken(user, options) {
|
|
220
|
+
return self.generateRefreshToken(user, options);
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Refresh access token using refresh token
|
|
225
|
+
*/
|
|
226
|
+
async refreshAccessToken(refreshToken, options) {
|
|
227
|
+
return await self.refreshAccessToken(refreshToken, options);
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Create JWT payload
|
|
232
|
+
*/
|
|
233
|
+
createJwt(payload, options) {
|
|
234
|
+
return self.createJwt(payload, options);
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Verify JWT token
|
|
239
|
+
*/
|
|
240
|
+
verifyJwt(token, options) {
|
|
241
|
+
return self.verifyJwt(token, options);
|
|
242
|
+
},
|
|
173
243
|
/**
|
|
174
244
|
* Attempt login with credentials
|
|
175
245
|
* @param {string} identifier - Email or username
|
|
@@ -598,6 +668,129 @@ class AuthManager {
|
|
|
598
668
|
await this.notifications.welcome(user, {});
|
|
599
669
|
}
|
|
600
670
|
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Create JWT token
|
|
674
|
+
* @param {Object} payload
|
|
675
|
+
* @param {Object} [options]
|
|
676
|
+
* @returns {string} JWT string
|
|
677
|
+
*/
|
|
678
|
+
createJwt(payload, options = {}) {
|
|
679
|
+
const secret = options.secret || this.jwt?.secret || this.config.session?.secret || process.env.JWT_SECRET;
|
|
680
|
+
if (!secret) {
|
|
681
|
+
throw new Error('JWT secret is missing');
|
|
682
|
+
}
|
|
683
|
+
const expiresIn = options.expiresIn || this.jwt?.expiresIn || '24h';
|
|
684
|
+
return signJwt(payload, secret, { expiresIn, ...options });
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Verify JWT token
|
|
689
|
+
* @param {string} token
|
|
690
|
+
* @param {Object} [options]
|
|
691
|
+
* @returns {Object} Decoded payload
|
|
692
|
+
*/
|
|
693
|
+
verifyJwt(token, options = {}) {
|
|
694
|
+
const secret = options.secret || this.jwt?.secret || this.config.session?.secret || process.env.JWT_SECRET;
|
|
695
|
+
if (!secret) {
|
|
696
|
+
throw new Error('JWT secret is missing');
|
|
697
|
+
}
|
|
698
|
+
const payload = verifyJwt(token, secret, options);
|
|
699
|
+
if (options.isAccess !== false && payload.type === 'refresh') {
|
|
700
|
+
throw new Error('Refresh token cannot be used as an access token');
|
|
701
|
+
}
|
|
702
|
+
return payload;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Generate JWT token for user
|
|
707
|
+
* @param {Object} user
|
|
708
|
+
* @param {Object} [options]
|
|
709
|
+
* @returns {string} JWT string
|
|
710
|
+
*/
|
|
711
|
+
generateUserToken(user, options = {}) {
|
|
712
|
+
if (!user || !user.id) {
|
|
713
|
+
throw new Error('User object with id is required');
|
|
714
|
+
}
|
|
715
|
+
const payload = {
|
|
716
|
+
id: user.id,
|
|
717
|
+
type: 'access',
|
|
718
|
+
[this.identifierField]: user[this.identifierField],
|
|
719
|
+
...options.payload,
|
|
720
|
+
};
|
|
721
|
+
return this.createJwt(payload, options);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Generate JWT Refresh token for user
|
|
726
|
+
* @param {Object} user
|
|
727
|
+
* @param {Object} [options]
|
|
728
|
+
* @returns {string} Refresh token string
|
|
729
|
+
*/
|
|
730
|
+
generateRefreshToken(user, options = {}) {
|
|
731
|
+
if (!user || !user.id) {
|
|
732
|
+
throw new Error('User object with id is required');
|
|
733
|
+
}
|
|
734
|
+
const secret = options.secret || this.jwt?.refreshSecret || this.jwt?.secret || this.config.session?.secret || process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET;
|
|
735
|
+
if (!secret) {
|
|
736
|
+
throw new Error('JWT refresh secret is missing');
|
|
737
|
+
}
|
|
738
|
+
const expiresIn = options.expiresIn || this.jwt?.refreshExpiresIn || '30d';
|
|
739
|
+
const payload = {
|
|
740
|
+
id: user.id,
|
|
741
|
+
type: 'refresh',
|
|
742
|
+
jti: generateToken(16),
|
|
743
|
+
[this.identifierField]: user[this.identifierField],
|
|
744
|
+
...options.payload,
|
|
745
|
+
};
|
|
746
|
+
return signJwt(payload, secret, { expiresIn, ...options });
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Refresh access token using valid refresh token
|
|
751
|
+
* @param {string} refreshToken
|
|
752
|
+
* @param {Object} [options]
|
|
753
|
+
* @returns {Promise<{ accessToken: string, refreshToken: string, user: Object }>}
|
|
754
|
+
*/
|
|
755
|
+
async refreshAccessToken(refreshToken, options = {}) {
|
|
756
|
+
const secret = options.secret || this.jwt?.refreshSecret || this.jwt?.secret || this.config.session?.secret || process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET;
|
|
757
|
+
if (!secret) {
|
|
758
|
+
throw new Error('JWT refresh secret is missing');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const payload = verifyJwt(refreshToken, secret, options);
|
|
762
|
+
if (payload.type !== 'refresh') {
|
|
763
|
+
throw new AuthenticationError('Invalid refresh token type', 'INVALID_REFRESH_TOKEN');
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const user = await this.findUserById(payload.id);
|
|
767
|
+
if (!user) {
|
|
768
|
+
throw new AuthenticationError('User not found for refresh token', 'USER_NOT_FOUND');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const accessToken = this.generateUserToken(user, options.accessOptions);
|
|
772
|
+
const rotate = options.rotate ?? true;
|
|
773
|
+
const newRefreshToken = rotate ? this.generateRefreshToken(user, options.refreshOptions) : refreshToken;
|
|
774
|
+
|
|
775
|
+
return {
|
|
776
|
+
accessToken,
|
|
777
|
+
refreshToken: newRefreshToken,
|
|
778
|
+
user,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Get requireJwt guard middleware for Express routes
|
|
784
|
+
* @param {Object} [options]
|
|
785
|
+
* @returns {Function} Express middleware
|
|
786
|
+
*/
|
|
787
|
+
requireJwt(options = {}) {
|
|
788
|
+
if (!this._authMiddleware) {
|
|
789
|
+
const { createAuthMiddleware } = require('./middleware');
|
|
790
|
+
this._authMiddleware = createAuthMiddleware(this);
|
|
791
|
+
}
|
|
792
|
+
return this._authMiddleware.requireJwt(options);
|
|
793
|
+
}
|
|
601
794
|
}
|
|
602
795
|
|
|
603
796
|
module.exports = {
|
package/core/auth/middleware.js
CHANGED
|
@@ -28,7 +28,7 @@ function createAuthMiddleware(authManager) {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Authenticate middleware
|
|
31
|
-
* Loads user from session or remember me token
|
|
31
|
+
* Loads user from Bearer JWT header, session, or remember me token
|
|
32
32
|
* Should run on every request
|
|
33
33
|
*/
|
|
34
34
|
async function authenticate(req, res, next) {
|
|
@@ -39,8 +39,27 @@ function createAuthMiddleware(authManager) {
|
|
|
39
39
|
// Create request-bound auth helper
|
|
40
40
|
req.auth = authManager.createRequestAuth(req, res);
|
|
41
41
|
|
|
42
|
-
//
|
|
43
|
-
|
|
42
|
+
// Check Bearer JWT token header first
|
|
43
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
|
44
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
45
|
+
const token = authHeader.substring(7).trim();
|
|
46
|
+
try {
|
|
47
|
+
const payload = authManager.verifyJwt(token);
|
|
48
|
+
req.token = token;
|
|
49
|
+
req.jwtPayload = payload;
|
|
50
|
+
if (payload.id) {
|
|
51
|
+
const user = await authManager.findUserById(payload.id);
|
|
52
|
+
req.user = user || payload;
|
|
53
|
+
} else {
|
|
54
|
+
req.user = payload;
|
|
55
|
+
}
|
|
56
|
+
} catch (jwtErr) {
|
|
57
|
+
// Invalid or expired JWT token header
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Try to load user from session if not authenticated by JWT
|
|
62
|
+
if (!req.user && req.session?.userId) {
|
|
44
63
|
const user = await authManager.findUserById(req.session.userId);
|
|
45
64
|
if (user) {
|
|
46
65
|
req.user = user;
|
|
@@ -50,7 +69,7 @@ function createAuthMiddleware(authManager) {
|
|
|
50
69
|
}
|
|
51
70
|
}
|
|
52
71
|
|
|
53
|
-
// If no user in session, try remember me token
|
|
72
|
+
// If no user in session or JWT, try remember me token
|
|
54
73
|
if (!req.user && authManager.rememberTokens) {
|
|
55
74
|
const user = await authManager.verifyRememberToken(req, res);
|
|
56
75
|
if (user) {
|
|
@@ -96,6 +115,44 @@ function createAuthMiddleware(authManager) {
|
|
|
96
115
|
};
|
|
97
116
|
}
|
|
98
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Require JWT middleware
|
|
120
|
+
* Enforces valid Bearer JWT token in Authorization header
|
|
121
|
+
* @param {Object} [options] - Verification options
|
|
122
|
+
*/
|
|
123
|
+
function requireJwt(options = {}) {
|
|
124
|
+
return async (req, res, next) => {
|
|
125
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
|
126
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
127
|
+
return res.status(401).json({
|
|
128
|
+
error: 'Unauthorized',
|
|
129
|
+
message: 'Bearer token required',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const token = authHeader.substring(7).trim();
|
|
134
|
+
try {
|
|
135
|
+
const payload = authManager.verifyJwt(token, options);
|
|
136
|
+
req.token = token;
|
|
137
|
+
req.jwtPayload = payload;
|
|
138
|
+
|
|
139
|
+
if (payload.id) {
|
|
140
|
+
const user = await authManager.findUserById(payload.id);
|
|
141
|
+
req.user = user || payload;
|
|
142
|
+
} else {
|
|
143
|
+
req.user = payload;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
next();
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return res.status(401).json({
|
|
149
|
+
error: 'Unauthorized',
|
|
150
|
+
message: err.message || 'Invalid JWT token',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
99
156
|
/**
|
|
100
157
|
* Require guest middleware
|
|
101
158
|
* Redirects authenticated users away
|
|
@@ -192,6 +249,9 @@ function createAuthMiddleware(authManager) {
|
|
|
192
249
|
case 'auth':
|
|
193
250
|
return requireAuth({ api: args.includes('api') });
|
|
194
251
|
|
|
252
|
+
case 'jwt':
|
|
253
|
+
return requireJwt();
|
|
254
|
+
|
|
195
255
|
case 'guest':
|
|
196
256
|
return requireGuest();
|
|
197
257
|
|
|
@@ -216,6 +276,7 @@ function createAuthMiddleware(authManager) {
|
|
|
216
276
|
|
|
217
277
|
// Guard middleware
|
|
218
278
|
requireAuth,
|
|
279
|
+
requireJwt,
|
|
219
280
|
requireGuest,
|
|
220
281
|
requireCan,
|
|
221
282
|
requireVerified,
|
|
@@ -225,6 +286,7 @@ function createAuthMiddleware(authManager) {
|
|
|
225
286
|
|
|
226
287
|
// Aliases for route config (factories — file-router calls requireAuth(opts) / requireGuest(opts))
|
|
227
288
|
auth: requireAuth,
|
|
289
|
+
jwt: requireJwt,
|
|
228
290
|
guest: requireGuest,
|
|
229
291
|
};
|
|
230
292
|
}
|
package/index.d.ts
CHANGED
|
@@ -567,6 +567,28 @@ export function swaggerPlugin(options?: Record<string, unknown>): WebspressoPlug
|
|
|
567
567
|
|
|
568
568
|
export function healthCheckPlugin(options?: Record<string, unknown>): WebspressoPlugin;
|
|
569
569
|
|
|
570
|
+
export interface CorsPluginOptions {
|
|
571
|
+
origin?:
|
|
572
|
+
| string
|
|
573
|
+
| string[]
|
|
574
|
+
| RegExp
|
|
575
|
+
| ((
|
|
576
|
+
origin: string,
|
|
577
|
+
callback: (err: Error | null, allow?: boolean | string) => void
|
|
578
|
+
) => void);
|
|
579
|
+
methods?: string | string[];
|
|
580
|
+
allowedHeaders?: string | string[];
|
|
581
|
+
exposedHeaders?: string | string[];
|
|
582
|
+
credentials?: boolean;
|
|
583
|
+
maxAge?: number;
|
|
584
|
+
preflightContinue?: boolean;
|
|
585
|
+
optionsSuccessStatus?: number;
|
|
586
|
+
routes?: string | string[] | boolean;
|
|
587
|
+
global?: boolean;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export function corsPlugin(options?: CorsPluginOptions): WebspressoPlugin;
|
|
591
|
+
|
|
570
592
|
export interface RedirectRule {
|
|
571
593
|
from: string | RegExp;
|
|
572
594
|
to: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.85",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"nodemailer": "^9.0.3",
|
|
73
73
|
"nunjucks": "^3.2.4",
|
|
74
74
|
"sanitize-html": "^2.17.3",
|
|
75
|
-
"sharp": "^0.
|
|
75
|
+
"sharp": "^0.35.3",
|
|
76
76
|
"swup": "^4.8.3",
|
|
77
77
|
"yaml": "^2.8.3",
|
|
78
78
|
"zod": "^3.23.0",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"chokidar": "^3.5.3",
|
|
117
117
|
"dotenv": "^16.3.1",
|
|
118
118
|
"express-rate-limit": "^8.4.1",
|
|
119
|
-
"release-it": "^
|
|
119
|
+
"release-it": "^20.2.1",
|
|
120
120
|
"supertest": "^6.3.4",
|
|
121
121
|
"typescript": "~5.6.3",
|
|
122
122
|
"vitest": "^3.0.0"
|
|
@@ -71,10 +71,15 @@ function adminPanelPlugin(options = {}) {
|
|
|
71
71
|
version: '2.0.0',
|
|
72
72
|
description: 'Modular admin panel for Webspresso with extensions support',
|
|
73
73
|
|
|
74
|
-
// CSP requirements for admin panel scripts
|
|
74
|
+
// CSP requirements for admin panel scripts, styles, and custom page iframe sources
|
|
75
75
|
// Note: cdn.quilljs.com 301-redirects to cdn.jsdelivr.net; CSP is enforced on the final URL.
|
|
76
76
|
csp: {
|
|
77
|
-
styleSrc: [
|
|
77
|
+
styleSrc: [
|
|
78
|
+
'https://cdn.quilljs.com',
|
|
79
|
+
'https://cdn.jsdelivr.net',
|
|
80
|
+
'https://unpkg.com',
|
|
81
|
+
'https://cdn.tailwindcss.com',
|
|
82
|
+
],
|
|
78
83
|
scriptSrc: [
|
|
79
84
|
'https://cdn.quilljs.com',
|
|
80
85
|
'https://cdn.jsdelivr.net',
|
|
@@ -82,6 +87,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
82
87
|
'https://cdn.tailwindcss.com',
|
|
83
88
|
],
|
|
84
89
|
connectSrc: ['https://unpkg.com', 'https://cdn.tailwindcss.com', 'https://cdn.jsdelivr.net'],
|
|
90
|
+
frameSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
|
85
91
|
},
|
|
86
92
|
enabled,
|
|
87
93
|
registry, // Expose registry for external configuration
|
|
@@ -281,7 +287,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
281
287
|
|
|
282
288
|
// Custom pages API routes
|
|
283
289
|
ctx.addRoute('get', `${adminPath}/api/extensions/pages/:pageId/data`, requireAuth, pageHandlers.getPageData);
|
|
284
|
-
ctx.addRoute('
|
|
290
|
+
ctx.addRoute('all', `${adminPath}/api/extensions/pages/:pageId/actions/:actionId`, requireAuth, pageHandlers.executePageAction);
|
|
285
291
|
|
|
286
292
|
// User management API routes
|
|
287
293
|
if (userHandlers) {
|
|
@@ -128,7 +128,15 @@ function createCustomPageApiHandlers(options) {
|
|
|
128
128
|
return res.status(404).json({ error: 'Action not found' });
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const
|
|
131
|
+
const payload = { ...(req.query || {}), ...(req.body || {}) };
|
|
132
|
+
const result = await action({
|
|
133
|
+
db,
|
|
134
|
+
req,
|
|
135
|
+
body: payload,
|
|
136
|
+
payload,
|
|
137
|
+
query: req.query || {},
|
|
138
|
+
user: req.session?.adminUser,
|
|
139
|
+
});
|
|
132
140
|
res.json({ success: true, result });
|
|
133
141
|
} catch (error) {
|
|
134
142
|
res.status(500).json({ error: error.message });
|
|
@@ -243,6 +243,38 @@
|
|
|
243
243
|
});
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
function sanitizeClientHtml(htmlStr) {
|
|
247
|
+
if (!htmlStr) return '';
|
|
248
|
+
try {
|
|
249
|
+
var parser = new DOMParser();
|
|
250
|
+
var doc = parser.parseFromString(htmlStr, 'text/html');
|
|
251
|
+
var forbiddenTags = ['script', 'iframe', 'object', 'embed', 'applet', 'base'];
|
|
252
|
+
forbiddenTags.forEach(function (tag) {
|
|
253
|
+
var nodes = doc.querySelectorAll(tag);
|
|
254
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
255
|
+
nodes[i].parentNode.removeChild(nodes[i]);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
var allNodes = doc.querySelectorAll('*');
|
|
259
|
+
for (var i = 0; i < allNodes.length; i++) {
|
|
260
|
+
var node = allNodes[i];
|
|
261
|
+
var attrs = Array.prototype.slice.call(node.attributes);
|
|
262
|
+
for (var j = 0; j < attrs.length; j++) {
|
|
263
|
+
var attrName = attrs[j].name.toLowerCase();
|
|
264
|
+
var attrVal = attrs[j].value.toLowerCase().trim();
|
|
265
|
+
if (attrName.indexOf('on') === 0) {
|
|
266
|
+
node.removeAttribute(attrs[j].name);
|
|
267
|
+
} else if ((attrName === 'href' || attrName === 'src') && attrVal.indexOf('javascript:') === 0) {
|
|
268
|
+
node.removeAttribute(attrs[j].name);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return doc.body.innerHTML;
|
|
273
|
+
} catch (e) {
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
246
278
|
function updateDomFields(entryId, data) {
|
|
247
279
|
qsa('[data-ws-content-entry="' + entryId + '"][data-ws-content-field]').forEach(function (el) {
|
|
248
280
|
var field = el.getAttribute('data-ws-content-field');
|
|
@@ -250,7 +282,7 @@
|
|
|
250
282
|
var val = data[field];
|
|
251
283
|
var isHtml = el.getAttribute('data-ws-content-html') === 'true';
|
|
252
284
|
if (isHtml) {
|
|
253
|
-
el.innerHTML = val
|
|
285
|
+
el.innerHTML = sanitizeClientHtml(val);
|
|
254
286
|
} else {
|
|
255
287
|
el.textContent = val != null ? val : '';
|
|
256
288
|
}
|
package/plugins/cors.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso CORS Plugin
|
|
3
|
+
* Zero-dependency CORS middleware & preflight handler
|
|
4
|
+
* @module plugins/cors
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configure CORS middleware
|
|
9
|
+
* @param {Object} [options] - CORS configuration
|
|
10
|
+
* @param {string|string[]|RegExp|Function} [options.origin='*'] - Allowed origin(s)
|
|
11
|
+
* @param {string|string[]} [options.methods] - Allowed HTTP methods
|
|
12
|
+
* @param {string|string[]} [options.allowedHeaders] - Allowed request headers
|
|
13
|
+
* @param {string|string[]} [options.exposedHeaders] - Headers exposed to response
|
|
14
|
+
* @param {boolean} [options.credentials=false] - Allow credentials (cookies/authorization headers)
|
|
15
|
+
* @param {number} [options.maxAge=86400] - Access-Control-Max-Age header in seconds
|
|
16
|
+
* @param {boolean} [options.preflightContinue=false] - Pass OPTIONS to next handler
|
|
17
|
+
* @param {number} [options.optionsSuccessStatus=204] - OPTIONS preflight success status
|
|
18
|
+
* @param {string|string[]|boolean} [options.routes=true] - Route prefixes or true for all
|
|
19
|
+
* @returns {Function} Express middleware handler
|
|
20
|
+
*/
|
|
21
|
+
function createCorsMiddleware(options = {}) {
|
|
22
|
+
const defaults = {
|
|
23
|
+
origin: '*',
|
|
24
|
+
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
|
25
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'],
|
|
26
|
+
exposedHeaders: [],
|
|
27
|
+
credentials: false,
|
|
28
|
+
maxAge: 86400,
|
|
29
|
+
preflightContinue: false,
|
|
30
|
+
optionsSuccessStatus: 204,
|
|
31
|
+
routes: true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const config = { ...defaults, ...options };
|
|
35
|
+
|
|
36
|
+
const methodsStr = Array.isArray(config.methods)
|
|
37
|
+
? config.methods.join(',')
|
|
38
|
+
: String(config.methods);
|
|
39
|
+
|
|
40
|
+
const allowedHeadersStr = Array.isArray(config.allowedHeaders)
|
|
41
|
+
? config.allowedHeaders.join(',')
|
|
42
|
+
: String(config.allowedHeaders);
|
|
43
|
+
|
|
44
|
+
const exposedHeadersStr = Array.isArray(config.exposedHeaders)
|
|
45
|
+
? config.exposedHeaders.join(',')
|
|
46
|
+
: String(config.exposedHeaders);
|
|
47
|
+
|
|
48
|
+
function isOriginAllowed(reqOrigin, callback) {
|
|
49
|
+
if (!config.origin || config.origin === '*') {
|
|
50
|
+
return callback(null, config.origin);
|
|
51
|
+
}
|
|
52
|
+
if (!reqOrigin) {
|
|
53
|
+
return callback(null, false);
|
|
54
|
+
}
|
|
55
|
+
if (typeof config.origin === 'string') {
|
|
56
|
+
return callback(null, config.origin === reqOrigin ? reqOrigin : false);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(config.origin)) {
|
|
59
|
+
for (const item of config.origin) {
|
|
60
|
+
if (typeof item === 'string' && item === reqOrigin) {
|
|
61
|
+
return callback(null, reqOrigin);
|
|
62
|
+
}
|
|
63
|
+
if (item instanceof RegExp && item.test(reqOrigin)) {
|
|
64
|
+
return callback(null, reqOrigin);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return callback(null, false);
|
|
68
|
+
}
|
|
69
|
+
if (config.origin instanceof RegExp) {
|
|
70
|
+
return callback(null, config.origin.test(reqOrigin) ? reqOrigin : false);
|
|
71
|
+
}
|
|
72
|
+
if (typeof config.origin === 'function') {
|
|
73
|
+
return config.origin(reqOrigin, callback);
|
|
74
|
+
}
|
|
75
|
+
return callback(null, false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function shouldApplyToRoute(reqPath) {
|
|
79
|
+
if (config.routes === true || !config.routes) return true;
|
|
80
|
+
const routesList = Array.isArray(config.routes) ? config.routes : [config.routes];
|
|
81
|
+
return routesList.some((prefix) => reqPath.startsWith(prefix));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (req, res, next) => {
|
|
85
|
+
const reqPath = req.path || req.url || '/';
|
|
86
|
+
|
|
87
|
+
if (!shouldApplyToRoute(reqPath)) {
|
|
88
|
+
return next();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const reqOrigin = req.headers.origin;
|
|
92
|
+
|
|
93
|
+
isOriginAllowed(reqOrigin, (err, allowedOrigin) => {
|
|
94
|
+
if (err) {
|
|
95
|
+
return next(err);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (allowedOrigin) {
|
|
99
|
+
if (allowedOrigin === '*' && config.credentials) {
|
|
100
|
+
res.setHeader('Access-Control-Allow-Origin', reqOrigin || '*');
|
|
101
|
+
} else {
|
|
102
|
+
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
|
103
|
+
}
|
|
104
|
+
res.setHeader('Vary', 'Origin');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (config.credentials) {
|
|
108
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (exposedHeadersStr) {
|
|
112
|
+
res.setHeader('Access-Control-Expose-Headers', exposedHeadersStr);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Preflight OPTIONS request
|
|
116
|
+
if (req.method === 'OPTIONS') {
|
|
117
|
+
if (allowedOrigin) {
|
|
118
|
+
if (methodsStr) {
|
|
119
|
+
res.setHeader('Access-Control-Allow-Methods', methodsStr);
|
|
120
|
+
}
|
|
121
|
+
if (allowedHeadersStr) {
|
|
122
|
+
res.setHeader('Access-Control-Allow-Headers', allowedHeadersStr);
|
|
123
|
+
} else if (req.headers['access-control-request-headers']) {
|
|
124
|
+
res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
|
|
125
|
+
}
|
|
126
|
+
if (config.maxAge) {
|
|
127
|
+
res.setHeader('Access-Control-Max-Age', String(config.maxAge));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (config.preflightContinue) {
|
|
132
|
+
return next();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
res.statusCode = config.optionsSuccessStatus;
|
|
136
|
+
res.setHeader('Content-Length', '0');
|
|
137
|
+
return res.end();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
next();
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Webspresso CORS Plugin Factory
|
|
147
|
+
* @param {Object} [options] - CORS options
|
|
148
|
+
* @returns {Object} Plugin definition
|
|
149
|
+
*/
|
|
150
|
+
function corsPlugin(options = {}) {
|
|
151
|
+
return {
|
|
152
|
+
name: 'cors',
|
|
153
|
+
version: '1.0.0',
|
|
154
|
+
options,
|
|
155
|
+
register(ctx) {
|
|
156
|
+
const corsMiddleware = createCorsMiddleware(options);
|
|
157
|
+
|
|
158
|
+
if (ctx.middlewares) {
|
|
159
|
+
ctx.middlewares.cors = corsMiddleware;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (ctx.app && options.global !== false) {
|
|
163
|
+
ctx.app.use(corsMiddleware);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
corsPlugin.createCorsMiddleware = createCorsMiddleware;
|
|
170
|
+
|
|
171
|
+
module.exports = corsPlugin;
|
package/plugins/csrf/index.js
CHANGED
|
@@ -150,7 +150,14 @@ function csrfPlugin(options = {}) {
|
|
|
150
150
|
secret = cookieOptions.signed ? req.signedCookies[cookieKey] : req.cookies[cookieKey];
|
|
151
151
|
if (!secret) {
|
|
152
152
|
secret = crypto.randomBytes(32).toString('hex');
|
|
153
|
-
|
|
153
|
+
const isHttps = req.secure || req.headers?.['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true';
|
|
154
|
+
const effectiveCookieOptions = {
|
|
155
|
+
httpOnly: true,
|
|
156
|
+
sameSite: 'lax',
|
|
157
|
+
...cookieOptions,
|
|
158
|
+
secure: cookieOpts.secure !== undefined ? cookieOpts.secure : isHttps,
|
|
159
|
+
};
|
|
160
|
+
res.cookie(cookieKey, secret, effectiveCookieOptions);
|
|
154
161
|
}
|
|
155
162
|
} else {
|
|
156
163
|
// Session based
|
package/plugins/index.js
CHANGED
|
@@ -24,6 +24,7 @@ const { rateLimitPlugin } = require('./rate-limit');
|
|
|
24
24
|
const emailPlugin = require('./email');
|
|
25
25
|
const contentPlugin = require('./content');
|
|
26
26
|
const csrfPlugin = require('./csrf');
|
|
27
|
+
const corsPlugin = require('./cors');
|
|
27
28
|
|
|
28
29
|
module.exports = {
|
|
29
30
|
sitemapPlugin,
|
|
@@ -47,5 +48,6 @@ module.exports = {
|
|
|
47
48
|
emailPlugin,
|
|
48
49
|
contentPlugin,
|
|
49
50
|
csrfPlugin,
|
|
51
|
+
corsPlugin,
|
|
50
52
|
};
|
|
51
53
|
|
package/src/server.js
CHANGED
|
@@ -349,8 +349,9 @@ function createApp(options = {}) {
|
|
|
349
349
|
if (finalConfig.contentSecurityPolicy && finalConfig.contentSecurityPolicy.directives) {
|
|
350
350
|
const directives = finalConfig.contentSecurityPolicy.directives;
|
|
351
351
|
for (const [directive, sources] of Object.entries(pluginCspSources)) {
|
|
352
|
-
if (sources.size > 0
|
|
353
|
-
directives[directive]
|
|
352
|
+
if (sources.size > 0) {
|
|
353
|
+
const existing = (directives[directive] || []).filter(s => s !== "'none'");
|
|
354
|
+
directives[directive] = [...existing, ...Array.from(sources)];
|
|
354
355
|
}
|
|
355
356
|
}
|
|
356
357
|
}
|
|
@@ -385,6 +386,7 @@ function createApp(options = {}) {
|
|
|
385
386
|
// Add auth middleware to named middlewares for route config
|
|
386
387
|
middlewares.auth = authMiddleware.auth;
|
|
387
388
|
middlewares.guest = authMiddleware.guest;
|
|
389
|
+
middlewares.jwt = authMiddleware.jwt;
|
|
388
390
|
}
|
|
389
391
|
|
|
390
392
|
// Under Vitest, shared API fixtures use `fixtureRequireAuth`; default no-op unless overridden.
|
|
@@ -72,19 +72,27 @@ project/
|
|
|
72
72
|
- **CSP / Helmet:** production **`script-src 'self'`** works for **`/__webspresso/client-runtime/`**; some Alpine builds may need **`unsafe-eval`** — validate for your version or use a stricter build.
|
|
73
73
|
- **Longer doc:** **[`doc/index.html#client-runtime`](../../../doc/index.html#client-runtime)** · README **Client runtime**.
|
|
74
74
|
|
|
75
|
-
###
|
|
76
|
-
|
|
77
|
-
- **Import:** `const { createAuth, quickAuth, hash, verify, createRememberTokensTable } = require('webspresso/core/auth')` (published `core/` tree on npm; **not** re-exported from package root).
|
|
78
|
-
-
|
|
79
|
-
-
|
|
75
|
+
### Dual authentication (Session + JWT & Refresh Tokens) — essentials
|
|
76
|
+
|
|
77
|
+
- **Import:** `const { createAuth, quickAuth, hash, verify, signJwt, verifyJwt, decodeJwt, createRememberTokensTable } = require('webspresso/core/auth')` (published `core/` tree on npm; **not** re-exported from package root).
|
|
78
|
+
- **Dual Architecture:**
|
|
79
|
+
- **Session + Cookie (Stateful):** Primary for browser SSR routes and Admin Panel (`/`, `/_admin`). Uses `httpOnly` secure cookies.
|
|
80
|
+
- **JWT + Refresh Token (Stateless):** Built-in zero-dependency HS256 auth for REST APIs, Mobile Apps, and SPAs (`/api/*`). Uses `Authorization: Bearer <token>` for access tokens and rotatable Refresh Tokens.
|
|
81
|
+
- **`createAuth({ findUserById, findUserByCredentials, session: { secret }, jwt: { secret, refreshSecret, expiresIn, refreshExpiresIn }, rememberTokens?, ... })`** — adapter pattern; optional **remember-me** via `rememberTokens` and opt-in **JWT / Refresh Tokens**.
|
|
82
|
+
- **`quickAuth({ db, userModel, identifierField, passwordField, rememberMe, jwt })`** — wires **`getRepository`** + bcrypt **`verify`**; optional Knex **`remember_tokens`** and **JWT / Refresh Token** manager (`jwt: true` or `jwt: { expiresIn: '15m', refreshExpiresIn: '30d' }`).
|
|
83
|
+
- **JWT Helper Methods:**
|
|
84
|
+
- **`auth.generateUserToken(user, options)`** — generates short-lived access JWT (`type: 'access'`).
|
|
85
|
+
- **`auth.generateRefreshToken(user, options)`** — generates long-lived refresh JWT (`type: 'refresh'`).
|
|
86
|
+
- **`auth.refreshAccessToken(refreshToken, options)`** — verifies refresh token and issues new access token + rotated refresh token.
|
|
87
|
+
- **`auth.requireJwt()`** — guard middleware enforcing valid `Bearer` access token.
|
|
80
88
|
- **Request API** (after global authenticate): **`req.auth.attempt(id, password, { remember })`**, **`login`**, **`logout`**, **`check`**, **`guest`**, **`user`**, **`id`**, **`can` / `cannot` / `authorize`** (policies: **`auth.definePolicy`**, **`defineGate`**, **`beforePolicy`**).
|
|
81
|
-
- **Route config:** `middleware: ['auth']` (
|
|
89
|
+
- **Route config:** `middleware: ['auth']` (SSR logged in), `middleware: ['jwt']` (API Bearer token), or `['guest']` (logged-out only). For JSON APIs mounted in **`setupRoutes`**, use **`ctx.authMiddleware.requireJwt()`** or **`requireAuth({ api: true })`**.
|
|
82
90
|
- **Login page pitfall:** a **`pages/login.njk`** can register **before** `setupRoutes` and bypass **`requireGuest`**. Prefer login GET/POST in **`setupRoutes`** with templates under **`views/`** only, or omit **`pages/login.njk`** — see [`tests/e2e/auth.spec.js`](../../../tests/e2e/auth.spec.js).
|
|
83
91
|
- **Admin panel** uses a **separate** session (`req.session.adminUser`, `/_admin/api/auth/*`); it does **not** replace **`createApp({ auth })`** for site users.
|
|
84
92
|
- **Site users in the admin UI (`userManagement`):** Opt-in on **`adminPanelPlugin`**. Set **`userManagement: { enabled: true, model: 'User', fields?: { ... } }`** so the SPA shows **Users**: sidebar **All Users** / **Add User** link to **`/_admin/models/{model}`** and **`/_admin/models/{model}/new`** (same RecordList/RecordForm as other models). **`/_admin/users`**, **`/_admin/users/new`**, **`/_admin/users/:id/edit`** remain SPA aliases that redirect there; **`/_admin/users/sessions`** is **Active Sessions**. The **`model`** must match site auth (e.g. **`quickAuth({ userModel: 'User', ... })`** / **`createAuth`**) and must have **`admin: { enabled: true, ... }`** on **`defineModel`** so admin CRUD metadata loads; otherwise Users screens look empty. Pass **`auth: authManager`** with the **same** **`AuthManager`** as **`createApp({ auth: authManager })`** for **Active Sessions** / revoke (**`rememberTokens`** / **`remember_me`**); without **`auth`**, user CRUD still works via **`db.getRepository(model)`**, but session endpoints return empty or “not enabled”.
|
|
85
93
|
- **Wiring:** `plugins: [ adminPanelPlugin({ db, auth: authManager, userManagement: { enabled: true, model: 'User' } }) ]` alongside `createApp({ ..., auth: authManager })`. Admin staff log in at **`/_admin`**; end users use your normal site login — two different cookies/sessions.
|
|
86
94
|
|
|
87
|
-
Longer narrative: **[`doc/index.html#authentication`](../../../doc/index.html#authentication)** · **[`#admin-user-management`](../../../doc/index.html#admin-user-management)** · README **Authentication (session)** and **Admin Panel Plugin**.
|
|
95
|
+
Longer narrative: **[`doc/index.html#authentication`](../../../doc/index.html#authentication)** · **[`#admin-user-management`](../../../doc/index.html#admin-user-management)** · README **Authentication (session & JWT)** and **Admin Panel Plugin**.
|
|
88
96
|
|
|
89
97
|
---
|
|
90
98
|
|