webspresso 0.0.108 → 0.0.109
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 +21 -0
- package/README.md +3 -3
- package/bin/commands/add-esbuild.js +7 -3
- package/bin/commands/add-tailwind.js +9 -5
- package/bin/commands/build.js +20 -4
- package/bin/commands/dev.js +57 -7
- package/bin/commands/doctor.js +42 -1
- package/bin/commands/favicon-generate.js +10 -3
- package/bin/commands/new.js +8 -3
- package/bin/commands/start.js +3 -1
- package/bin/utils/add-command.js +16 -0
- package/core/app.js +45 -3
- package/core/auth/authenticate.js +3 -3
- package/core/auth/hash.js +167 -43
- package/core/auth/index.js +13 -1
- package/core/auth/jwt.js +21 -2
- package/core/auth/manager.js +63 -14
- package/core/auth/middleware.js +4 -2
- package/core/auth/oauth.js +5 -1
- package/core/auth/tokens.js +7 -2
- package/core/auth/totp.js +42 -6
- package/core/auth/user-agent.js +2 -0
- package/core/client/adapter/datastar.js +1254 -0
- package/core/client/api.js +1033 -0
- package/core/client/controller.js +1081 -0
- package/core/client/debug.js +367 -0
- package/core/client/forms.js +1021 -0
- package/core/client/fragments.js +738 -0
- package/core/client/index.d.ts +40 -0
- package/core/client/index.js +347 -0
- package/core/client/lite.js +164 -0
- package/core/client/server-fragments.js +859 -0
- package/core/client/utils.js +144 -0
- package/core/compression/middleware.js +277 -26
- package/core/context/app-context.js +25 -3
- package/core/context/request.js +32 -4
- package/core/errors/middleware.js +14 -0
- package/core/events/dispatcher.js +33 -0
- package/core/forms/flash.js +136 -102
- package/core/health/middleware.js +36 -6
- package/core/http/pipeline.js +45 -6
- package/core/index.js +21 -7
- package/core/kernel/view.js +41 -10
- package/core/metrics/middleware.js +30 -6
- package/core/metrics/registry.js +13 -5
- package/core/orm/cache/fingerprint.js +32 -11
- package/core/orm/cache/layer.js +19 -2
- package/core/orm/hidden.js +145 -0
- package/core/orm/model.js +48 -2
- package/core/orm/query-builder.js +343 -6
- package/core/orm/query-filters.js +14 -2
- package/core/orm/repository.js +64 -13
- package/core/orm/schema-helpers.js +9 -6
- package/core/orm/transaction.js +50 -6
- package/core/orm/types.js +4 -0
- package/core/queue/adapters/database.js +184 -2
- package/core/queue/adapters/memory.js +153 -5
- package/core/queue/adapters/redis.js +59 -4
- package/core/queue/job.js +14 -1
- package/core/queue/manager.js +115 -0
- package/core/resilience/circuit-breaker.js +27 -8
- package/core/services/builtins/exchange.js +174 -7
- package/core/services/builtins/file-manager.js +36 -0
- package/core/services/builtins/mail.js +27 -0
- package/core/services/builtins/queue.js +144 -0
- package/core/services/builtins/security.js +118 -0
- package/core/services/builtins/webhook.js +110 -0
- package/core/services/executor.js +38 -16
- package/core/services/index.js +4 -1
- package/core/sse/stream.js +28 -5
- package/core/utils/timeout.js +8 -1
- package/core/validation/compiler.js +10 -7
- package/index.d.ts +1127 -71
- package/index.js +124 -8
- package/package.json +75 -26
- package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
- package/plugins/admin-panel/index.js +19 -8
- package/plugins/csrf/index.js +3 -3
- package/plugins/data-exchange/export.js +214 -0
- package/plugins/data-exchange/import.js +48 -5
- package/plugins/data-exchange/index.js +70 -28
- package/plugins/data-exchange/preview.js +266 -0
- package/plugins/data-exchange/template.js +242 -0
- package/plugins/email/admin-component.js +783 -141
- package/plugins/email/api-handlers.js +51 -1
- package/plugins/email/index.js +5 -0
- package/plugins/email/service.js +48 -0
- package/plugins/file-manager/admin-component.js +1546 -70
- package/plugins/file-manager/api-handlers.js +166 -1
- package/plugins/file-manager/core.js +598 -8
- package/plugins/file-manager/index.js +8 -0
- package/plugins/index.js +7 -0
- package/plugins/mcp/auth.js +3 -3
- package/plugins/oauth/index.js +36 -3
- package/plugins/queue/admin-component.js +1092 -0
- package/plugins/queue/api-handlers.js +248 -0
- package/plugins/queue/index.js +69 -0
- package/plugins/rest-resources/index.js +7 -2
- package/plugins/security/admin-component.js +689 -0
- package/plugins/security/api-handlers.js +169 -0
- package/plugins/security/audit.js +153 -0
- package/plugins/security/bruteforce.js +244 -0
- package/plugins/security/firewall.js +459 -0
- package/plugins/security/headers.js +214 -0
- package/plugins/security/honeypot.js +234 -0
- package/plugins/security/incident-recorder.js +137 -0
- package/plugins/security/index.js +211 -0
- package/plugins/seo-checker/analyzer.js +20 -7
- package/plugins/upload/index.js +36 -0
- package/plugins/webhooks/admin-component.js +702 -175
- package/plugins/webhooks/api-handlers.js +75 -1
- package/plugins/webhooks/dispatcher.js +290 -11
- package/plugins/webhooks/index.js +37 -2
- package/plugins/webhooks/sender.js +56 -0
- package/plugins/xlsx/engine.js +19 -4
- package/src/api/api-loader.js +19 -1
- package/src/bundle/index.js +443 -17
- package/src/client-runtime/mount.js +9 -2
- package/src/dev/api-typegen.js +1300 -0
- package/src/dev/banner.js +10 -1
- package/src/dev/colors.js +1 -1
- package/src/dev/index.js +7 -1
- package/src/dev/typegen.js +173 -17
- package/src/dev-reload/index.js +1 -1
- package/src/discovery/file-route-parser.js +17 -3
- package/src/discovery/scan-routes.js +4 -1
- package/src/file-router.js +249 -69
- package/src/helpers.js +46 -26
- package/src/njk-frontmatter.js +30 -4
- package/src/routing/cascading.js +26 -8
- package/src/routing/mount-discovered-routes.js +69 -0
- package/src/server.js +237 -66
- package/.agents/ADMIN.md +0 -426
- package/.agents/AGENTS.md +0 -96
- package/.agents/ARCHITECTURE.md +0 -163
- package/.agents/AUTH.md +0 -581
- package/.agents/CACHE.md +0 -148
- package/.agents/CLI.md +0 -649
- package/.agents/COMPRESSION.md +0 -59
- package/.agents/CONTEXT.md +0 -152
- package/.agents/CONVENTIONS.md +0 -50
- package/.agents/EARLY_HINTS.md +0 -120
- package/.agents/EMAIL.md +0 -223
- package/.agents/ERRORS.md +0 -260
- package/.agents/EVENTS.md +0 -179
- package/.agents/FLAGS.md +0 -118
- package/.agents/HEALTH.md +0 -118
- package/.agents/LIMITS.md +0 -109
- package/.agents/LOGGING.md +0 -93
- package/.agents/MCP.md +0 -103
- package/.agents/METRICS.md +0 -127
- package/.agents/MIGRATION_JOBS.md +0 -95
- package/.agents/ORM.md +0 -700
- package/.agents/PLUGINS.md +0 -729
- package/.agents/QUEUE.md +0 -290
- package/.agents/RATE_LIMIT.md +0 -147
- package/.agents/REALTIME.md +0 -213
- package/.agents/RESILIENCE.md +0 -235
- package/.agents/ROUTING.md +0 -605
- package/.agents/SCALE_OUT.md +0 -109
- package/.agents/SECRETS.md +0 -117
- package/.agents/SERVICES.md +0 -341
- package/.agents/SHUTDOWN.md +0 -74
- package/.agents/STREAMING.md +0 -108
- package/.agents/WORKFLOW.md +0 -27
- package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
- package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
- package/.agents/skills/webspresso-framework/SKILL.md +0 -89
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
- package/.agents/skills/webspresso-services/SKILL.md +0 -134
- package/.cursorrules +0 -14
- package/AGENTS.md +0 -203
- package/CLAUDE.md +0 -54
- package/core/kernel/run-demo.js +0 -58
- package/plugins/admin-panel/styles.js +0 -9
- package/plugins/data-exchange/export-xlsx.js +0 -119
package/core/auth/hash.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Bcrypt wrapper for secure password hashing
|
|
4
|
-
* @module core/auth/hash
|
|
5
|
-
*/
|
|
6
|
-
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const util = require('util');
|
|
7
3
|
const { sha256, randomToken } = require('../utils/crypto');
|
|
8
4
|
|
|
5
|
+
const scryptAsync = util.promisify(crypto.scrypt);
|
|
6
|
+
|
|
9
7
|
let bcrypt = null;
|
|
10
8
|
let bcryptChecked = false;
|
|
11
9
|
function getBcrypt() {
|
|
@@ -27,69 +25,191 @@ function getBcrypt() {
|
|
|
27
25
|
const DEFAULT_ROUNDS = process.env.NODE_ENV === 'test' ? 1 : 12;
|
|
28
26
|
|
|
29
27
|
/**
|
|
30
|
-
*
|
|
28
|
+
* Default scrypt parameters (OWASP recommended)
|
|
29
|
+
* In test environments, lower cost is used for test execution speed
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_SCRYPT_OPTIONS = {
|
|
32
|
+
cost: process.env.NODE_ENV === 'test' ? 1024 : 16384, // N
|
|
33
|
+
blockSize: 8, // r
|
|
34
|
+
parallelization: 1, // p
|
|
35
|
+
keylen: 64,
|
|
36
|
+
maxmem: 32 * 1024 * 1024, // 32MB
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hash a password using native crypto.scrypt (zero external dependencies)
|
|
31
41
|
* @param {string} password - Plain text password
|
|
32
|
-
* @param {
|
|
33
|
-
* @
|
|
42
|
+
* @param {Object} [options] - Scrypt options
|
|
43
|
+
* @param {number} [options.cost=16384] - CPU/memory cost parameter (N)
|
|
44
|
+
* @param {number} [options.blockSize=8] - Block size parameter (r)
|
|
45
|
+
* @param {number} [options.parallelization=1] - Parallelization parameter (p)
|
|
46
|
+
* @param {number} [options.keylen=64] - Desired key length in bytes
|
|
47
|
+
* @returns {Promise<string>} Standardized scrypt hash string
|
|
34
48
|
*/
|
|
35
|
-
async function
|
|
36
|
-
const b = getBcrypt();
|
|
37
|
-
if (!b) {
|
|
38
|
-
throw new Error('bcrypt is required for password hashing. Install it with: npm install bcrypt');
|
|
39
|
-
}
|
|
40
|
-
|
|
49
|
+
async function hashScrypt(password, options = {}) {
|
|
41
50
|
if (!password || typeof password !== 'string') {
|
|
42
51
|
throw new Error('Password must be a non-empty string');
|
|
43
52
|
}
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
|
|
54
|
+
const N = options.cost || options.N || DEFAULT_SCRYPT_OPTIONS.cost;
|
|
55
|
+
const r = options.blockSize || options.r || DEFAULT_SCRYPT_OPTIONS.blockSize;
|
|
56
|
+
const p = options.parallelization || options.p || DEFAULT_SCRYPT_OPTIONS.parallelization;
|
|
57
|
+
const keylen = options.keylen || DEFAULT_SCRYPT_OPTIONS.keylen;
|
|
58
|
+
const maxmem = options.maxmem || Math.max(DEFAULT_SCRYPT_OPTIONS.maxmem, 128 * N * r * 2);
|
|
59
|
+
|
|
60
|
+
const salt = options.salt ? Buffer.from(options.salt) : crypto.randomBytes(16);
|
|
61
|
+
const derivedKey = await scryptAsync(password, salt, keylen, { N, r, p, maxmem });
|
|
62
|
+
|
|
63
|
+
return `$scrypt$N=${N},r=${r},p=${p}$${salt.toString('hex')}$${derivedKey.toString('hex')}`;
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
/**
|
|
49
|
-
* Verify a password against
|
|
50
|
-
* @param {string} password - Plain text password
|
|
51
|
-
* @param {string} hashedPassword -
|
|
67
|
+
* Verify a password against an scrypt hash
|
|
68
|
+
* @param {string} password - Plain text password
|
|
69
|
+
* @param {string} hashedPassword - Standardized scrypt hash string
|
|
52
70
|
* @returns {Promise<boolean>} True if password matches
|
|
53
71
|
*/
|
|
54
|
-
async function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
throw new Error('bcrypt is required for password verification. Install it with: npm install bcrypt');
|
|
72
|
+
async function verifyScrypt(password, hashedPassword) {
|
|
73
|
+
if (!password || !hashedPassword || typeof password !== 'string' || typeof hashedPassword !== 'string') {
|
|
74
|
+
return false;
|
|
58
75
|
}
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
|
|
77
|
+
const match = hashedPassword.match(/^\$scrypt\$N=(\d+),r=(\d+),p=(\d+)\$([0-9a-fA-F]+)\$([0-9a-fA-F]+)$/);
|
|
78
|
+
if (!match) {
|
|
61
79
|
return false;
|
|
62
80
|
}
|
|
63
|
-
|
|
81
|
+
|
|
82
|
+
const N = parseInt(match[1], 10);
|
|
83
|
+
const r = parseInt(match[2], 10);
|
|
84
|
+
const p = parseInt(match[3], 10);
|
|
85
|
+
const salt = Buffer.from(match[4], 'hex');
|
|
86
|
+
const expectedKey = Buffer.from(match[5], 'hex');
|
|
87
|
+
|
|
88
|
+
const maxmem = Math.max(DEFAULT_SCRYPT_OPTIONS.maxmem, 128 * N * r * 2);
|
|
89
|
+
|
|
64
90
|
try {
|
|
65
|
-
|
|
91
|
+
const derivedKey = await scryptAsync(password, salt, expectedKey.length, { N, r, p, maxmem });
|
|
92
|
+
if (derivedKey.length !== expectedKey.length) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return crypto.timingSafeEqual(derivedKey, expectedKey);
|
|
66
96
|
} catch {
|
|
67
97
|
return false;
|
|
68
98
|
}
|
|
69
99
|
}
|
|
70
100
|
|
|
71
101
|
/**
|
|
72
|
-
*
|
|
73
|
-
* @param {string}
|
|
74
|
-
* @param {number} [
|
|
75
|
-
* @returns {
|
|
102
|
+
* Hash a password using bcrypt (or native scrypt if bcrypt is unavailable or requested)
|
|
103
|
+
* @param {string} password - Plain text password
|
|
104
|
+
* @param {number|Object} [options=12] - Cost factor (rounds) or options object { algorithm: 'scrypt'|'bcrypt'|'auto', ... }
|
|
105
|
+
* @returns {Promise<string>} Hashed password
|
|
76
106
|
*/
|
|
77
|
-
function
|
|
107
|
+
async function hash(password, options = DEFAULT_ROUNDS) {
|
|
108
|
+
if (!password || typeof password !== 'string') {
|
|
109
|
+
throw new Error('Password must be a non-empty string');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let algorithm = 'auto';
|
|
113
|
+
let rounds = DEFAULT_ROUNDS;
|
|
114
|
+
let scryptOpts = {};
|
|
115
|
+
|
|
116
|
+
if (typeof options === 'number') {
|
|
117
|
+
rounds = options;
|
|
118
|
+
} else if (options && typeof options === 'object') {
|
|
119
|
+
algorithm = options.algorithm || 'auto';
|
|
120
|
+
rounds = options.rounds || DEFAULT_ROUNDS;
|
|
121
|
+
scryptOpts = options;
|
|
122
|
+
}
|
|
123
|
+
|
|
78
124
|
const b = getBcrypt();
|
|
125
|
+
|
|
126
|
+
if (algorithm === 'scrypt' || (!b && algorithm === 'auto')) {
|
|
127
|
+
return hashScrypt(password, scryptOpts);
|
|
128
|
+
}
|
|
129
|
+
|
|
79
130
|
if (!b) {
|
|
80
|
-
throw new Error('bcrypt is required. Install it with: npm install bcrypt');
|
|
131
|
+
throw new Error('bcrypt is required for bcrypt password hashing. Install it with: npm install bcrypt or use scrypt');
|
|
81
132
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
133
|
+
|
|
134
|
+
return b.hash(password, rounds);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Verify a password against a hash (supports both bcrypt and native scrypt hashes)
|
|
139
|
+
* @param {string} password - Plain text password to verify
|
|
140
|
+
* @param {string} hashedPassword - Hashed password to compare against
|
|
141
|
+
* @returns {Promise<boolean>} True if password matches
|
|
142
|
+
*/
|
|
143
|
+
async function verify(password, hashedPassword) {
|
|
144
|
+
if (!password || !hashedPassword || typeof password !== 'string' || typeof hashedPassword !== 'string') {
|
|
145
|
+
return false;
|
|
85
146
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return
|
|
90
|
-
}
|
|
147
|
+
|
|
148
|
+
// Handle native scrypt hash format
|
|
149
|
+
if (hashedPassword.startsWith('$scrypt$')) {
|
|
150
|
+
return verifyScrypt(password, hashedPassword);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Handle bcrypt hash formats ($2a$, $2b$, $2y$)
|
|
154
|
+
if (hashedPassword.startsWith('$2')) {
|
|
155
|
+
const b = getBcrypt();
|
|
156
|
+
if (!b) {
|
|
157
|
+
throw new Error('bcrypt is required to verify bcrypt hashes. Install it with: npm install bcrypt');
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
return await b.compare(password, hashedPassword);
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if a hash needs rehashing (e.g., cost factor changed or migrating to scrypt)
|
|
171
|
+
* @param {string} hashedPassword - Hashed password to check
|
|
172
|
+
* @param {number|Object} [options=12] - Desired cost factor (rounds) or options object
|
|
173
|
+
* @returns {boolean} True if rehash is needed
|
|
174
|
+
*/
|
|
175
|
+
function needsRehash(hashedPassword, options = DEFAULT_ROUNDS) {
|
|
176
|
+
if (!hashedPassword || typeof hashedPassword !== 'string') {
|
|
91
177
|
return true;
|
|
92
178
|
}
|
|
179
|
+
|
|
180
|
+
let desiredAlgorithm = 'auto';
|
|
181
|
+
let desiredRounds = DEFAULT_ROUNDS;
|
|
182
|
+
let desiredN = DEFAULT_SCRYPT_OPTIONS.cost;
|
|
183
|
+
|
|
184
|
+
if (typeof options === 'number') {
|
|
185
|
+
desiredRounds = options;
|
|
186
|
+
} else if (options && typeof options === 'object') {
|
|
187
|
+
desiredAlgorithm = options.algorithm || 'auto';
|
|
188
|
+
desiredRounds = options.rounds || DEFAULT_ROUNDS;
|
|
189
|
+
desiredN = options.cost || options.N || DEFAULT_SCRYPT_OPTIONS.cost;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (hashedPassword.startsWith('$scrypt$')) {
|
|
193
|
+
if (desiredAlgorithm === 'bcrypt') return true;
|
|
194
|
+
const match = hashedPassword.match(/^\$scrypt\$N=(\d+)/);
|
|
195
|
+
if (!match) return true;
|
|
196
|
+
const currentN = parseInt(match[1], 10);
|
|
197
|
+
return currentN < desiredN;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (hashedPassword.startsWith('$2')) {
|
|
201
|
+
if (desiredAlgorithm === 'scrypt') return true;
|
|
202
|
+
const b = getBcrypt();
|
|
203
|
+
if (!b) return true;
|
|
204
|
+
try {
|
|
205
|
+
const hashRounds = b.getRounds(hashedPassword);
|
|
206
|
+
return hashRounds < desiredRounds;
|
|
207
|
+
} catch {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return true;
|
|
93
213
|
}
|
|
94
214
|
|
|
95
215
|
/**
|
|
@@ -105,7 +225,7 @@ function generateToken(length = 32) {
|
|
|
105
225
|
* Hash a high-entropy random lookup token for database storage (SHA-256).
|
|
106
226
|
* Note: Used exclusively for 256-bit cryptographically secure random lookup tokens
|
|
107
227
|
* (remember-me tokens, email verification tokens, password reset tokens), NOT user passwords.
|
|
108
|
-
* User passwords must always be hashed using `hash()` (bcrypt).
|
|
228
|
+
* User passwords must always be hashed using `hash()` (bcrypt or scrypt).
|
|
109
229
|
*
|
|
110
230
|
* // CodeQL [js/insufficient-password-hash] SHA-256 is intentionally used for 256-bit high-entropy random lookup token hashing, not user passwords.
|
|
111
231
|
* @param {string} token - High-entropy random token
|
|
@@ -121,7 +241,11 @@ module.exports = {
|
|
|
121
241
|
hash,
|
|
122
242
|
verify,
|
|
123
243
|
needsRehash,
|
|
244
|
+
hashScrypt,
|
|
245
|
+
verifyScrypt,
|
|
246
|
+
DEFAULT_SCRYPT_OPTIONS,
|
|
124
247
|
generateToken,
|
|
125
248
|
hashToken,
|
|
126
249
|
DEFAULT_ROUNDS,
|
|
127
250
|
};
|
|
251
|
+
|
package/core/auth/index.js
CHANGED
|
@@ -15,7 +15,16 @@ function getMiddleware() {
|
|
|
15
15
|
}
|
|
16
16
|
return _middleware;
|
|
17
17
|
}
|
|
18
|
-
const {
|
|
18
|
+
const {
|
|
19
|
+
hash,
|
|
20
|
+
verify,
|
|
21
|
+
needsRehash,
|
|
22
|
+
hashScrypt,
|
|
23
|
+
verifyScrypt,
|
|
24
|
+
DEFAULT_SCRYPT_OPTIONS,
|
|
25
|
+
generateToken,
|
|
26
|
+
hashToken,
|
|
27
|
+
} = require('./hash');
|
|
19
28
|
const { signJwt, verifyJwt, decodeJwt, clearJwtCache, HS256_HEADER_BASE64URL, MAX_PAYLOAD_SIZE } = require('./jwt');
|
|
20
29
|
const {
|
|
21
30
|
TOKEN_TYPES,
|
|
@@ -264,6 +273,9 @@ module.exports = {
|
|
|
264
273
|
hash,
|
|
265
274
|
verify,
|
|
266
275
|
needsRehash,
|
|
276
|
+
hashScrypt,
|
|
277
|
+
verifyScrypt,
|
|
278
|
+
DEFAULT_SCRYPT_OPTIONS,
|
|
267
279
|
generateToken,
|
|
268
280
|
hashToken,
|
|
269
281
|
|
package/core/auth/jwt.js
CHANGED
|
@@ -118,13 +118,23 @@ function verifyJwt(token, secret, options = {}) {
|
|
|
118
118
|
verifiedJwtCache.set(cacheKey, cached);
|
|
119
119
|
|
|
120
120
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
121
|
+
const leewaySec = typeof options.leeway === 'number'
|
|
122
|
+
? options.leeway
|
|
123
|
+
: (typeof options.clockTolerance === 'number' ? options.clockTolerance : 0);
|
|
124
|
+
|
|
121
125
|
if (!options.ignoreExpiration && typeof cached.exp === 'number') {
|
|
122
|
-
if (nowSec >= cached.exp) {
|
|
126
|
+
if (nowSec >= cached.exp + leewaySec) {
|
|
123
127
|
verifiedJwtCache.delete(cacheKey);
|
|
124
128
|
throw new Error('JWT token has expired');
|
|
125
129
|
}
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
if (typeof cached.nbf === 'number') {
|
|
133
|
+
if (nowSec + leewaySec < cached.nbf) {
|
|
134
|
+
throw new Error('JWT token is not active yet');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
128
138
|
if (options.issuer && cached.iss !== options.issuer) {
|
|
129
139
|
throw new Error(`Invalid JWT issuer (expected ${options.issuer}, got ${cached.iss})`);
|
|
130
140
|
}
|
|
@@ -188,13 +198,22 @@ function verifyJwt(token, secret, options = {}) {
|
|
|
188
198
|
|
|
189
199
|
// 4. Check claims
|
|
190
200
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
201
|
+
const leewaySec = typeof options.leeway === 'number'
|
|
202
|
+
? options.leeway
|
|
203
|
+
: (typeof options.clockTolerance === 'number' ? options.clockTolerance : 0);
|
|
191
204
|
|
|
192
205
|
if (!options.ignoreExpiration && typeof payload.exp === 'number') {
|
|
193
|
-
if (nowSec >= payload.exp) {
|
|
206
|
+
if (nowSec >= payload.exp + leewaySec) {
|
|
194
207
|
throw new Error('JWT token has expired');
|
|
195
208
|
}
|
|
196
209
|
}
|
|
197
210
|
|
|
211
|
+
if (typeof payload.nbf === 'number') {
|
|
212
|
+
if (nowSec + leewaySec < payload.nbf) {
|
|
213
|
+
throw new Error('JWT token is not active yet');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
198
217
|
if (options.issuer && payload.iss !== options.issuer) {
|
|
199
218
|
throw new Error(`Invalid JWT issuer (expected ${options.issuer}, got ${payload.iss})`);
|
|
200
219
|
}
|
package/core/auth/manager.js
CHANGED
|
@@ -29,6 +29,36 @@ class AuthenticationError extends Error {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const WEAK_SECRETS_PATTERN = /^(changeme|secret|default|password|test|webspresso-|123456)/i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate secret strength
|
|
36
|
+
* In production: throws Error if secret is missing, shorter than 32 chars, or uses common defaults.
|
|
37
|
+
* In development/test: logs a warning if weak.
|
|
38
|
+
* @param {string} name - Name of the secret setting / env var
|
|
39
|
+
* @param {string} value - Secret value
|
|
40
|
+
* @param {boolean} [isProd=false] - Whether environment is production
|
|
41
|
+
*/
|
|
42
|
+
function assertStrongSecret(name, value, isProd = false) {
|
|
43
|
+
if (!value || typeof value !== 'string') {
|
|
44
|
+
throw new Error(`${name} is required.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isWeak = value.length < 32 || WEAK_SECRETS_PATTERN.test(value);
|
|
48
|
+
|
|
49
|
+
if (isProd && isWeak) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Insecure secret for ${name}: Production requires at least 32 high-entropy characters and cannot use default/weak words. Generate one with 'openssl rand -hex 32'.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isProd && isWeak && !process.env.VITEST && process.env.NODE_ENV !== 'test') {
|
|
56
|
+
console.warn(
|
|
57
|
+
`\x1b[33m[Webspresso Security Warning]\x1b[0m ${name} appears weak or uses a default value. In production, at least 32 high-entropy characters are strictly enforced.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
/**
|
|
33
63
|
* Default configuration
|
|
34
64
|
*/
|
|
@@ -117,12 +147,20 @@ class AuthManager extends EventEmitter {
|
|
|
117
147
|
this.twoFactor = config.twoFactor || { enabled: false };
|
|
118
148
|
|
|
119
149
|
if (config.jwt || config.secret) {
|
|
150
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
120
151
|
const jwtSecret = typeof config.jwt === 'object' && config.jwt.secret
|
|
121
152
|
? config.jwt.secret
|
|
122
|
-
: (config.secret || config.session?.secret || process.env.JWT_SECRET || process.env.AUTH_SESSION_SECRET || 'webspresso-jwt-secret');
|
|
153
|
+
: (config.secret || config.session?.secret || process.env.JWT_SECRET || process.env.AUTH_SESSION_SECRET || (isProd ? null : 'webspresso-jwt-secret'));
|
|
154
|
+
|
|
155
|
+
assertStrongSecret('JWT_SECRET / auth.secret', jwtSecret, isProd);
|
|
156
|
+
|
|
123
157
|
const refreshSecret = typeof config.jwt === 'object' && config.jwt.refreshSecret
|
|
124
158
|
? config.jwt.refreshSecret
|
|
125
159
|
: (process.env.JWT_REFRESH_SECRET || jwtSecret);
|
|
160
|
+
|
|
161
|
+
if (refreshSecret !== jwtSecret) {
|
|
162
|
+
assertStrongSecret('JWT_REFRESH_SECRET', refreshSecret, isProd);
|
|
163
|
+
}
|
|
126
164
|
const expiresIn = typeof config.jwt === 'object' && config.jwt.expiresIn
|
|
127
165
|
? config.jwt.expiresIn
|
|
128
166
|
: '24h';
|
|
@@ -231,10 +269,9 @@ class AuthManager extends EventEmitter {
|
|
|
231
269
|
*/
|
|
232
270
|
getSessionConfig() {
|
|
233
271
|
const config = { ...this.config.session };
|
|
272
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
234
273
|
|
|
235
|
-
|
|
236
|
-
throw new Error('Session secret is required. Set AUTH_SESSION_SECRET environment variable or pass session.secret in config.');
|
|
237
|
-
}
|
|
274
|
+
assertStrongSecret('AUTH_SESSION_SECRET / session.secret', config.secret, isProd);
|
|
238
275
|
|
|
239
276
|
// Auto-detect and configure distributed Redis session store
|
|
240
277
|
if (!config.store) {
|
|
@@ -559,7 +596,10 @@ class AuthManager extends EventEmitter {
|
|
|
559
596
|
* @param {string} tokenOrCode - Submitted 6-digit TOTP code or recovery code
|
|
560
597
|
* @param {Object|string} secretOrUser - Secret string or user object with totp_secret / recovery_codes
|
|
561
598
|
* @param {Object} [options]
|
|
562
|
-
* @
|
|
599
|
+
* @param {number} [options.lastStep] - Last accepted TOTP counter (defaults to user.totp_last_step / totpLastStep)
|
|
600
|
+
* @param {boolean} [options.preventReuse=true] - Reject a TOTP code whose counter was already accepted
|
|
601
|
+
* @returns {{ success: boolean, type?: 'totp'|'recovery_code', delta?: number, step?: number, remainingHashedCodes?: string[], matchedIndex?: number }}
|
|
602
|
+
* On TOTP success, persist `step` (e.g. users.totp_last_step) to block replays across processes.
|
|
563
603
|
*/
|
|
564
604
|
verify2Fa(tokenOrCode, secretOrUser, options = {}) {
|
|
565
605
|
if (!tokenOrCode) {
|
|
@@ -578,10 +618,21 @@ class AuthManager extends EventEmitter {
|
|
|
578
618
|
|
|
579
619
|
// Try TOTP code first
|
|
580
620
|
if (secret) {
|
|
581
|
-
const
|
|
621
|
+
const preventReuse = options.preventReuse !== false;
|
|
622
|
+
let afterStep;
|
|
623
|
+
if (preventReuse) {
|
|
624
|
+
const persisted = options.lastStep ?? (secretOrUser && typeof secretOrUser === 'object'
|
|
625
|
+
? (secretOrUser.totp_last_step ?? secretOrUser.totpLastStep)
|
|
626
|
+
: undefined);
|
|
627
|
+
const steps = [Number(persisted), self.totp.getLastUsedTotpStep(secret)]
|
|
628
|
+
.filter((n) => typeof n === 'number' && Number.isFinite(n));
|
|
629
|
+
if (steps.length > 0) afterStep = Math.max(...steps);
|
|
630
|
+
}
|
|
631
|
+
const totpResult = self.totp.verifyTotp(tokenOrCode, secret, { ...options, afterStep, detailed: true });
|
|
582
632
|
if (totpResult.valid) {
|
|
633
|
+
if (preventReuse) self.totp.markTotpStepUsed(secret, totpResult.counter);
|
|
583
634
|
this.set2FaVerified(true);
|
|
584
|
-
return { success: true, type: 'totp', delta: totpResult.delta };
|
|
635
|
+
return { success: true, type: 'totp', delta: totpResult.delta, step: totpResult.counter };
|
|
585
636
|
}
|
|
586
637
|
}
|
|
587
638
|
|
|
@@ -895,13 +946,13 @@ class AuthManager extends EventEmitter {
|
|
|
895
946
|
TOKEN_TYPES.PASSWORD_RESET,
|
|
896
947
|
rawToken
|
|
897
948
|
);
|
|
898
|
-
|
|
949
|
+
// Claim the token before acting on it so concurrent requests cannot both redeem it
|
|
950
|
+
if (!verified || !(await consumeAuthToken(this.authTokens, verified.tokenHash))) {
|
|
899
951
|
throw new AuthenticationError('Invalid or expired reset token', 'INVALID_TOKEN');
|
|
900
952
|
}
|
|
901
953
|
|
|
902
954
|
const hashed = await hashPassword(newPassword);
|
|
903
955
|
await this.updateUser(verified.userId, { [this.passwordField]: hashed });
|
|
904
|
-
await consumeAuthToken(this.authTokens, verified.tokenHash);
|
|
905
956
|
await this.authTokens.deleteAllForUser(verified.userId, TOKEN_TYPES.PASSWORD_RESET);
|
|
906
957
|
|
|
907
958
|
return { ok: true };
|
|
@@ -966,12 +1017,11 @@ class AuthManager extends EventEmitter {
|
|
|
966
1017
|
TOKEN_TYPES.EMAIL_VERIFY,
|
|
967
1018
|
rawToken
|
|
968
1019
|
);
|
|
969
|
-
if (!verified) {
|
|
1020
|
+
if (!verified || !(await consumeAuthToken(this.authTokens, verified.tokenHash))) {
|
|
970
1021
|
throw new AuthenticationError('Invalid or expired verification token', 'INVALID_TOKEN');
|
|
971
1022
|
}
|
|
972
1023
|
|
|
973
1024
|
await this.updateUser(verified.userId, { [this.verifiedField]: new Date() });
|
|
974
|
-
await consumeAuthToken(this.authTokens, verified.tokenHash);
|
|
975
1025
|
|
|
976
1026
|
return { ok: true, userId: verified.userId };
|
|
977
1027
|
}
|
|
@@ -1059,7 +1109,7 @@ class AuthManager extends EventEmitter {
|
|
|
1059
1109
|
TOKEN_TYPES.MAGIC_LINK,
|
|
1060
1110
|
rawToken
|
|
1061
1111
|
);
|
|
1062
|
-
if (!verified) {
|
|
1112
|
+
if (!verified || !(await consumeAuthToken(this.authTokens, verified.tokenHash))) {
|
|
1063
1113
|
throw new AuthenticationError('Invalid or expired magic link', 'INVALID_MAGIC_LINK');
|
|
1064
1114
|
}
|
|
1065
1115
|
|
|
@@ -1068,8 +1118,6 @@ class AuthManager extends EventEmitter {
|
|
|
1068
1118
|
throw new AuthenticationError('User not found for magic link', 'USER_NOT_FOUND');
|
|
1069
1119
|
}
|
|
1070
1120
|
|
|
1071
|
-
await consumeAuthToken(this.authTokens, verified.tokenHash);
|
|
1072
|
-
|
|
1073
1121
|
if (options.consumeAllForUser !== false) {
|
|
1074
1122
|
await this.authTokens.deleteAllForUser(verified.userId, TOKEN_TYPES.MAGIC_LINK);
|
|
1075
1123
|
}
|
|
@@ -1330,4 +1378,5 @@ module.exports = {
|
|
|
1330
1378
|
AuthManager,
|
|
1331
1379
|
AuthenticationError,
|
|
1332
1380
|
DEFAULT_CONFIG,
|
|
1381
|
+
assertStrongSecret,
|
|
1333
1382
|
};
|
package/core/auth/middleware.js
CHANGED
|
@@ -45,6 +45,8 @@ function createAuthMiddleware(authManager) {
|
|
|
45
45
|
* Sets up express-session with secure cookie enforcement
|
|
46
46
|
*/
|
|
47
47
|
const sessionMiddleware = session({
|
|
48
|
+
resave: sessionConfig.resave ?? false,
|
|
49
|
+
saveUninitialized: sessionConfig.saveUninitialized ?? false,
|
|
48
50
|
...sessionConfig,
|
|
49
51
|
cookie: {
|
|
50
52
|
httpOnly: true,
|
|
@@ -125,14 +127,14 @@ function createAuthMiddleware(authManager) {
|
|
|
125
127
|
function requireJwt(options = {}) {
|
|
126
128
|
return async (req, res, next) => {
|
|
127
129
|
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
|
128
|
-
if (!authHeader ||
|
|
130
|
+
if (!authHeader || !/^Bearer\s+/i.test(authHeader)) {
|
|
129
131
|
return res.status(401).json({
|
|
130
132
|
error: 'Unauthorized',
|
|
131
133
|
message: 'Bearer token required',
|
|
132
134
|
});
|
|
133
135
|
}
|
|
134
136
|
|
|
135
|
-
const token = authHeader.
|
|
137
|
+
const token = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
136
138
|
try {
|
|
137
139
|
const payload = authManager.verifyJwt(token, options);
|
|
138
140
|
req.token = token;
|
package/core/auth/oauth.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
const crypto = require('crypto');
|
|
9
9
|
const { fetchWithTimeout } = require('../utils/fetch');
|
|
10
10
|
const { safeCompare, sha256, hmacSha256, base64UrlEncode, base64UrlDecode, randomToken } = require('../utils/crypto');
|
|
11
|
+
const { assertStrongSecret } = require('./manager');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Custom error class for OAuth operations
|
|
@@ -193,7 +194,10 @@ class OAuthClient {
|
|
|
193
194
|
* @param {number} [config.stateTtlMs=600000] - State token TTL in ms
|
|
194
195
|
*/
|
|
195
196
|
constructor(config = {}) {
|
|
196
|
-
|
|
197
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
198
|
+
this.secret = config.secret || process.env.OAUTH_SECRET || process.env.SESSION_SECRET || (isProd ? null : 'webspresso-oauth-secret');
|
|
199
|
+
// State tokens are HMAC-signed with this secret; a well-known default would let anyone forge them.
|
|
200
|
+
if (isProd) assertStrongSecret('OAUTH_SECRET / oauth.secret', this.secret, true);
|
|
197
201
|
this.stateTtlMs = config.stateTtlMs || 10 * 60 * 1000;
|
|
198
202
|
this.timeout = typeof (config.timeout || config.requestTimeout) === 'number'
|
|
199
203
|
? (config.timeout || config.requestTimeout)
|
package/core/auth/tokens.js
CHANGED
|
@@ -63,7 +63,7 @@ function createKnexAuthTokensAdapter(db) {
|
|
|
63
63
|
return knex('auth_tokens').where({ token: tokenHash, type }).first();
|
|
64
64
|
},
|
|
65
65
|
delete: async (tokenHash) => {
|
|
66
|
-
|
|
66
|
+
return knex('auth_tokens').where({ token: tokenHash }).delete();
|
|
67
67
|
},
|
|
68
68
|
deleteAllForUser: async (userId, type) => {
|
|
69
69
|
const q = knex('auth_tokens').where({ user_id: userId });
|
|
@@ -120,11 +120,16 @@ async function verifyAuthToken(authTokensAdapter, type, rawToken) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
|
+
* Atomically claim a verified token by deleting it. Only the caller whose delete removed the row
|
|
124
|
+
* may act on the token, so concurrent requests cannot both redeem it.
|
|
125
|
+
* Adapters whose delete() returns no count are trusted (treated as claimed).
|
|
123
126
|
* @param {Object} authTokensAdapter
|
|
124
127
|
* @param {string} tokenHash
|
|
128
|
+
* @returns {Promise<boolean>} true when this caller claimed the token
|
|
125
129
|
*/
|
|
126
130
|
async function consumeAuthToken(authTokensAdapter, tokenHash) {
|
|
127
|
-
await authTokensAdapter.delete(tokenHash);
|
|
131
|
+
const deleted = await authTokensAdapter.delete(tokenHash);
|
|
132
|
+
return typeof deleted === 'number' ? deleted > 0 : true;
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
/**
|
package/core/auth/totp.js
CHANGED
|
@@ -223,13 +223,16 @@ function generateTotp(secret, options = {}) {
|
|
|
223
223
|
* @param {number} [options.step=30] - Time step in seconds
|
|
224
224
|
* @param {number} [options.digits=6] - Expected digits
|
|
225
225
|
* @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
|
|
226
|
-
* @param {
|
|
227
|
-
*
|
|
226
|
+
* @param {number} [options.afterStep] - Reject counters <= this value (RFC 6238 §5.2 replay protection;
|
|
227
|
+
* pass the counter of the last accepted code)
|
|
228
|
+
* @param {boolean} [options.detailed=false] - Return { valid, delta, counter } instead of boolean
|
|
229
|
+
* @returns {boolean|{ valid: boolean, delta: number|null, counter?: number|null }}
|
|
228
230
|
*/
|
|
229
231
|
function verifyTotp(token, secret, options = {}) {
|
|
230
232
|
if (typeof token !== 'string') {
|
|
231
|
-
return options.detailed ? { valid: false, delta: null } : false;
|
|
233
|
+
return options.detailed ? { valid: false, delta: null, counter: null } : false;
|
|
232
234
|
}
|
|
235
|
+
const afterStep = typeof options.afterStep === 'number' ? options.afterStep : -1;
|
|
233
236
|
|
|
234
237
|
const cleanToken = token.trim();
|
|
235
238
|
const digits = options.digits || cleanToken.length || 6;
|
|
@@ -250,23 +253,54 @@ function verifyTotp(token, secret, options = {}) {
|
|
|
250
253
|
|
|
251
254
|
for (let delta = -windowSize; delta <= windowSize; delta++) {
|
|
252
255
|
const counter = currentCounter + delta;
|
|
253
|
-
if (counter < 0) continue;
|
|
256
|
+
if (counter < 0 || counter <= afterStep) continue;
|
|
254
257
|
|
|
255
258
|
const expected = generateHotp(secret, counter, { ...options, digits });
|
|
256
259
|
if (timingSafeTokenCompare(cleanToken, expected)) {
|
|
257
260
|
if (options.detailed) {
|
|
258
|
-
return { valid: true, delta };
|
|
261
|
+
return { valid: true, delta, counter };
|
|
259
262
|
}
|
|
260
263
|
return true;
|
|
261
264
|
}
|
|
262
265
|
}
|
|
263
266
|
|
|
264
267
|
if (options.detailed) {
|
|
265
|
-
return { valid: false, delta: null };
|
|
268
|
+
return { valid: false, delta: null, counter: null };
|
|
266
269
|
}
|
|
267
270
|
return false;
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
// In-process record of the last accepted TOTP counter per secret, so a code cannot be
|
|
274
|
+
// replayed within its validity window. Bounded; persist the returned step for cluster-wide safety.
|
|
275
|
+
const MAX_TRACKED_TOTP_SECRETS = 10000;
|
|
276
|
+
const lastUsedTotpSteps = new Map();
|
|
277
|
+
|
|
278
|
+
function secretKey(secret) {
|
|
279
|
+
return sha256(Buffer.isBuffer(secret) ? secret.toString('hex') : String(secret));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {string|Buffer} secret
|
|
284
|
+
* @returns {number|undefined} Last accepted counter for this secret in this process
|
|
285
|
+
*/
|
|
286
|
+
function getLastUsedTotpStep(secret) {
|
|
287
|
+
return lastUsedTotpSteps.get(secretKey(secret));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @param {string|Buffer} secret
|
|
292
|
+
* @param {number} counter - Accepted counter
|
|
293
|
+
*/
|
|
294
|
+
function markTotpStepUsed(secret, counter) {
|
|
295
|
+
const key = secretKey(secret);
|
|
296
|
+
const prev = lastUsedTotpSteps.get(key);
|
|
297
|
+
lastUsedTotpSteps.delete(key);
|
|
298
|
+
lastUsedTotpSteps.set(key, typeof prev === 'number' ? Math.max(prev, counter) : counter);
|
|
299
|
+
if (lastUsedTotpSteps.size > MAX_TRACKED_TOTP_SECRETS) {
|
|
300
|
+
lastUsedTotpSteps.delete(lastUsedTotpSteps.keys().next().value);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
270
304
|
/**
|
|
271
305
|
* Generate standard Key URI for Google Authenticator / 1Password / Authy (otpauth://)
|
|
272
306
|
* @param {Object} options
|
|
@@ -465,6 +499,8 @@ function createTotpSetup(options = {}) {
|
|
|
465
499
|
}
|
|
466
500
|
|
|
467
501
|
module.exports = {
|
|
502
|
+
getLastUsedTotpStep,
|
|
503
|
+
markTotpStepUsed,
|
|
468
504
|
// Base32
|
|
469
505
|
base32Encode,
|
|
470
506
|
base32Decode,
|