webspresso 0.0.97 → 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 +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- 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/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- 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/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- 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 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -63
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Auth - Two-Factor Authentication (2FA / TOTP)
|
|
3
|
+
* Zero-dependency RFC 6238 (TOTP), RFC 4226 (HOTP), and RFC 4648 (Base32) implementation
|
|
4
|
+
* @module core/auth/totp
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const { generateQrSvg, generateQrDataUri, createQrMatrix } = require('./qrcode');
|
|
9
|
+
|
|
10
|
+
const RFC4648_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Encode buffer or string to RFC 4648 Base32
|
|
14
|
+
* @param {Buffer|string} buffer
|
|
15
|
+
* @param {Object} [options]
|
|
16
|
+
* @param {boolean} [options.padding=false] - Whether to include '=' padding (default: false for authenticator apps)
|
|
17
|
+
* @returns {string} Base32 uppercase string
|
|
18
|
+
*/
|
|
19
|
+
function base32Encode(buffer, options = {}) {
|
|
20
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || '', 'utf8');
|
|
21
|
+
if (buf.length === 0) return '';
|
|
22
|
+
const pad = options.padding === true;
|
|
23
|
+
|
|
24
|
+
let bits = 0;
|
|
25
|
+
let value = 0;
|
|
26
|
+
let output = '';
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < buf.length; i++) {
|
|
29
|
+
value = (value << 8) | buf[i];
|
|
30
|
+
bits += 8;
|
|
31
|
+
while (bits >= 5) {
|
|
32
|
+
output += RFC4648_BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
|
33
|
+
bits -= 5;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (bits > 0) {
|
|
38
|
+
output += RFC4648_BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (pad) {
|
|
42
|
+
while (output.length % 8 !== 0) {
|
|
43
|
+
output += '=';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decode RFC 4648 Base32 string to Buffer
|
|
52
|
+
* Tolerates lowercase, spaces, hyphens, and padding
|
|
53
|
+
* @param {string} input
|
|
54
|
+
* @returns {Buffer}
|
|
55
|
+
*/
|
|
56
|
+
function base32Decode(input) {
|
|
57
|
+
if (typeof input !== 'string') {
|
|
58
|
+
throw new TypeError('Base32 input must be a string');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const clean = input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/, '');
|
|
62
|
+
if (clean.length === 0) return Buffer.alloc(0);
|
|
63
|
+
|
|
64
|
+
let bits = 0;
|
|
65
|
+
let value = 0;
|
|
66
|
+
const bytes = [];
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < clean.length; i++) {
|
|
69
|
+
const char = clean[i];
|
|
70
|
+
const val = RFC4648_BASE32_ALPHABET.indexOf(char);
|
|
71
|
+
if (val === -1) {
|
|
72
|
+
throw new Error(`Invalid Base32 character: "${char}"`);
|
|
73
|
+
}
|
|
74
|
+
value = (value << 5) | val;
|
|
75
|
+
bits += 5;
|
|
76
|
+
if (bits >= 8) {
|
|
77
|
+
bytes.push((value >>> (bits - 8)) & 0xff);
|
|
78
|
+
bits -= 8;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return Buffer.from(bytes);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generate a random cryptographically secure TOTP secret key
|
|
87
|
+
* @param {Object} [options]
|
|
88
|
+
* @param {number} [options.length=20] - Number of random bytes (20 bytes = 160 bits per RFC recommendations)
|
|
89
|
+
* @param {boolean} [options.padding=false] - Whether to pad the Base32 string
|
|
90
|
+
* @returns {string} Base32 encoded secret
|
|
91
|
+
*/
|
|
92
|
+
function generateTotpSecret(options = {}) {
|
|
93
|
+
const length = options.length || 20;
|
|
94
|
+
const buffer = crypto.randomBytes(length);
|
|
95
|
+
return base32Encode(buffer, options);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize secret to Buffer
|
|
100
|
+
* @param {string|Buffer} secret
|
|
101
|
+
* @param {string} [encoding='base32'] - 'base32', 'ascii', 'utf8', 'hex'
|
|
102
|
+
* @returns {Buffer}
|
|
103
|
+
*/
|
|
104
|
+
function normalizeSecretBuffer(secret, encoding = 'base32') {
|
|
105
|
+
if (Buffer.isBuffer(secret)) {
|
|
106
|
+
return secret;
|
|
107
|
+
}
|
|
108
|
+
if (typeof secret !== 'string') {
|
|
109
|
+
throw new TypeError('Secret must be a string or Buffer');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (encoding === 'base32') {
|
|
113
|
+
return base32Decode(secret);
|
|
114
|
+
}
|
|
115
|
+
return Buffer.from(secret, encoding);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Normalize algorithm string to Node.js crypto algorithm name
|
|
120
|
+
* @param {string} algorithm
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
function normalizeAlgorithm(algorithm = 'sha1') {
|
|
124
|
+
const algo = String(algorithm).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
125
|
+
if (algo === 'sha1' || algo === 'sha256' || algo === 'sha512') {
|
|
126
|
+
return algo;
|
|
127
|
+
}
|
|
128
|
+
return 'sha1';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Generate HMAC-Based One-Time Password (HOTP, RFC 4226)
|
|
133
|
+
* @param {string|Buffer} secret - Secret key (Base32 string or Buffer)
|
|
134
|
+
* @param {number|BigInt} counter - 8-byte moving factor counter
|
|
135
|
+
* @param {Object} [options]
|
|
136
|
+
* @param {number} [options.digits=6] - Number of digits (typically 6, 7, or 8)
|
|
137
|
+
* @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
|
|
138
|
+
* @param {string} [options.encoding='base32'] - Encoding of secret if string ('base32', 'ascii', etc.)
|
|
139
|
+
* @returns {string} Numerical token padded with leading zeros
|
|
140
|
+
*/
|
|
141
|
+
function generateHotp(secret, counter, options = {}) {
|
|
142
|
+
const key = normalizeSecretBuffer(secret, options.encoding);
|
|
143
|
+
const digits = options.digits || 6;
|
|
144
|
+
const algorithm = normalizeAlgorithm(options.algorithm);
|
|
145
|
+
|
|
146
|
+
const counterBuf = Buffer.alloc(8);
|
|
147
|
+
counterBuf.writeBigUInt64BE(BigInt(counter), 0);
|
|
148
|
+
|
|
149
|
+
const hmac = crypto.createHmac(algorithm, key).update(counterBuf).digest();
|
|
150
|
+
|
|
151
|
+
// Dynamic truncation (RFC 4226 Section 5.4)
|
|
152
|
+
const offset = hmac[hmac.length - 1] & 0x0f;
|
|
153
|
+
const binary =
|
|
154
|
+
((hmac[offset] & 0x7f) << 24) |
|
|
155
|
+
((hmac[offset + 1] & 0xff) << 16) |
|
|
156
|
+
((hmac[offset + 2] & 0xff) << 8) |
|
|
157
|
+
(hmac[offset + 3] & 0xff);
|
|
158
|
+
|
|
159
|
+
const token = (binary % 10 ** digits).toString().padStart(digits, '0');
|
|
160
|
+
return token;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Timing-safe token equality check
|
|
165
|
+
* @param {string} a
|
|
166
|
+
* @param {string} b
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
function timingSafeTokenCompare(a, b) {
|
|
170
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
171
|
+
if (a.length !== b.length) return false;
|
|
172
|
+
|
|
173
|
+
const bufA = Buffer.from(a);
|
|
174
|
+
const bufB = Buffer.from(b);
|
|
175
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Verify HOTP token against counter
|
|
180
|
+
* @param {string} token - Submitted token
|
|
181
|
+
* @param {string|Buffer} secret - Secret key
|
|
182
|
+
* @param {number|BigInt} counter - Expected counter
|
|
183
|
+
* @param {Object} [options]
|
|
184
|
+
* @returns {boolean}
|
|
185
|
+
*/
|
|
186
|
+
function verifyHotp(token, secret, counter, options = {}) {
|
|
187
|
+
if (typeof token !== 'string') return false;
|
|
188
|
+
const cleanToken = token.trim();
|
|
189
|
+
const digits = options.digits || cleanToken.length || 6;
|
|
190
|
+
const expected = generateHotp(secret, counter, { ...options, digits });
|
|
191
|
+
return timingSafeTokenCompare(cleanToken, expected);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Generate Time-Based One-Time Password (TOTP, RFC 6238)
|
|
196
|
+
* @param {string|Buffer} secret - Secret key (Base32 or Buffer)
|
|
197
|
+
* @param {Object} [options]
|
|
198
|
+
* @param {number|Date} [options.time=Date.now()] - Timestamp in ms, seconds, or Date
|
|
199
|
+
* @param {number} [options.step=30] - Time step in seconds (default: 30)
|
|
200
|
+
* @param {number} [options.epoch=0] - Epoch start in seconds (default: 0)
|
|
201
|
+
* @param {number} [options.digits=6] - Number of digits (default: 6)
|
|
202
|
+
* @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
|
|
203
|
+
* @param {string} [options.encoding='base32'] - Encoding of secret if string
|
|
204
|
+
* @returns {string} Numerical token padded with leading zeros
|
|
205
|
+
*/
|
|
206
|
+
function generateTotp(secret, options = {}) {
|
|
207
|
+
let timeSeconds;
|
|
208
|
+
if (options.time instanceof Date) {
|
|
209
|
+
timeSeconds = Math.floor(options.time.getTime() / 1000);
|
|
210
|
+
} else if (typeof options.time === 'number') {
|
|
211
|
+
// If value looks like milliseconds (> 10000000000), convert to seconds
|
|
212
|
+
timeSeconds = options.time > 1e11 ? Math.floor(options.time / 1000) : Math.floor(options.time);
|
|
213
|
+
} else {
|
|
214
|
+
timeSeconds = Math.floor(Date.now() / 1000);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const step = options.step || 30;
|
|
218
|
+
const epoch = options.epoch || 0;
|
|
219
|
+
const counter = Math.floor((timeSeconds - epoch) / step);
|
|
220
|
+
|
|
221
|
+
return generateHotp(secret, counter, options);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Verify TOTP token with time-drift window support
|
|
226
|
+
* @param {string} token - Submitted token
|
|
227
|
+
* @param {string|Buffer} secret - Secret key
|
|
228
|
+
* @param {Object} [options]
|
|
229
|
+
* @param {number} [options.window=1] - Counter drift window (e.g. 1 checks counter-1, counter, counter+1)
|
|
230
|
+
* @param {number|Date} [options.time=Date.now()] - Current timestamp
|
|
231
|
+
* @param {number} [options.step=30] - Time step in seconds
|
|
232
|
+
* @param {number} [options.digits=6] - Expected digits
|
|
233
|
+
* @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
|
|
234
|
+
* @param {boolean} [options.detailed=false] - Return { valid: boolean, delta: number|null } instead of boolean
|
|
235
|
+
* @returns {boolean|{ valid: boolean, delta: number|null }}
|
|
236
|
+
*/
|
|
237
|
+
function verifyTotp(token, secret, options = {}) {
|
|
238
|
+
if (typeof token !== 'string') {
|
|
239
|
+
return options.detailed ? { valid: false, delta: null } : false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const cleanToken = token.trim();
|
|
243
|
+
const digits = options.digits || cleanToken.length || 6;
|
|
244
|
+
const step = options.step || 30;
|
|
245
|
+
const epoch = options.epoch || 0;
|
|
246
|
+
const windowSize = Math.max(0, options.window != null ? options.window : 1);
|
|
247
|
+
|
|
248
|
+
let timeSeconds;
|
|
249
|
+
if (options.time instanceof Date) {
|
|
250
|
+
timeSeconds = Math.floor(options.time.getTime() / 1000);
|
|
251
|
+
} else if (typeof options.time === 'number') {
|
|
252
|
+
timeSeconds = options.time > 1e11 ? Math.floor(options.time / 1000) : Math.floor(options.time);
|
|
253
|
+
} else {
|
|
254
|
+
timeSeconds = Math.floor(Date.now() / 1000);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const currentCounter = Math.floor((timeSeconds - epoch) / step);
|
|
258
|
+
|
|
259
|
+
for (let delta = -windowSize; delta <= windowSize; delta++) {
|
|
260
|
+
const counter = currentCounter + delta;
|
|
261
|
+
if (counter < 0) continue;
|
|
262
|
+
|
|
263
|
+
const expected = generateHotp(secret, counter, { ...options, digits });
|
|
264
|
+
if (timingSafeTokenCompare(cleanToken, expected)) {
|
|
265
|
+
if (options.detailed) {
|
|
266
|
+
return { valid: true, delta };
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (options.detailed) {
|
|
273
|
+
return { valid: false, delta: null };
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Generate standard Key URI for Google Authenticator / 1Password / Authy (otpauth://)
|
|
280
|
+
* @param {Object} options
|
|
281
|
+
* @param {string} options.secret - Base32 encoded secret
|
|
282
|
+
* @param {string} [options.account] - User account identifier (e.g. 'alice@example.com')
|
|
283
|
+
* @param {string} [options.issuer='Webspresso'] - Application or organization name
|
|
284
|
+
* @param {string} [options.label] - Custom full label (overrides issuer:account)
|
|
285
|
+
* @param {string} [options.algorithm='SHA1'] - Algorithm ('SHA1', 'SHA256', 'SHA512')
|
|
286
|
+
* @param {number} [options.digits=6] - Number of digits
|
|
287
|
+
* @param {number} [options.period=30] - Time period in seconds
|
|
288
|
+
* @returns {string} otpauth URI string
|
|
289
|
+
*/
|
|
290
|
+
function generateTotpUri(options = {}) {
|
|
291
|
+
const {
|
|
292
|
+
secret,
|
|
293
|
+
account,
|
|
294
|
+
issuer = 'Webspresso',
|
|
295
|
+
digits = 6,
|
|
296
|
+
period = 30,
|
|
297
|
+
algorithm = 'SHA1',
|
|
298
|
+
} = options;
|
|
299
|
+
|
|
300
|
+
if (!secret) {
|
|
301
|
+
throw new Error('Secret is required to generate TOTP URI');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Label: "Issuer:Account" or "Account"
|
|
305
|
+
let label = options.label;
|
|
306
|
+
if (!label) {
|
|
307
|
+
const cleanAccount = account || 'user';
|
|
308
|
+
label = issuer ? `${issuer}:${cleanAccount}` : cleanAccount;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const params = new URLSearchParams();
|
|
312
|
+
params.set('secret', secret);
|
|
313
|
+
if (issuer) params.set('issuer', issuer);
|
|
314
|
+
if (algorithm && algorithm.toUpperCase() !== 'SHA1') {
|
|
315
|
+
params.set('algorithm', algorithm.toUpperCase());
|
|
316
|
+
}
|
|
317
|
+
if (digits && digits !== 6) {
|
|
318
|
+
params.set('digits', String(digits));
|
|
319
|
+
}
|
|
320
|
+
if (period && period !== 30) {
|
|
321
|
+
params.set('period', String(period));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// otpauth://totp/LABEL?PARAMS
|
|
325
|
+
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Generate SVG string of the QR Code for a TOTP URI
|
|
330
|
+
* @param {string|Object} uriOrOptions - URI string or options for generateTotpUri
|
|
331
|
+
* @param {Object} [svgOptions] - QR SVG rendering options (size, margin, darkColor, lightColor)
|
|
332
|
+
* @returns {string} SVG string
|
|
333
|
+
*/
|
|
334
|
+
function generateTotpQrSvg(uriOrOptions, svgOptions = {}) {
|
|
335
|
+
const uri = typeof uriOrOptions === 'string' ? uriOrOptions : generateTotpUri(uriOrOptions);
|
|
336
|
+
return generateQrSvg(uri, svgOptions);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Generate Data URI of the QR Code SVG
|
|
341
|
+
* @param {string|Object} uriOrOptions
|
|
342
|
+
* @param {Object} [svgOptions]
|
|
343
|
+
* @returns {string} data:image/svg+xml;utf8,...
|
|
344
|
+
*/
|
|
345
|
+
function generateTotpQrDataUri(uriOrOptions, svgOptions = {}) {
|
|
346
|
+
const uri = typeof uriOrOptions === 'string' ? uriOrOptions : generateTotpUri(uriOrOptions);
|
|
347
|
+
return generateQrDataUri(uri, svgOptions);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Generate a set of random recovery / backup codes
|
|
352
|
+
* E.g., 'a1b2-c3d4', '98f7-21a4'
|
|
353
|
+
* @param {Object} [options]
|
|
354
|
+
* @param {number} [options.count=8] - Number of codes to generate
|
|
355
|
+
* @param {number} [options.length=10] - Characters per code (before hyphen)
|
|
356
|
+
* @param {boolean} [options.hyphen=true] - Format with hyphen in middle
|
|
357
|
+
* @returns {string[]} Array of plaintext recovery codes
|
|
358
|
+
*/
|
|
359
|
+
function generateRecoveryCodes(options = {}) {
|
|
360
|
+
const count = options.count || 8;
|
|
361
|
+
const length = options.length || 10;
|
|
362
|
+
const hyphen = options.hyphen !== false;
|
|
363
|
+
|
|
364
|
+
const codes = [];
|
|
365
|
+
for (let i = 0; i < count; i++) {
|
|
366
|
+
// Generate random hex characters
|
|
367
|
+
const byteCount = Math.ceil(length / 2);
|
|
368
|
+
let code = crypto.randomBytes(byteCount).toString('hex').slice(0, length);
|
|
369
|
+
if (hyphen && code.length >= 6) {
|
|
370
|
+
const mid = Math.floor(code.length / 2);
|
|
371
|
+
code = `${code.slice(0, mid)}-${code.slice(mid)}`;
|
|
372
|
+
}
|
|
373
|
+
codes.push(code);
|
|
374
|
+
}
|
|
375
|
+
return codes;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Hash a single recovery code for secure database storage
|
|
380
|
+
* @param {string} code - Plaintext recovery code
|
|
381
|
+
* @returns {string} SHA-256 hex hash
|
|
382
|
+
*/
|
|
383
|
+
function hashRecoveryCode(code) {
|
|
384
|
+
if (typeof code !== 'string') {
|
|
385
|
+
throw new TypeError('Recovery code must be a string');
|
|
386
|
+
}
|
|
387
|
+
const clean = code.toLowerCase().replace(/[\s-]/g, '');
|
|
388
|
+
return crypto.createHash('sha256').update(clean, 'utf8').digest('hex');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Verify a submitted recovery code against an array of hashed recovery codes
|
|
393
|
+
* @param {string} code - Submitted plaintext recovery code
|
|
394
|
+
* @param {string[]} hashedCodes - Array of hashed codes from database
|
|
395
|
+
* @returns {{ valid: boolean, matchedIndex: number, remainingHashedCodes: string[] }}
|
|
396
|
+
*/
|
|
397
|
+
function verifyRecoveryCode(code, hashedCodes = []) {
|
|
398
|
+
if (typeof code !== 'string' || !Array.isArray(hashedCodes) || hashedCodes.length === 0) {
|
|
399
|
+
return { valid: false, matchedIndex: -1, remainingHashedCodes: hashedCodes || [] };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const targetHash = hashRecoveryCode(code);
|
|
403
|
+
const targetBuf = Buffer.from(targetHash, 'hex');
|
|
404
|
+
|
|
405
|
+
let matchedIndex = -1;
|
|
406
|
+
for (let i = 0; i < hashedCodes.length; i++) {
|
|
407
|
+
const candidateHash = hashedCodes[i];
|
|
408
|
+
if (typeof candidateHash !== 'string' || candidateHash.length !== targetHash.length) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const candidateBuf = Buffer.from(candidateHash, 'hex');
|
|
412
|
+
if (crypto.timingSafeEqual(targetBuf, candidateBuf)) {
|
|
413
|
+
matchedIndex = i;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (matchedIndex === -1) {
|
|
419
|
+
return { valid: false, matchedIndex: -1, remainingHashedCodes: [...hashedCodes] };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const remaining = hashedCodes.filter((_, idx) => idx !== matchedIndex);
|
|
423
|
+
return {
|
|
424
|
+
valid: true,
|
|
425
|
+
matchedIndex,
|
|
426
|
+
remainingHashedCodes: remaining,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Convenience all-in-one helper to initialize 2FA setup for a user
|
|
432
|
+
* @param {Object} options
|
|
433
|
+
* @param {Object|string} options.user - User object (with email or username) or user identifier string
|
|
434
|
+
* @param {string} [options.issuer='Webspresso'] - App/Company name
|
|
435
|
+
* @param {number} [options.recoveryCodeCount=8] - Number of backup codes
|
|
436
|
+
* @param {Object} [options.qrOptions] - QR SVG options
|
|
437
|
+
* @returns {{
|
|
438
|
+
* secret: string,
|
|
439
|
+
* uri: string,
|
|
440
|
+
* qrSvg: string,
|
|
441
|
+
* qrDataUri: string,
|
|
442
|
+
* recoveryCodes: string[],
|
|
443
|
+
* recoveryCodesHashed: string[]
|
|
444
|
+
* }}
|
|
445
|
+
*/
|
|
446
|
+
function createTotpSetup(options = {}) {
|
|
447
|
+
const secret = options.secret || generateTotpSecret(options);
|
|
448
|
+
const user = options.user;
|
|
449
|
+
const account = typeof user === 'string' ? user : (user?.email || user?.username || user?.id || 'user');
|
|
450
|
+
const issuer = options.issuer || 'Webspresso';
|
|
451
|
+
|
|
452
|
+
const uri = generateTotpUri({
|
|
453
|
+
secret,
|
|
454
|
+
account: String(account),
|
|
455
|
+
issuer,
|
|
456
|
+
...options,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const qrSvg = generateTotpQrSvg(uri, options.qrOptions);
|
|
460
|
+
const qrDataUri = generateTotpQrDataUri(uri, options.qrOptions);
|
|
461
|
+
|
|
462
|
+
const recoveryCodes = generateRecoveryCodes({ count: options.recoveryCodeCount || 8 });
|
|
463
|
+
const recoveryCodesHashed = recoveryCodes.map(hashRecoveryCode);
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
secret,
|
|
467
|
+
uri,
|
|
468
|
+
qrSvg,
|
|
469
|
+
qrDataUri,
|
|
470
|
+
recoveryCodes,
|
|
471
|
+
recoveryCodesHashed,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
module.exports = {
|
|
476
|
+
// Base32
|
|
477
|
+
base32Encode,
|
|
478
|
+
base32Decode,
|
|
479
|
+
|
|
480
|
+
// HOTP & TOTP Core
|
|
481
|
+
generateTotpSecret,
|
|
482
|
+
generateHotp,
|
|
483
|
+
verifyHotp,
|
|
484
|
+
generateTotp,
|
|
485
|
+
verifyTotp,
|
|
486
|
+
|
|
487
|
+
// URI & QR
|
|
488
|
+
generateTotpUri,
|
|
489
|
+
generateTotpQrSvg,
|
|
490
|
+
generateTotpQrDataUri,
|
|
491
|
+
|
|
492
|
+
// Recovery Codes
|
|
493
|
+
generateRecoveryCodes,
|
|
494
|
+
hashRecoveryCode,
|
|
495
|
+
verifyRecoveryCode,
|
|
496
|
+
|
|
497
|
+
// All-in-one setup
|
|
498
|
+
createTotpSetup,
|
|
499
|
+
|
|
500
|
+
// QR primitives
|
|
501
|
+
createQrMatrix,
|
|
502
|
+
generateQrSvg,
|
|
503
|
+
generateQrDataUri,
|
|
504
|
+
};
|
|
@@ -42,6 +42,11 @@ function appendVary(res, field) {
|
|
|
42
42
|
* @param {Object} [options.deflate] - Custom Deflate options
|
|
43
43
|
* @returns {import('express').RequestHandler}
|
|
44
44
|
*/
|
|
45
|
+
function resCompress(enable = true) {
|
|
46
|
+
this._noCompression = !enable;
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
function createCompressionMiddleware(options = {}) {
|
|
46
51
|
const threshold = typeof options.threshold === 'number' && options.threshold >= 0 ? options.threshold : 1024;
|
|
47
52
|
const supportedEncodings = Array.isArray(options.encodings) && options.encodings.length > 0
|
|
@@ -53,10 +58,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
53
58
|
return function compressionMiddleware(req, res, next) {
|
|
54
59
|
// Expose opt-out API on response object
|
|
55
60
|
res._noCompression = false;
|
|
56
|
-
res.compress =
|
|
57
|
-
res._noCompression = !enable;
|
|
58
|
-
return res;
|
|
59
|
-
};
|
|
61
|
+
res.compress = resCompress;
|
|
60
62
|
|
|
61
63
|
// Skip compression for HEAD requests
|
|
62
64
|
if (req.method === 'HEAD') {
|
|
@@ -64,6 +66,10 @@ function createCompressionMiddleware(options = {}) {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const acceptEncoding = req.headers['accept-encoding'];
|
|
69
|
+
if (!acceptEncoding) {
|
|
70
|
+
return next();
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
let selectedEncoding = null;
|
|
68
74
|
let checkedEncoding = false;
|
|
69
75
|
|
|
@@ -75,10 +81,10 @@ function createCompressionMiddleware(options = {}) {
|
|
|
75
81
|
return selectedEncoding;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
// Original methods to wrap
|
|
79
|
-
const origWrite = res.write
|
|
80
|
-
const origEnd = res.end
|
|
81
|
-
const origWriteHead = res.writeHead
|
|
84
|
+
// Original methods to wrap (store references without .bind() allocation)
|
|
85
|
+
const origWrite = res.write;
|
|
86
|
+
const origEnd = res.end;
|
|
87
|
+
const origWriteHead = res.writeHead;
|
|
82
88
|
|
|
83
89
|
let compressStream = null;
|
|
84
90
|
let isCompressing = false;
|
|
@@ -151,15 +157,15 @@ function createCompressionMiddleware(options = {}) {
|
|
|
151
157
|
|
|
152
158
|
// Pipe compression stream chunks directly into underlying socket/response
|
|
153
159
|
compressStream.on('data', (chunk) => {
|
|
154
|
-
origWrite(chunk);
|
|
160
|
+
origWrite.call(res, chunk);
|
|
155
161
|
});
|
|
156
162
|
|
|
157
163
|
compressStream.on('end', () => {
|
|
158
|
-
origEnd();
|
|
164
|
+
origEnd.call(res);
|
|
159
165
|
});
|
|
160
166
|
|
|
161
167
|
compressStream.on('error', (err) => {
|
|
162
|
-
origEnd();
|
|
168
|
+
origEnd.call(res);
|
|
163
169
|
});
|
|
164
170
|
}
|
|
165
171
|
|
|
@@ -219,7 +225,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
219
225
|
} else {
|
|
220
226
|
// Send buffered chunks uncompressed
|
|
221
227
|
for (const b of bufferedChunks) {
|
|
222
|
-
origWrite(b);
|
|
228
|
+
origWrite.call(res, b);
|
|
223
229
|
}
|
|
224
230
|
bufferedChunks = [];
|
|
225
231
|
}
|
|
@@ -232,7 +238,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
232
238
|
return compressStream.write(buf, callback);
|
|
233
239
|
}
|
|
234
240
|
|
|
235
|
-
return origWrite(buf, callback);
|
|
241
|
+
return origWrite.call(res, buf, callback);
|
|
236
242
|
};
|
|
237
243
|
|
|
238
244
|
// Wrap end
|
|
@@ -253,7 +259,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
253
259
|
} else if (isCompressing && compressStream) {
|
|
254
260
|
compressStream.write(buf);
|
|
255
261
|
} else {
|
|
256
|
-
origWrite(buf);
|
|
262
|
+
origWrite.call(res, buf);
|
|
257
263
|
}
|
|
258
264
|
}
|
|
259
265
|
|
|
@@ -282,10 +288,10 @@ function createCompressionMiddleware(options = {}) {
|
|
|
282
288
|
appendVary(res, 'Accept-Encoding');
|
|
283
289
|
}
|
|
284
290
|
for (const b of bufferedChunks) {
|
|
285
|
-
origWrite(b);
|
|
291
|
+
origWrite.call(res, b);
|
|
286
292
|
}
|
|
287
293
|
bufferedChunks = [];
|
|
288
|
-
return origEnd(callback);
|
|
294
|
+
return origEnd.call(res, callback);
|
|
289
295
|
}
|
|
290
296
|
}
|
|
291
297
|
|
|
@@ -297,7 +303,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
297
303
|
return res;
|
|
298
304
|
}
|
|
299
305
|
|
|
300
|
-
return origEnd(callback);
|
|
306
|
+
return origEnd.call(res, callback);
|
|
301
307
|
};
|
|
302
308
|
|
|
303
309
|
next();
|