webspresso 0.0.98 → 0.0.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/ADMIN.md +11 -1
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
|
@@ -0,0 +1,507 @@
|
|
|
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
|
+
let clean = input.toUpperCase().replace(/[\s-]/g, '');
|
|
62
|
+
while (clean.endsWith('=')) {
|
|
63
|
+
clean = clean.slice(0, -1);
|
|
64
|
+
}
|
|
65
|
+
if (clean.length === 0) return Buffer.alloc(0);
|
|
66
|
+
|
|
67
|
+
let bits = 0;
|
|
68
|
+
let value = 0;
|
|
69
|
+
const bytes = [];
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < clean.length; i++) {
|
|
72
|
+
const char = clean[i];
|
|
73
|
+
const val = RFC4648_BASE32_ALPHABET.indexOf(char);
|
|
74
|
+
if (val === -1) {
|
|
75
|
+
throw new Error(`Invalid Base32 character: "${char}"`);
|
|
76
|
+
}
|
|
77
|
+
value = (value << 5) | val;
|
|
78
|
+
bits += 5;
|
|
79
|
+
if (bits >= 8) {
|
|
80
|
+
bytes.push((value >>> (bits - 8)) & 0xff);
|
|
81
|
+
bits -= 8;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Buffer.from(bytes);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Generate a random cryptographically secure TOTP secret key
|
|
90
|
+
* @param {Object} [options]
|
|
91
|
+
* @param {number} [options.length=20] - Number of random bytes (20 bytes = 160 bits per RFC recommendations)
|
|
92
|
+
* @param {boolean} [options.padding=false] - Whether to pad the Base32 string
|
|
93
|
+
* @returns {string} Base32 encoded secret
|
|
94
|
+
*/
|
|
95
|
+
function generateTotpSecret(options = {}) {
|
|
96
|
+
const length = options.length || 20;
|
|
97
|
+
const buffer = crypto.randomBytes(length);
|
|
98
|
+
return base32Encode(buffer, options);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Normalize secret to Buffer
|
|
103
|
+
* @param {string|Buffer} secret
|
|
104
|
+
* @param {string} [encoding='base32'] - 'base32', 'ascii', 'utf8', 'hex'
|
|
105
|
+
* @returns {Buffer}
|
|
106
|
+
*/
|
|
107
|
+
function normalizeSecretBuffer(secret, encoding = 'base32') {
|
|
108
|
+
if (Buffer.isBuffer(secret)) {
|
|
109
|
+
return secret;
|
|
110
|
+
}
|
|
111
|
+
if (typeof secret !== 'string') {
|
|
112
|
+
throw new TypeError('Secret must be a string or Buffer');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (encoding === 'base32') {
|
|
116
|
+
return base32Decode(secret);
|
|
117
|
+
}
|
|
118
|
+
return Buffer.from(secret, encoding);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalize algorithm string to Node.js crypto algorithm name
|
|
123
|
+
* @param {string} algorithm
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
function normalizeAlgorithm(algorithm = 'sha1') {
|
|
127
|
+
const algo = String(algorithm).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
128
|
+
if (algo === 'sha1' || algo === 'sha256' || algo === 'sha512') {
|
|
129
|
+
return algo;
|
|
130
|
+
}
|
|
131
|
+
return 'sha1';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Generate HMAC-Based One-Time Password (HOTP, RFC 4226)
|
|
136
|
+
* @param {string|Buffer} secret - Secret key (Base32 string or Buffer)
|
|
137
|
+
* @param {number|BigInt} counter - 8-byte moving factor counter
|
|
138
|
+
* @param {Object} [options]
|
|
139
|
+
* @param {number} [options.digits=6] - Number of digits (typically 6, 7, or 8)
|
|
140
|
+
* @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
|
|
141
|
+
* @param {string} [options.encoding='base32'] - Encoding of secret if string ('base32', 'ascii', etc.)
|
|
142
|
+
* @returns {string} Numerical token padded with leading zeros
|
|
143
|
+
*/
|
|
144
|
+
function generateHotp(secret, counter, options = {}) {
|
|
145
|
+
const key = normalizeSecretBuffer(secret, options.encoding);
|
|
146
|
+
const digits = options.digits || 6;
|
|
147
|
+
const algorithm = normalizeAlgorithm(options.algorithm);
|
|
148
|
+
|
|
149
|
+
const counterBuf = Buffer.alloc(8);
|
|
150
|
+
counterBuf.writeBigUInt64BE(BigInt(counter), 0);
|
|
151
|
+
|
|
152
|
+
const hmac = crypto.createHmac(algorithm, key).update(counterBuf).digest();
|
|
153
|
+
|
|
154
|
+
// Dynamic truncation (RFC 4226 Section 5.4)
|
|
155
|
+
const offset = hmac[hmac.length - 1] & 0x0f;
|
|
156
|
+
const binary =
|
|
157
|
+
((hmac[offset] & 0x7f) << 24) |
|
|
158
|
+
((hmac[offset + 1] & 0xff) << 16) |
|
|
159
|
+
((hmac[offset + 2] & 0xff) << 8) |
|
|
160
|
+
(hmac[offset + 3] & 0xff);
|
|
161
|
+
|
|
162
|
+
const token = (binary % 10 ** digits).toString().padStart(digits, '0');
|
|
163
|
+
return token;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Timing-safe token equality check
|
|
168
|
+
* @param {string} a
|
|
169
|
+
* @param {string} b
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
function timingSafeTokenCompare(a, b) {
|
|
173
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
174
|
+
if (a.length !== b.length) return false;
|
|
175
|
+
|
|
176
|
+
const bufA = Buffer.from(a);
|
|
177
|
+
const bufB = Buffer.from(b);
|
|
178
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Verify HOTP token against counter
|
|
183
|
+
* @param {string} token - Submitted token
|
|
184
|
+
* @param {string|Buffer} secret - Secret key
|
|
185
|
+
* @param {number|BigInt} counter - Expected counter
|
|
186
|
+
* @param {Object} [options]
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
function verifyHotp(token, secret, counter, options = {}) {
|
|
190
|
+
if (typeof token !== 'string') return false;
|
|
191
|
+
const cleanToken = token.trim();
|
|
192
|
+
const digits = options.digits || cleanToken.length || 6;
|
|
193
|
+
const expected = generateHotp(secret, counter, { ...options, digits });
|
|
194
|
+
return timingSafeTokenCompare(cleanToken, expected);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Generate Time-Based One-Time Password (TOTP, RFC 6238)
|
|
199
|
+
* @param {string|Buffer} secret - Secret key (Base32 or Buffer)
|
|
200
|
+
* @param {Object} [options]
|
|
201
|
+
* @param {number|Date} [options.time=Date.now()] - Timestamp in ms, seconds, or Date
|
|
202
|
+
* @param {number} [options.step=30] - Time step in seconds (default: 30)
|
|
203
|
+
* @param {number} [options.epoch=0] - Epoch start in seconds (default: 0)
|
|
204
|
+
* @param {number} [options.digits=6] - Number of digits (default: 6)
|
|
205
|
+
* @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
|
|
206
|
+
* @param {string} [options.encoding='base32'] - Encoding of secret if string
|
|
207
|
+
* @returns {string} Numerical token padded with leading zeros
|
|
208
|
+
*/
|
|
209
|
+
function generateTotp(secret, options = {}) {
|
|
210
|
+
let timeSeconds;
|
|
211
|
+
if (options.time instanceof Date) {
|
|
212
|
+
timeSeconds = Math.floor(options.time.getTime() / 1000);
|
|
213
|
+
} else if (typeof options.time === 'number') {
|
|
214
|
+
// If value looks like milliseconds (> 10000000000), convert to seconds
|
|
215
|
+
timeSeconds = options.time > 1e11 ? Math.floor(options.time / 1000) : Math.floor(options.time);
|
|
216
|
+
} else {
|
|
217
|
+
timeSeconds = Math.floor(Date.now() / 1000);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const step = options.step || 30;
|
|
221
|
+
const epoch = options.epoch || 0;
|
|
222
|
+
const counter = Math.floor((timeSeconds - epoch) / step);
|
|
223
|
+
|
|
224
|
+
return generateHotp(secret, counter, options);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Verify TOTP token with time-drift window support
|
|
229
|
+
* @param {string} token - Submitted token
|
|
230
|
+
* @param {string|Buffer} secret - Secret key
|
|
231
|
+
* @param {Object} [options]
|
|
232
|
+
* @param {number} [options.window=1] - Counter drift window (e.g. 1 checks counter-1, counter, counter+1)
|
|
233
|
+
* @param {number|Date} [options.time=Date.now()] - Current timestamp
|
|
234
|
+
* @param {number} [options.step=30] - Time step in seconds
|
|
235
|
+
* @param {number} [options.digits=6] - Expected digits
|
|
236
|
+
* @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
|
|
237
|
+
* @param {boolean} [options.detailed=false] - Return { valid: boolean, delta: number|null } instead of boolean
|
|
238
|
+
* @returns {boolean|{ valid: boolean, delta: number|null }}
|
|
239
|
+
*/
|
|
240
|
+
function verifyTotp(token, secret, options = {}) {
|
|
241
|
+
if (typeof token !== 'string') {
|
|
242
|
+
return options.detailed ? { valid: false, delta: null } : false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const cleanToken = token.trim();
|
|
246
|
+
const digits = options.digits || cleanToken.length || 6;
|
|
247
|
+
const step = options.step || 30;
|
|
248
|
+
const epoch = options.epoch || 0;
|
|
249
|
+
const windowSize = Math.max(0, options.window != null ? options.window : 1);
|
|
250
|
+
|
|
251
|
+
let timeSeconds;
|
|
252
|
+
if (options.time instanceof Date) {
|
|
253
|
+
timeSeconds = Math.floor(options.time.getTime() / 1000);
|
|
254
|
+
} else if (typeof options.time === 'number') {
|
|
255
|
+
timeSeconds = options.time > 1e11 ? Math.floor(options.time / 1000) : Math.floor(options.time);
|
|
256
|
+
} else {
|
|
257
|
+
timeSeconds = Math.floor(Date.now() / 1000);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const currentCounter = Math.floor((timeSeconds - epoch) / step);
|
|
261
|
+
|
|
262
|
+
for (let delta = -windowSize; delta <= windowSize; delta++) {
|
|
263
|
+
const counter = currentCounter + delta;
|
|
264
|
+
if (counter < 0) continue;
|
|
265
|
+
|
|
266
|
+
const expected = generateHotp(secret, counter, { ...options, digits });
|
|
267
|
+
if (timingSafeTokenCompare(cleanToken, expected)) {
|
|
268
|
+
if (options.detailed) {
|
|
269
|
+
return { valid: true, delta };
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (options.detailed) {
|
|
276
|
+
return { valid: false, delta: null };
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Generate standard Key URI for Google Authenticator / 1Password / Authy (otpauth://)
|
|
283
|
+
* @param {Object} options
|
|
284
|
+
* @param {string} options.secret - Base32 encoded secret
|
|
285
|
+
* @param {string} [options.account] - User account identifier (e.g. 'alice@example.com')
|
|
286
|
+
* @param {string} [options.issuer='Webspresso'] - Application or organization name
|
|
287
|
+
* @param {string} [options.label] - Custom full label (overrides issuer:account)
|
|
288
|
+
* @param {string} [options.algorithm='SHA1'] - Algorithm ('SHA1', 'SHA256', 'SHA512')
|
|
289
|
+
* @param {number} [options.digits=6] - Number of digits
|
|
290
|
+
* @param {number} [options.period=30] - Time period in seconds
|
|
291
|
+
* @returns {string} otpauth URI string
|
|
292
|
+
*/
|
|
293
|
+
function generateTotpUri(options = {}) {
|
|
294
|
+
const {
|
|
295
|
+
secret,
|
|
296
|
+
account,
|
|
297
|
+
issuer = 'Webspresso',
|
|
298
|
+
digits = 6,
|
|
299
|
+
period = 30,
|
|
300
|
+
algorithm = 'SHA1',
|
|
301
|
+
} = options;
|
|
302
|
+
|
|
303
|
+
if (!secret) {
|
|
304
|
+
throw new Error('Secret is required to generate TOTP URI');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Label: "Issuer:Account" or "Account"
|
|
308
|
+
let label = options.label;
|
|
309
|
+
if (!label) {
|
|
310
|
+
const cleanAccount = account || 'user';
|
|
311
|
+
label = issuer ? `${issuer}:${cleanAccount}` : cleanAccount;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const params = new URLSearchParams();
|
|
315
|
+
params.set('secret', secret);
|
|
316
|
+
if (issuer) params.set('issuer', issuer);
|
|
317
|
+
if (algorithm && algorithm.toUpperCase() !== 'SHA1') {
|
|
318
|
+
params.set('algorithm', algorithm.toUpperCase());
|
|
319
|
+
}
|
|
320
|
+
if (digits && digits !== 6) {
|
|
321
|
+
params.set('digits', String(digits));
|
|
322
|
+
}
|
|
323
|
+
if (period && period !== 30) {
|
|
324
|
+
params.set('period', String(period));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// otpauth://totp/LABEL?PARAMS
|
|
328
|
+
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Generate SVG string of the QR Code for a TOTP URI
|
|
333
|
+
* @param {string|Object} uriOrOptions - URI string or options for generateTotpUri
|
|
334
|
+
* @param {Object} [svgOptions] - QR SVG rendering options (size, margin, darkColor, lightColor)
|
|
335
|
+
* @returns {string} SVG string
|
|
336
|
+
*/
|
|
337
|
+
function generateTotpQrSvg(uriOrOptions, svgOptions = {}) {
|
|
338
|
+
const uri = typeof uriOrOptions === 'string' ? uriOrOptions : generateTotpUri(uriOrOptions);
|
|
339
|
+
return generateQrSvg(uri, svgOptions);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Generate Data URI of the QR Code SVG
|
|
344
|
+
* @param {string|Object} uriOrOptions
|
|
345
|
+
* @param {Object} [svgOptions]
|
|
346
|
+
* @returns {string} data:image/svg+xml;utf8,...
|
|
347
|
+
*/
|
|
348
|
+
function generateTotpQrDataUri(uriOrOptions, svgOptions = {}) {
|
|
349
|
+
const uri = typeof uriOrOptions === 'string' ? uriOrOptions : generateTotpUri(uriOrOptions);
|
|
350
|
+
return generateQrDataUri(uri, svgOptions);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Generate a set of random recovery / backup codes
|
|
355
|
+
* E.g., 'a1b2-c3d4', '98f7-21a4'
|
|
356
|
+
* @param {Object} [options]
|
|
357
|
+
* @param {number} [options.count=8] - Number of codes to generate
|
|
358
|
+
* @param {number} [options.length=10] - Characters per code (before hyphen)
|
|
359
|
+
* @param {boolean} [options.hyphen=true] - Format with hyphen in middle
|
|
360
|
+
* @returns {string[]} Array of plaintext recovery codes
|
|
361
|
+
*/
|
|
362
|
+
function generateRecoveryCodes(options = {}) {
|
|
363
|
+
const count = options.count || 8;
|
|
364
|
+
const length = options.length || 10;
|
|
365
|
+
const hyphen = options.hyphen !== false;
|
|
366
|
+
|
|
367
|
+
const codes = [];
|
|
368
|
+
for (let i = 0; i < count; i++) {
|
|
369
|
+
// Generate random hex characters
|
|
370
|
+
const byteCount = Math.ceil(length / 2);
|
|
371
|
+
let code = crypto.randomBytes(byteCount).toString('hex').slice(0, length);
|
|
372
|
+
if (hyphen && code.length >= 6) {
|
|
373
|
+
const mid = Math.floor(code.length / 2);
|
|
374
|
+
code = `${code.slice(0, mid)}-${code.slice(mid)}`;
|
|
375
|
+
}
|
|
376
|
+
codes.push(code);
|
|
377
|
+
}
|
|
378
|
+
return codes;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Hash a single recovery code for secure database storage
|
|
383
|
+
* @param {string} code - Plaintext recovery code
|
|
384
|
+
* @returns {string} SHA-256 hex hash
|
|
385
|
+
*/
|
|
386
|
+
function hashRecoveryCode(code) {
|
|
387
|
+
if (typeof code !== 'string') {
|
|
388
|
+
throw new TypeError('Recovery code must be a string');
|
|
389
|
+
}
|
|
390
|
+
const clean = code.toLowerCase().replace(/[\s-]/g, '');
|
|
391
|
+
return crypto.createHash('sha256').update(clean, 'utf8').digest('hex');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Verify a submitted recovery code against an array of hashed recovery codes
|
|
396
|
+
* @param {string} code - Submitted plaintext recovery code
|
|
397
|
+
* @param {string[]} hashedCodes - Array of hashed codes from database
|
|
398
|
+
* @returns {{ valid: boolean, matchedIndex: number, remainingHashedCodes: string[] }}
|
|
399
|
+
*/
|
|
400
|
+
function verifyRecoveryCode(code, hashedCodes = []) {
|
|
401
|
+
if (typeof code !== 'string' || !Array.isArray(hashedCodes) || hashedCodes.length === 0) {
|
|
402
|
+
return { valid: false, matchedIndex: -1, remainingHashedCodes: hashedCodes || [] };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const targetHash = hashRecoveryCode(code);
|
|
406
|
+
const targetBuf = Buffer.from(targetHash, 'hex');
|
|
407
|
+
|
|
408
|
+
let matchedIndex = -1;
|
|
409
|
+
for (let i = 0; i < hashedCodes.length; i++) {
|
|
410
|
+
const candidateHash = hashedCodes[i];
|
|
411
|
+
if (typeof candidateHash !== 'string' || candidateHash.length !== targetHash.length) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const candidateBuf = Buffer.from(candidateHash, 'hex');
|
|
415
|
+
if (crypto.timingSafeEqual(targetBuf, candidateBuf)) {
|
|
416
|
+
matchedIndex = i;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (matchedIndex === -1) {
|
|
422
|
+
return { valid: false, matchedIndex: -1, remainingHashedCodes: [...hashedCodes] };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const remaining = hashedCodes.filter((_, idx) => idx !== matchedIndex);
|
|
426
|
+
return {
|
|
427
|
+
valid: true,
|
|
428
|
+
matchedIndex,
|
|
429
|
+
remainingHashedCodes: remaining,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Convenience all-in-one helper to initialize 2FA setup for a user
|
|
435
|
+
* @param {Object} options
|
|
436
|
+
* @param {Object|string} options.user - User object (with email or username) or user identifier string
|
|
437
|
+
* @param {string} [options.issuer='Webspresso'] - App/Company name
|
|
438
|
+
* @param {number} [options.recoveryCodeCount=8] - Number of backup codes
|
|
439
|
+
* @param {Object} [options.qrOptions] - QR SVG options
|
|
440
|
+
* @returns {{
|
|
441
|
+
* secret: string,
|
|
442
|
+
* uri: string,
|
|
443
|
+
* qrSvg: string,
|
|
444
|
+
* qrDataUri: string,
|
|
445
|
+
* recoveryCodes: string[],
|
|
446
|
+
* recoveryCodesHashed: string[]
|
|
447
|
+
* }}
|
|
448
|
+
*/
|
|
449
|
+
function createTotpSetup(options = {}) {
|
|
450
|
+
const secret = options.secret || generateTotpSecret(options);
|
|
451
|
+
const user = options.user;
|
|
452
|
+
const account = typeof user === 'string' ? user : (user?.email || user?.username || user?.id || 'user');
|
|
453
|
+
const issuer = options.issuer || 'Webspresso';
|
|
454
|
+
|
|
455
|
+
const uri = generateTotpUri({
|
|
456
|
+
secret,
|
|
457
|
+
account: String(account),
|
|
458
|
+
issuer,
|
|
459
|
+
...options,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const qrSvg = generateTotpQrSvg(uri, options.qrOptions);
|
|
463
|
+
const qrDataUri = generateTotpQrDataUri(uri, options.qrOptions);
|
|
464
|
+
|
|
465
|
+
const recoveryCodes = generateRecoveryCodes({ count: options.recoveryCodeCount || 8 });
|
|
466
|
+
const recoveryCodesHashed = recoveryCodes.map(hashRecoveryCode);
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
secret,
|
|
470
|
+
uri,
|
|
471
|
+
qrSvg,
|
|
472
|
+
qrDataUri,
|
|
473
|
+
recoveryCodes,
|
|
474
|
+
recoveryCodesHashed,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
module.exports = {
|
|
479
|
+
// Base32
|
|
480
|
+
base32Encode,
|
|
481
|
+
base32Decode,
|
|
482
|
+
|
|
483
|
+
// HOTP & TOTP Core
|
|
484
|
+
generateTotpSecret,
|
|
485
|
+
generateHotp,
|
|
486
|
+
verifyHotp,
|
|
487
|
+
generateTotp,
|
|
488
|
+
verifyTotp,
|
|
489
|
+
|
|
490
|
+
// URI & QR
|
|
491
|
+
generateTotpUri,
|
|
492
|
+
generateTotpQrSvg,
|
|
493
|
+
generateTotpQrDataUri,
|
|
494
|
+
|
|
495
|
+
// Recovery Codes
|
|
496
|
+
generateRecoveryCodes,
|
|
497
|
+
hashRecoveryCode,
|
|
498
|
+
verifyRecoveryCode,
|
|
499
|
+
|
|
500
|
+
// All-in-one setup
|
|
501
|
+
createTotpSetup,
|
|
502
|
+
|
|
503
|
+
// QR primitives
|
|
504
|
+
createQrMatrix,
|
|
505
|
+
generateQrSvg,
|
|
506
|
+
generateQrDataUri,
|
|
507
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Zero-Dependency User-Agent & Device Parser
|
|
3
|
+
* Extracts browser, operating system, and device category from User-Agent header
|
|
4
|
+
* @module core/auth/user-agent
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} DeviceInfo
|
|
11
|
+
* @property {string} browser - Browser name with major version (e.g. 'Chrome 128', 'Safari 17')
|
|
12
|
+
* @property {string} os - Operating system name (e.g. 'macOS 14', 'Windows 11', 'iOS 17')
|
|
13
|
+
* @property {'desktop'|'mobile'|'tablet'|'bot'|'unknown'} device - Device category
|
|
14
|
+
* @property {boolean} isMobile
|
|
15
|
+
* @property {boolean} isTablet
|
|
16
|
+
* @property {boolean} isDesktop
|
|
17
|
+
* @property {boolean} isBot
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract client IP address handling proxies
|
|
22
|
+
* @param {Object} req - Express / Node.js HTTP request
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function getClientIp(req) {
|
|
26
|
+
if (!req) return '127.0.0.1';
|
|
27
|
+
|
|
28
|
+
const forwarded = req.headers?.['x-forwarded-for'];
|
|
29
|
+
if (forwarded) {
|
|
30
|
+
const ips = typeof forwarded === 'string' ? forwarded.split(',') : forwarded;
|
|
31
|
+
const first = Array.isArray(ips) ? ips[0] : ips;
|
|
32
|
+
if (first) return first.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return req.headers?.['x-real-ip'] ||
|
|
36
|
+
req.ip ||
|
|
37
|
+
req.connection?.remoteAddress ||
|
|
38
|
+
req.socket?.remoteAddress ||
|
|
39
|
+
'127.0.0.1';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse raw User-Agent header string into structured device info
|
|
44
|
+
* Zero external dependencies — pure regex extraction
|
|
45
|
+
*
|
|
46
|
+
* @param {string} [userAgent='']
|
|
47
|
+
* @returns {DeviceInfo}
|
|
48
|
+
*/
|
|
49
|
+
function parseUserAgent(userAgent = '') {
|
|
50
|
+
const ua = String(userAgent || '').trim();
|
|
51
|
+
|
|
52
|
+
if (!ua) {
|
|
53
|
+
return {
|
|
54
|
+
browser: 'Unknown',
|
|
55
|
+
os: 'Unknown',
|
|
56
|
+
device: 'unknown',
|
|
57
|
+
isMobile: false,
|
|
58
|
+
isTablet: false,
|
|
59
|
+
isDesktop: false,
|
|
60
|
+
isBot: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 1. Detect Bots / Crawlers
|
|
65
|
+
const botPattern = /googlebot|bingbot|yandex|baiduspider|duckduckbot|slurp|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/|slackbot|vkshare|w3c_validator|whatsapp|curl|wget|postman/i;
|
|
66
|
+
const isBot = botPattern.test(ua);
|
|
67
|
+
|
|
68
|
+
// 2. Detect Operating System
|
|
69
|
+
let os = 'Unknown';
|
|
70
|
+
if (/iPad/i.test(ua)) {
|
|
71
|
+
const match = ua.match(/OS ([\d_\.]+)/i);
|
|
72
|
+
os = match ? `iPadOS ${match[1].replace(/_/g, '.')}` : 'iPadOS';
|
|
73
|
+
} else if (/iPhone|iPod/i.test(ua)) {
|
|
74
|
+
const match = ua.match(/OS ([\d_\.]+)/i);
|
|
75
|
+
os = match ? `iOS ${match[1].replace(/_/g, '.')}` : 'iOS';
|
|
76
|
+
} else if (/Android/i.test(ua)) {
|
|
77
|
+
const match = ua.match(/Android (\d+(\.\d+)?)/i);
|
|
78
|
+
os = match ? `Android ${match[1]}` : 'Android';
|
|
79
|
+
} else if (/Macintosh|Mac OS X/i.test(ua)) {
|
|
80
|
+
const match = ua.match(/Mac OS X ([\d_\.]+)/i);
|
|
81
|
+
if (match) {
|
|
82
|
+
const ver = match[1].replace(/_/g, '.');
|
|
83
|
+
os = `macOS ${ver}`;
|
|
84
|
+
} else {
|
|
85
|
+
os = 'macOS';
|
|
86
|
+
}
|
|
87
|
+
} else if (/Windows/i.test(ua)) {
|
|
88
|
+
if (/Windows NT 10\.0/i.test(ua)) os = 'Windows 10/11';
|
|
89
|
+
else if (/Windows NT 6\.3/i.test(ua)) os = 'Windows 8.1';
|
|
90
|
+
else if (/Windows NT 6\.2/i.test(ua)) os = 'Windows 8';
|
|
91
|
+
else if (/Windows NT 6\.1/i.test(ua)) os = 'Windows 7';
|
|
92
|
+
else os = 'Windows';
|
|
93
|
+
} else if (/CrOS/i.test(ua)) {
|
|
94
|
+
os = 'ChromeOS';
|
|
95
|
+
} else if (/Linux/i.test(ua)) {
|
|
96
|
+
if (/Ubuntu/i.test(ua)) os = 'Ubuntu Linux';
|
|
97
|
+
else if (/Fedora/i.test(ua)) os = 'Fedora Linux';
|
|
98
|
+
else os = 'Linux';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3. Detect Browser
|
|
102
|
+
let browser = 'Unknown';
|
|
103
|
+
if (/Edg(e|A|iOS)?\/(\d+)/i.test(ua)) {
|
|
104
|
+
const match = ua.match(/Edg(e|A|iOS)?\/(\d+)/i);
|
|
105
|
+
browser = `Edge ${match ? match[2] : ''}`.trim();
|
|
106
|
+
} else if (/OPR\/(\d+)/i.test(ua)) {
|
|
107
|
+
const match = ua.match(/OPR\/(\d+)/i);
|
|
108
|
+
browser = `Opera ${match ? match[1] : ''}`.trim();
|
|
109
|
+
} else if (/SamsungBrowser\/(\d+)/i.test(ua)) {
|
|
110
|
+
const match = ua.match(/SamsungBrowser\/(\d+)/i);
|
|
111
|
+
browser = `Samsung Internet ${match ? match[1] : ''}`.trim();
|
|
112
|
+
} else if (/Brave\/(\d+)/i.test(ua)) {
|
|
113
|
+
const match = ua.match(/Brave\/(\d+)/i);
|
|
114
|
+
browser = `Brave ${match ? match[1] : ''}`.trim();
|
|
115
|
+
} else if (/Vivaldi\/(\d+)/i.test(ua)) {
|
|
116
|
+
const match = ua.match(/Vivaldi\/(\d+)/i);
|
|
117
|
+
browser = `Vivaldi ${match ? match[1] : ''}`.trim();
|
|
118
|
+
} else if (/Chrome\/(\d+)/i.test(ua) && !/Chromium/i.test(ua)) {
|
|
119
|
+
const match = ua.match(/Chrome\/(\d+)/i);
|
|
120
|
+
browser = `Chrome ${match ? match[1] : ''}`.trim();
|
|
121
|
+
} else if (/Firefox\/(\d+)/i.test(ua) || /FxiOS\/(\d+)/i.test(ua)) {
|
|
122
|
+
const match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i);
|
|
123
|
+
browser = `Firefox ${match ? match[1] : ''}`.trim();
|
|
124
|
+
} else if (/Safari/i.test(ua) && /Version\/(\d+)/i.test(ua)) {
|
|
125
|
+
const match = ua.match(/Version\/(\d+)/i);
|
|
126
|
+
browser = `Safari ${match ? match[1] : ''}`.trim();
|
|
127
|
+
} else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
|
|
128
|
+
browser = 'Safari';
|
|
129
|
+
} else if (/MSIE (\d+)/i.test(ua) || (/Trident/i.test(ua) && /rv:(\d+)/i.test(ua))) {
|
|
130
|
+
browser = 'Internet Explorer';
|
|
131
|
+
} else if (isBot) {
|
|
132
|
+
const botNameMatch = ua.match(/(googlebot|bingbot|duckduckbot|slurp|twitterbot|slackbot|curl|wget)/i);
|
|
133
|
+
browser = botNameMatch ? botNameMatch[1] : 'Bot';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 4. Detect Device Category
|
|
137
|
+
const isTablet = /iPad|tablet/i.test(ua) || (/android/i.test(ua) && !/mobile/i.test(ua));
|
|
138
|
+
const isMobile = !isTablet && /mobile|iphone|ipod|android|blackberry|opera mini|windows phone/i.test(ua);
|
|
139
|
+
const isDesktop = !isTablet && !isMobile && !isBot;
|
|
140
|
+
|
|
141
|
+
let device = 'unknown';
|
|
142
|
+
if (isBot) device = 'bot';
|
|
143
|
+
else if (isTablet) device = 'tablet';
|
|
144
|
+
else if (isMobile) device = 'mobile';
|
|
145
|
+
else if (isDesktop) device = 'desktop';
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
browser,
|
|
149
|
+
os,
|
|
150
|
+
device,
|
|
151
|
+
isMobile,
|
|
152
|
+
isTablet,
|
|
153
|
+
isDesktop,
|
|
154
|
+
isBot,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
getClientIp,
|
|
160
|
+
parseUserAgent,
|
|
161
|
+
};
|