@zero-server/auth 0.9.1 → 0.9.2
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 -21
- package/index.js +26 -26
- package/lib/auth/authorize.js +399 -0
- package/lib/auth/enrollment.js +367 -0
- package/lib/auth/index.js +57 -0
- package/lib/auth/jwt.js +731 -0
- package/lib/auth/oauth.js +362 -0
- package/lib/auth/session.js +588 -0
- package/lib/auth/trustedDevice.js +409 -0
- package/lib/auth/twoFactor.js +1150 -0
- package/lib/auth/webauthn.js +946 -0
- package/lib/debug.js +372 -0
- package/package.json +11 -2
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module auth/twoFactor
|
|
3
|
+
* @description Zero-dependency Two-Factor Authentication (2FA) module.
|
|
4
|
+
* Implements TOTP (RFC 6238 / RFC 4226), backup codes,
|
|
5
|
+
* and composable middleware for step-up verification.
|
|
6
|
+
*
|
|
7
|
+
* Uses only Node.js built-in `crypto` — no external packages.
|
|
8
|
+
*
|
|
9
|
+
* @example | Setup 2FA for a user
|
|
10
|
+
* const { twoFactor } = require('@zero-server/sdk');
|
|
11
|
+
*
|
|
12
|
+
* app.post('/2fa/setup', async (req, res) => {
|
|
13
|
+
* const secret = twoFactor.generateSecret();
|
|
14
|
+
* const uri = twoFactor.otpauthURI({ secret, issuer: 'MyApp', account: req.user.email });
|
|
15
|
+
* // Store secret.base32 in your database (encrypted)
|
|
16
|
+
* res.json({ secret: secret.base32, uri });
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* @example | Verify a TOTP code
|
|
20
|
+
* app.post('/2fa/verify', async (req, res) => {
|
|
21
|
+
* const user = await db.users.findById(req.user.sub);
|
|
22
|
+
* const valid = twoFactor.verifyTOTP(req.body.code, user.totpSecret);
|
|
23
|
+
* if (!valid) return res.status(401).json({ error: 'Invalid code' });
|
|
24
|
+
* req.session.set('twoFactorVerified', true);
|
|
25
|
+
* res.json({ ok: true });
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* @example | Protect routes with 2FA middleware
|
|
29
|
+
* app.use('/admin', twoFactor.require2FA(), adminRouter);
|
|
30
|
+
*
|
|
31
|
+
* @example | Generate and redeem backup codes
|
|
32
|
+
* const { codes, hashes } = twoFactor.generateBackupCodes(10);
|
|
33
|
+
* // Store hashes in DB; give codes to user once
|
|
34
|
+
* const ok = await twoFactor.verifyBackupCode(inputCode, storedHashes);
|
|
35
|
+
*/
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const log = require('../debug')('zero:2fa');
|
|
38
|
+
|
|
39
|
+
// -- Constants ---------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** Default TOTP period in seconds. */
|
|
42
|
+
const DEFAULT_PERIOD = 30;
|
|
43
|
+
|
|
44
|
+
/** Default TOTP code length in digits. */
|
|
45
|
+
const DEFAULT_DIGITS = 6;
|
|
46
|
+
|
|
47
|
+
/** Default HMAC algorithm. */
|
|
48
|
+
const DEFAULT_ALGORITHM = 'sha1';
|
|
49
|
+
|
|
50
|
+
/** Default time-step window for clock drift (±1 step). */
|
|
51
|
+
const DEFAULT_WINDOW = 1;
|
|
52
|
+
|
|
53
|
+
// -- TOTP Replay Prevention (RFC 6238 §5.2) ----------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* In-memory replay store for TOTP counter tracking.
|
|
57
|
+
* Prevents code reuse within the validity window by storing the last-used
|
|
58
|
+
* time-step counter per user. Implements TTL-based eviction.
|
|
59
|
+
*
|
|
60
|
+
* For distributed deployments, implement the same interface backed by Redis or a database.
|
|
61
|
+
*
|
|
62
|
+
* @class
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* const store = new InMemoryReplayStore();
|
|
66
|
+
* app.post('/verify', verifyTOTPMiddleware({
|
|
67
|
+
* replayStore: store,
|
|
68
|
+
* getUserId: (req) => req.user.id,
|
|
69
|
+
* getSecret: (req) => req.user.totpSecret,
|
|
70
|
+
* }));
|
|
71
|
+
*/
|
|
72
|
+
class InMemoryReplayStore
|
|
73
|
+
{
|
|
74
|
+
constructor()
|
|
75
|
+
{
|
|
76
|
+
/** @private @type {Map<string, { counter: number, expires: number }>} */
|
|
77
|
+
this._store = new Map();
|
|
78
|
+
|
|
79
|
+
/** @private - Periodic pruning every 60 seconds */
|
|
80
|
+
this._pruneTimer = setInterval(() =>
|
|
81
|
+
{
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
for (const [key, entry] of this._store)
|
|
84
|
+
{
|
|
85
|
+
if (now >= entry.expires) this._store.delete(key);
|
|
86
|
+
}
|
|
87
|
+
}, 60000);
|
|
88
|
+
if (this._pruneTimer.unref) this._pruneTimer.unref();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the last-used counter for a user.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} userId - User identifier.
|
|
95
|
+
* @returns {Promise<number|null>} Last-used counter or null.
|
|
96
|
+
*/
|
|
97
|
+
async get(userId)
|
|
98
|
+
{
|
|
99
|
+
const entry = this._store.get(userId);
|
|
100
|
+
if (!entry) return null;
|
|
101
|
+
if (Date.now() >= entry.expires)
|
|
102
|
+
{
|
|
103
|
+
this._store.delete(userId);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return entry.counter;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Store the last-used counter for a user with a TTL.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} userId - User identifier.
|
|
113
|
+
* @param {number} counter - The time-step counter that was used.
|
|
114
|
+
* @param {number} ttlMs - Time-to-live in milliseconds.
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
async set(userId, counter, ttlMs)
|
|
118
|
+
{
|
|
119
|
+
this._store.set(userId, {
|
|
120
|
+
counter,
|
|
121
|
+
expires: Date.now() + ttlMs,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Clear all stored counters (for testing or user revocation).
|
|
127
|
+
*/
|
|
128
|
+
clear()
|
|
129
|
+
{
|
|
130
|
+
this._store.clear();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Destroy the store and stop periodic pruning.
|
|
135
|
+
*/
|
|
136
|
+
destroy()
|
|
137
|
+
{
|
|
138
|
+
clearInterval(this._pruneTimer);
|
|
139
|
+
this._store.clear();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// -- Remaining Constants -----------------------------------------
|
|
144
|
+
|
|
145
|
+
/** Number of bytes for secret generation (20 bytes = 160-bit per RFC 4226). */
|
|
146
|
+
const SECRET_BYTES = 20;
|
|
147
|
+
|
|
148
|
+
/** Number of bytes for backup code entropy. */
|
|
149
|
+
const BACKUP_CODE_BYTES = 4;
|
|
150
|
+
|
|
151
|
+
/** Default backup code count. */
|
|
152
|
+
const DEFAULT_BACKUP_COUNT = 10;
|
|
153
|
+
|
|
154
|
+
/** Supported HMAC algorithms for TOTP. */
|
|
155
|
+
const SUPPORTED_TOTP_ALGORITHMS = ['sha1', 'sha256', 'sha512'];
|
|
156
|
+
|
|
157
|
+
// -- Base32 Encoder/Decoder (RFC 4648) ---------------------------
|
|
158
|
+
|
|
159
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Encode a Buffer to Base32 (RFC 4648) without padding.
|
|
163
|
+
*
|
|
164
|
+
* @param {Buffer} buf - Data to encode.
|
|
165
|
+
* @returns {string} Base32-encoded string (uppercase, no padding).
|
|
166
|
+
* @private
|
|
167
|
+
*/
|
|
168
|
+
function _base32Encode(buf)
|
|
169
|
+
{
|
|
170
|
+
let bits = 0;
|
|
171
|
+
let value = 0;
|
|
172
|
+
let out = '';
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < buf.length; i++)
|
|
175
|
+
{
|
|
176
|
+
value = (value << 8) | buf[i];
|
|
177
|
+
bits += 8;
|
|
178
|
+
|
|
179
|
+
while (bits >= 5)
|
|
180
|
+
{
|
|
181
|
+
bits -= 5;
|
|
182
|
+
out += BASE32_ALPHABET[(value >>> bits) & 0x1f];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (bits > 0)
|
|
187
|
+
{
|
|
188
|
+
out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decode a Base32 string (RFC 4648) to a Buffer.
|
|
196
|
+
* Tolerates lowercase, whitespace, and missing padding.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} str - Base32-encoded string.
|
|
199
|
+
* @returns {Buffer} Decoded bytes.
|
|
200
|
+
* @private
|
|
201
|
+
*/
|
|
202
|
+
function _base32Decode(str)
|
|
203
|
+
{
|
|
204
|
+
const cleaned = str.replace(/[\s=]/g, '').toUpperCase();
|
|
205
|
+
let bits = 0;
|
|
206
|
+
let value = 0;
|
|
207
|
+
const bytes = [];
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < cleaned.length; i++)
|
|
210
|
+
{
|
|
211
|
+
const idx = BASE32_ALPHABET.indexOf(cleaned[i]);
|
|
212
|
+
if (idx === -1) throw new Error(`Invalid Base32 character: ${cleaned[i]}`);
|
|
213
|
+
|
|
214
|
+
value = (value << 5) | idx;
|
|
215
|
+
bits += 5;
|
|
216
|
+
|
|
217
|
+
if (bits >= 8)
|
|
218
|
+
{
|
|
219
|
+
bits -= 8;
|
|
220
|
+
bytes.push((value >>> bits) & 0xff);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return Buffer.from(bytes);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// -- HOTP (RFC 4226) Core ----------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Generate an HOTP code for a given counter value.
|
|
231
|
+
* Implements RFC 4226 §5 — HMAC-based One-Time Password.
|
|
232
|
+
*
|
|
233
|
+
* @param {Buffer} secret - Shared secret key.
|
|
234
|
+
* @param {number} counter - 8-byte counter value.
|
|
235
|
+
* @param {object} [opts] - Options.
|
|
236
|
+
* @param {number} [opts.digits=6] - Code length (6 or 8).
|
|
237
|
+
* @param {string} [opts.algorithm='sha1'] - HMAC algorithm.
|
|
238
|
+
* @returns {string} Zero-padded OTP code.
|
|
239
|
+
* @private
|
|
240
|
+
*/
|
|
241
|
+
function _generateHOTP(secret, counter, opts = {})
|
|
242
|
+
{
|
|
243
|
+
const digits = opts.digits || DEFAULT_DIGITS;
|
|
244
|
+
const algorithm = opts.algorithm || DEFAULT_ALGORITHM;
|
|
245
|
+
|
|
246
|
+
// Counter as 8-byte big-endian buffer
|
|
247
|
+
const counterBuf = Buffer.alloc(8);
|
|
248
|
+
counterBuf.writeBigUInt64BE(BigInt(counter));
|
|
249
|
+
|
|
250
|
+
// HMAC
|
|
251
|
+
const hmac = crypto.createHmac(algorithm, secret);
|
|
252
|
+
hmac.update(counterBuf);
|
|
253
|
+
const digest = hmac.digest();
|
|
254
|
+
|
|
255
|
+
// Dynamic truncation (RFC 4226 §5.4)
|
|
256
|
+
const offset = digest[digest.length - 1] & 0x0f;
|
|
257
|
+
const binary =
|
|
258
|
+
((digest[offset] & 0x7f) << 24) |
|
|
259
|
+
((digest[offset + 1] & 0xff) << 16) |
|
|
260
|
+
((digest[offset + 2] & 0xff) << 8) |
|
|
261
|
+
(digest[offset + 3] & 0xff);
|
|
262
|
+
|
|
263
|
+
const otp = binary % Math.pow(10, digits);
|
|
264
|
+
return otp.toString().padStart(digits, '0');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// -- TOTP (RFC 6238) Functions -----------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Generate a cryptographically random TOTP secret.
|
|
271
|
+
*
|
|
272
|
+
* @param {number} [bytes=20] - Number of random bytes (default 20 = 160-bit, per RFC 4226).
|
|
273
|
+
* @returns {{ raw: Buffer, base32: string, hex: string }} Secret in multiple formats.
|
|
274
|
+
*
|
|
275
|
+
* @example
|
|
276
|
+
* const secret = generateSecret();
|
|
277
|
+
* console.log(secret.base32); // "JBSWY3DPEHPK3PXP..."
|
|
278
|
+
* // Store secret.base32 in database (encrypted at rest)
|
|
279
|
+
*/
|
|
280
|
+
function generateSecret(bytes)
|
|
281
|
+
{
|
|
282
|
+
const len = bytes || SECRET_BYTES;
|
|
283
|
+
const raw = crypto.randomBytes(len);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
raw,
|
|
287
|
+
base32: _base32Encode(raw),
|
|
288
|
+
hex: raw.toString('hex'),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Generate a TOTP code for the current (or given) time.
|
|
294
|
+
*
|
|
295
|
+
* @param {string|Buffer} secret - Base32-encoded string or raw Buffer.
|
|
296
|
+
* @param {object} [opts] - Options.
|
|
297
|
+
* @param {number} [opts.period=30] - Time step in seconds.
|
|
298
|
+
* @param {number} [opts.digits=6] - Code length.
|
|
299
|
+
* @param {string} [opts.algorithm='sha1'] - HMAC algorithm (sha1, sha256, sha512).
|
|
300
|
+
* @param {number} [opts.time] - Unix timestamp in seconds (defaults to now).
|
|
301
|
+
* @returns {string} TOTP code string.
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* const code = generateTOTP('JBSWY3DPEHPK3PXP');
|
|
305
|
+
* // '482913'
|
|
306
|
+
*
|
|
307
|
+
* @example | Custom period and algorithm
|
|
308
|
+
* const code = generateTOTP(secret, { period: 60, algorithm: 'sha256', digits: 8 });
|
|
309
|
+
*/
|
|
310
|
+
function generateTOTP(secret, opts = {})
|
|
311
|
+
{
|
|
312
|
+
const period = opts.period || DEFAULT_PERIOD;
|
|
313
|
+
const algorithm = opts.algorithm || DEFAULT_ALGORITHM;
|
|
314
|
+
const digits = opts.digits || DEFAULT_DIGITS;
|
|
315
|
+
const time = opts.time != null ? opts.time : Math.floor(Date.now() / 1000);
|
|
316
|
+
|
|
317
|
+
if (!SUPPORTED_TOTP_ALGORITHMS.includes(algorithm))
|
|
318
|
+
throw new Error(`Unsupported algorithm: ${algorithm}. Use: ${SUPPORTED_TOTP_ALGORITHMS.join(', ')}`);
|
|
319
|
+
|
|
320
|
+
const secretBuf = Buffer.isBuffer(secret) ? secret : _base32Decode(secret);
|
|
321
|
+
const counter = Math.floor(time / period);
|
|
322
|
+
|
|
323
|
+
return _generateHOTP(secretBuf, counter, { digits, algorithm });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Verify a user-supplied TOTP code against a shared secret.
|
|
328
|
+
* Checks within a configurable time-step window to handle clock drift.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} token - The 6/8-digit code submitted by the user.
|
|
331
|
+
* @param {string|Buffer} secret - Base32-encoded string or raw Buffer.
|
|
332
|
+
* @param {object} [opts] - Options.
|
|
333
|
+
* @param {number} [opts.period=30] - Time step in seconds.
|
|
334
|
+
* @param {number} [opts.digits=6] - Expected code length.
|
|
335
|
+
* @param {string} [opts.algorithm='sha1'] - HMAC algorithm.
|
|
336
|
+
* @param {number} [opts.window=1] - Number of periods to check before/after current.
|
|
337
|
+
* @param {number} [opts.time] - Unix timestamp override (seconds).
|
|
338
|
+
* @returns {{ valid: boolean, delta: number|null }} Result with timing delta.
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* const result = verifyTOTP('482913', userSecret);
|
|
342
|
+
* if (result.valid) console.log('Authenticated!', result.delta);
|
|
343
|
+
*
|
|
344
|
+
* @example | Wider window for unreliable clocks
|
|
345
|
+
* const result = verifyTOTP(code, secret, { window: 2 });
|
|
346
|
+
*/
|
|
347
|
+
function verifyTOTP(token, secret, opts = {})
|
|
348
|
+
{
|
|
349
|
+
const period = opts.period || DEFAULT_PERIOD;
|
|
350
|
+
const algorithm = opts.algorithm || DEFAULT_ALGORITHM;
|
|
351
|
+
const digits = opts.digits || DEFAULT_DIGITS;
|
|
352
|
+
const window = opts.window != null ? opts.window : DEFAULT_WINDOW;
|
|
353
|
+
const time = opts.time != null ? opts.time : Math.floor(Date.now() / 1000);
|
|
354
|
+
|
|
355
|
+
if (typeof token !== 'string' || token.length !== digits || !/^\d+$/.test(token))
|
|
356
|
+
return { valid: false, delta: null };
|
|
357
|
+
|
|
358
|
+
const secretBuf = Buffer.isBuffer(secret) ? secret : _base32Decode(secret);
|
|
359
|
+
const currentCounter = Math.floor(time / period);
|
|
360
|
+
|
|
361
|
+
for (let i = -window; i <= window; i++)
|
|
362
|
+
{
|
|
363
|
+
const candidate = _generateHOTP(secretBuf, currentCounter + i, { digits, algorithm });
|
|
364
|
+
|
|
365
|
+
// Constant-time comparison to prevent timing attacks
|
|
366
|
+
if (candidate.length === token.length &&
|
|
367
|
+
crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(token)))
|
|
368
|
+
{
|
|
369
|
+
log.info('TOTP verified (delta=%d)', i);
|
|
370
|
+
return { valid: true, delta: i };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
log.warn('TOTP verification failed');
|
|
375
|
+
return { valid: false, delta: null };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// -- OTPAuth URI --------------------------------------------------
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Generate an `otpauth://` URI for QR code enrollment.
|
|
382
|
+
* Compatible with Google Authenticator, Authy, 1Password, etc.
|
|
383
|
+
*
|
|
384
|
+
* @param {object} opts - URI options.
|
|
385
|
+
* @param {string|Buffer} opts.secret - Base32-encoded secret or raw Buffer.
|
|
386
|
+
* @param {string} opts.issuer - Application/company name (e.g. "MyApp").
|
|
387
|
+
* @param {string} opts.account - User identifier (e.g. email).
|
|
388
|
+
* @param {number} [opts.period=30] - Time step in seconds.
|
|
389
|
+
* @param {number} [opts.digits=6] - Code length.
|
|
390
|
+
* @param {string} [opts.algorithm='sha1'] - HMAC algorithm.
|
|
391
|
+
* @returns {string} `otpauth://totp/...` URI string.
|
|
392
|
+
*
|
|
393
|
+
* @example
|
|
394
|
+
* const uri = otpauthURI({
|
|
395
|
+
* secret: 'JBSWY3DPEHPK3PXP',
|
|
396
|
+
* issuer: 'MyApp',
|
|
397
|
+
* account: 'user@example.com',
|
|
398
|
+
* });
|
|
399
|
+
* // "otpauth://totp/MyApp:user%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&algorithm=SHA1&digits=6&period=30"
|
|
400
|
+
*/
|
|
401
|
+
function otpauthURI(opts)
|
|
402
|
+
{
|
|
403
|
+
if (!opts || !opts.secret || !opts.issuer || !opts.account)
|
|
404
|
+
throw new Error('otpauthURI requires secret, issuer, and account');
|
|
405
|
+
|
|
406
|
+
const secret = Buffer.isBuffer(opts.secret) ? _base32Encode(opts.secret) : opts.secret;
|
|
407
|
+
const algorithm = (opts.algorithm || DEFAULT_ALGORITHM).toUpperCase();
|
|
408
|
+
const digits = opts.digits || DEFAULT_DIGITS;
|
|
409
|
+
const period = opts.period || DEFAULT_PERIOD;
|
|
410
|
+
|
|
411
|
+
const label = `${encodeURIComponent(opts.issuer)}:${encodeURIComponent(opts.account)}`;
|
|
412
|
+
const params = new URLSearchParams({
|
|
413
|
+
secret,
|
|
414
|
+
issuer: opts.issuer,
|
|
415
|
+
algorithm: algorithm === 'SHA1' ? 'SHA1' : algorithm,
|
|
416
|
+
digits: String(digits),
|
|
417
|
+
period: String(period),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// -- Backup Codes ------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Generate a set of single-use backup/recovery codes.
|
|
427
|
+
* Returns both the plaintext codes (show once to user) and
|
|
428
|
+
* SHA-256 hashes (store in database).
|
|
429
|
+
*
|
|
430
|
+
* @param {number} [count=10] - Number of codes to generate.
|
|
431
|
+
* @param {number} [bytes=4] - Random bytes per code (4 bytes → 8 hex chars).
|
|
432
|
+
* @returns {{ codes: string[], hashes: string[] }} Plaintext and hashed codes.
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* const { codes, hashes } = generateBackupCodes(10);
|
|
436
|
+
* // Show codes to user once: ['a1b2c3d4', 'e5f6a7b8', ...]
|
|
437
|
+
* // Store hashes in DB: ['sha256hex...', ...]
|
|
438
|
+
*/
|
|
439
|
+
function generateBackupCodes(count, bytes)
|
|
440
|
+
{
|
|
441
|
+
const n = count || DEFAULT_BACKUP_COUNT;
|
|
442
|
+
const b = bytes || BACKUP_CODE_BYTES;
|
|
443
|
+
const codes = [];
|
|
444
|
+
const hashes = [];
|
|
445
|
+
|
|
446
|
+
for (let i = 0; i < n; i++)
|
|
447
|
+
{
|
|
448
|
+
const code = crypto.randomBytes(b).toString('hex');
|
|
449
|
+
codes.push(code);
|
|
450
|
+
hashes.push(crypto.createHash('sha256').update(code).digest('hex'));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
log.info('generated %d backup codes', n);
|
|
454
|
+
return { codes, hashes };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Verify a backup code against stored hashes.
|
|
459
|
+
* On match, returns the index so the caller can remove/mark it as used.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} code - User-supplied backup code.
|
|
462
|
+
* @param {string[]} hashes - Array of SHA-256 hex hashes stored in DB.
|
|
463
|
+
* @returns {{ valid: boolean, index: number|null }} Match result with index.
|
|
464
|
+
*
|
|
465
|
+
* @example
|
|
466
|
+
* const result = verifyBackupCode('a1b2c3d4', storedHashes);
|
|
467
|
+
* if (result.valid) {
|
|
468
|
+
* storedHashes.splice(result.index, 1); // Remove used code
|
|
469
|
+
* await user.save();
|
|
470
|
+
* }
|
|
471
|
+
*/
|
|
472
|
+
function verifyBackupCode(code, hashes)
|
|
473
|
+
{
|
|
474
|
+
if (typeof code !== 'string' || !code.length || !Array.isArray(hashes))
|
|
475
|
+
return { valid: false, index: null };
|
|
476
|
+
|
|
477
|
+
const inputHash = crypto.createHash('sha256').update(code).digest('hex');
|
|
478
|
+
const inputBuf = Buffer.from(inputHash, 'hex');
|
|
479
|
+
|
|
480
|
+
for (let i = 0; i < hashes.length; i++)
|
|
481
|
+
{
|
|
482
|
+
const storedBuf = Buffer.from(hashes[i], 'hex');
|
|
483
|
+
|
|
484
|
+
// Constant-time comparison
|
|
485
|
+
if (inputBuf.length === storedBuf.length &&
|
|
486
|
+
crypto.timingSafeEqual(inputBuf, storedBuf))
|
|
487
|
+
{
|
|
488
|
+
log.info('backup code redeemed (index=%d)', i);
|
|
489
|
+
return { valid: true, index: i };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
log.warn('backup code verification failed');
|
|
494
|
+
return { valid: false, index: null };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// -- Middleware ---------------------------------------------------
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Middleware that requires completed 2FA verification on the session.
|
|
501
|
+
* Checks `req.session.get('twoFactorVerified')` — returns 403 if not set.
|
|
502
|
+
*
|
|
503
|
+
* Designed to compose with `jwt()` or `session()` middleware:
|
|
504
|
+
*
|
|
505
|
+
* app.use(jwt({ secret }));
|
|
506
|
+
* app.use(require2FA());
|
|
507
|
+
* // — or —
|
|
508
|
+
* app.use(session({ secret }));
|
|
509
|
+
* app.use(require2FA());
|
|
510
|
+
*
|
|
511
|
+
* @param {object} [opts] - Options.
|
|
512
|
+
* @param {string} [opts.sessionKey='twoFactorVerified'] - Session key to check.
|
|
513
|
+
* @param {string} [opts.errorMessage='Two-factor authentication required'] - Error body.
|
|
514
|
+
* @param {number} [opts.statusCode=403] - HTTP status code on failure.
|
|
515
|
+
* @param {Function} [opts.isEnabled] - `(req) => boolean|Promise<boolean>`.
|
|
516
|
+
* Return `false` to skip the 2FA check for users who haven't enrolled.
|
|
517
|
+
* Defaults to always requiring 2FA.
|
|
518
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
519
|
+
*
|
|
520
|
+
* @example | Basic — all authenticated users must complete 2FA
|
|
521
|
+
* app.use(require2FA());
|
|
522
|
+
*
|
|
523
|
+
* @example | Only enforce for users who have enrolled
|
|
524
|
+
* app.use(require2FA({
|
|
525
|
+
* isEnabled: async (req) => {
|
|
526
|
+
* const user = await db.users.findById(req.user.sub);
|
|
527
|
+
* return !!user.totpSecret;
|
|
528
|
+
* },
|
|
529
|
+
* }));
|
|
530
|
+
*
|
|
531
|
+
* @example | Custom session key
|
|
532
|
+
* app.use(require2FA({ sessionKey: 'mfaComplete' }));
|
|
533
|
+
*/
|
|
534
|
+
function require2FA(opts = {})
|
|
535
|
+
{
|
|
536
|
+
const sessionKey = opts.sessionKey || 'twoFactorVerified';
|
|
537
|
+
const errorMessage = opts.errorMessage || 'Two-factor authentication required';
|
|
538
|
+
const statusCode = opts.statusCode || 403;
|
|
539
|
+
const isEnabled = opts.isEnabled || null;
|
|
540
|
+
|
|
541
|
+
return async function _require2FA(req, res, next)
|
|
542
|
+
{
|
|
543
|
+
// If user chose to only enforce for enrolled users
|
|
544
|
+
if (typeof isEnabled === 'function')
|
|
545
|
+
{
|
|
546
|
+
try
|
|
547
|
+
{
|
|
548
|
+
const enabled = await isEnabled(req);
|
|
549
|
+
if (!enabled)
|
|
550
|
+
{
|
|
551
|
+
log('2FA not enabled for user — skipping');
|
|
552
|
+
return next();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
catch (err)
|
|
556
|
+
{
|
|
557
|
+
log.error('isEnabled callback error: %s', err.message);
|
|
558
|
+
const raw = res.raw || res;
|
|
559
|
+
if (raw.headersSent) return;
|
|
560
|
+
raw.statusCode = 500;
|
|
561
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
562
|
+
raw.end(JSON.stringify({ error: 'Internal server error' }));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Check session for 2FA completion
|
|
568
|
+
const session = req.session;
|
|
569
|
+
if (!session || typeof session.get !== 'function')
|
|
570
|
+
{
|
|
571
|
+
log.warn('require2FA: no session found — is session() middleware active?');
|
|
572
|
+
const raw = res.raw || res;
|
|
573
|
+
if (raw.headersSent) return;
|
|
574
|
+
raw.statusCode = 500;
|
|
575
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
576
|
+
raw.end(JSON.stringify({ error: 'Session middleware required for 2FA' }));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (session.get(sessionKey))
|
|
581
|
+
{
|
|
582
|
+
log('2FA already verified for session %s', session.id);
|
|
583
|
+
return next();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
log.warn('2FA not completed — blocking request');
|
|
587
|
+
const raw = res.raw || res;
|
|
588
|
+
if (raw.headersSent) return;
|
|
589
|
+
raw.statusCode = statusCode;
|
|
590
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
591
|
+
raw.end(JSON.stringify({ error: errorMessage }));
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Rate-limited TOTP verification middleware.
|
|
597
|
+
* Wraps `verifyTOTP` with attempt tracking to prevent brute-force attacks.
|
|
598
|
+
* Tracks attempts per-IP in memory with automatic expiry.
|
|
599
|
+
*
|
|
600
|
+
* Supports optional replay prevention (RFC 6238 §5.2) via a `replayStore`.
|
|
601
|
+
* When configured, each successfully verified TOTP counter is recorded
|
|
602
|
+
* and rejected on subsequent use within the validity window.
|
|
603
|
+
*
|
|
604
|
+
* @param {object} opts - Options.
|
|
605
|
+
* @param {Function} opts.getSecret - `(req) => string|Buffer|Promise<string|Buffer>`.
|
|
606
|
+
* Retrieves the user's TOTP secret from your database.
|
|
607
|
+
* @param {string} [opts.codeField='code'] - Request body field containing the code.
|
|
608
|
+
* @param {string} [opts.sessionKey='twoFactorVerified'] - Session key to set on success.
|
|
609
|
+
* @param {number} [opts.maxAttempts=5] - Max failed attempts before lockout.
|
|
610
|
+
* @param {number} [opts.lockoutMs=900000] - Lockout duration in ms (default 15 min).
|
|
611
|
+
* @param {number} [opts.window=1] - TOTP verification window.
|
|
612
|
+
* @param {number} [opts.period=30] - TOTP period in seconds.
|
|
613
|
+
* @param {string} [opts.algorithm='sha1'] - HMAC algorithm.
|
|
614
|
+
* @param {number} [opts.digits=6] - Expected code length.
|
|
615
|
+
* @param {object} [opts.replayStore] - Store for TOTP replay prevention.
|
|
616
|
+
* Must implement `get(userId): Promise<number|null>` and
|
|
617
|
+
* `set(userId, counter, ttlMs): Promise<void>`.
|
|
618
|
+
* @param {Function} [opts.getUserId] - `(req) => string|Promise<string>`.
|
|
619
|
+
* Required when `replayStore` is set. Returns a unique user identifier.
|
|
620
|
+
* @param {Function} [opts.onSuccess] - `(req, res) => void` called after verification.
|
|
621
|
+
* @param {Function} [opts.onFailure] - `(req, res, attemptsLeft) => void` called on failure.
|
|
622
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
623
|
+
*
|
|
624
|
+
* @example
|
|
625
|
+
* app.post('/2fa/verify', json(), verifyTOTPMiddleware({
|
|
626
|
+
* getSecret: async (req) => {
|
|
627
|
+
* const user = await db.users.findById(req.user.sub);
|
|
628
|
+
* return user.totpSecret;
|
|
629
|
+
* },
|
|
630
|
+
* }));
|
|
631
|
+
*
|
|
632
|
+
* @example | With replay prevention
|
|
633
|
+
* app.post('/2fa/verify', json(), verifyTOTPMiddleware({
|
|
634
|
+
* getSecret: async (req) => req.user.totpSecret,
|
|
635
|
+
* getUserId: (req) => req.user.id,
|
|
636
|
+
* replayStore: new InMemoryReplayStore(),
|
|
637
|
+
* }));
|
|
638
|
+
*/
|
|
639
|
+
function verifyTOTPMiddleware(opts = {})
|
|
640
|
+
{
|
|
641
|
+
if (typeof opts.getSecret !== 'function')
|
|
642
|
+
throw new Error('verifyTOTPMiddleware requires a getSecret(req) function');
|
|
643
|
+
|
|
644
|
+
if (opts.replayStore && typeof opts.getUserId !== 'function')
|
|
645
|
+
throw new Error('verifyTOTPMiddleware requires getUserId(req) when replayStore is set');
|
|
646
|
+
|
|
647
|
+
const codeField = opts.codeField || 'code';
|
|
648
|
+
const sessionKey = opts.sessionKey || 'twoFactorVerified';
|
|
649
|
+
const maxAttempts = opts.maxAttempts || 5;
|
|
650
|
+
const lockoutMs = opts.lockoutMs || 900000; // 15 min
|
|
651
|
+
const window = opts.window != null ? opts.window : DEFAULT_WINDOW;
|
|
652
|
+
const period = opts.period || DEFAULT_PERIOD;
|
|
653
|
+
const algorithm = opts.algorithm || DEFAULT_ALGORITHM;
|
|
654
|
+
const digits = opts.digits || DEFAULT_DIGITS;
|
|
655
|
+
const onSuccess = opts.onSuccess || null;
|
|
656
|
+
const onFailure = opts.onFailure || null;
|
|
657
|
+
|
|
658
|
+
// In-memory attempt tracker (per-IP)
|
|
659
|
+
const attempts = new Map();
|
|
660
|
+
|
|
661
|
+
// Periodic cleanup of expired entries every 5 minutes
|
|
662
|
+
const pruneTimer = setInterval(() =>
|
|
663
|
+
{
|
|
664
|
+
const now = Date.now();
|
|
665
|
+
for (const [key, entry] of attempts)
|
|
666
|
+
{
|
|
667
|
+
if (now - entry.firstAttempt > lockoutMs) attempts.delete(key);
|
|
668
|
+
}
|
|
669
|
+
}, 300000);
|
|
670
|
+
if (pruneTimer.unref) pruneTimer.unref();
|
|
671
|
+
|
|
672
|
+
return async function _verifyTOTPMiddleware(req, res, next)
|
|
673
|
+
{
|
|
674
|
+
const raw = res.raw || res;
|
|
675
|
+
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
676
|
+
|
|
677
|
+
// Check lockout
|
|
678
|
+
const record = attempts.get(ip);
|
|
679
|
+
if (record && record.count >= maxAttempts)
|
|
680
|
+
{
|
|
681
|
+
const elapsed = Date.now() - record.firstAttempt;
|
|
682
|
+
if (elapsed < lockoutMs)
|
|
683
|
+
{
|
|
684
|
+
const retryAfter = Math.ceil((lockoutMs - elapsed) / 1000);
|
|
685
|
+
log.warn('2FA locked out for IP %s (%d seconds remaining)', ip, retryAfter);
|
|
686
|
+
if (raw.headersSent) return;
|
|
687
|
+
raw.statusCode = 429;
|
|
688
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
689
|
+
raw.setHeader('Retry-After', String(retryAfter));
|
|
690
|
+
raw.end(JSON.stringify({ error: 'Too many attempts. Try again later.', retryAfter }));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
// Lockout expired — reset
|
|
694
|
+
attempts.delete(ip);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Extract code from body
|
|
698
|
+
const code = req.body?.[codeField];
|
|
699
|
+
if (!code || typeof code !== 'string')
|
|
700
|
+
{
|
|
701
|
+
if (raw.headersSent) return;
|
|
702
|
+
raw.statusCode = 400;
|
|
703
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
704
|
+
raw.end(JSON.stringify({ error: `Missing or invalid ${codeField} field` }));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Retrieve user secret
|
|
709
|
+
let secret;
|
|
710
|
+
try
|
|
711
|
+
{
|
|
712
|
+
secret = await opts.getSecret(req);
|
|
713
|
+
}
|
|
714
|
+
catch (err)
|
|
715
|
+
{
|
|
716
|
+
log.error('getSecret error: %s', err.message);
|
|
717
|
+
if (raw.headersSent) return;
|
|
718
|
+
raw.statusCode = 500;
|
|
719
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
720
|
+
raw.end(JSON.stringify({ error: 'Internal server error' }));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (!secret)
|
|
725
|
+
{
|
|
726
|
+
if (raw.headersSent) return;
|
|
727
|
+
raw.statusCode = 400;
|
|
728
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
729
|
+
raw.end(JSON.stringify({ error: '2FA not configured for this account' }));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Verify TOTP
|
|
734
|
+
const result = verifyTOTP(code, secret, { window, period, algorithm, digits });
|
|
735
|
+
|
|
736
|
+
if (result.valid)
|
|
737
|
+
{
|
|
738
|
+
// Replay prevention (RFC 6238 §5.2) — check AFTER signature
|
|
739
|
+
// verification to avoid leaking timing information about whether
|
|
740
|
+
// a code was previously used.
|
|
741
|
+
if (opts.replayStore)
|
|
742
|
+
{
|
|
743
|
+
try
|
|
744
|
+
{
|
|
745
|
+
const userId = await opts.getUserId(req);
|
|
746
|
+
const currentTime = Math.floor(Date.now() / 1000);
|
|
747
|
+
const usedCounter = Math.floor(currentTime / period) + result.delta;
|
|
748
|
+
const lastCounter = await opts.replayStore.get(userId);
|
|
749
|
+
|
|
750
|
+
if (lastCounter !== null && usedCounter <= lastCounter)
|
|
751
|
+
{
|
|
752
|
+
log.warn('TOTP replay detected for user %s (counter=%d, last=%d)', userId, usedCounter, lastCounter);
|
|
753
|
+
|
|
754
|
+
// Track as a failed attempt
|
|
755
|
+
const current = attempts.get(ip) || { count: 0, firstAttempt: Date.now() };
|
|
756
|
+
current.count++;
|
|
757
|
+
attempts.set(ip, current);
|
|
758
|
+
|
|
759
|
+
const remaining = maxAttempts - current.count;
|
|
760
|
+
if (typeof onFailure === 'function') { onFailure(req, res, remaining); return; }
|
|
761
|
+
if (raw.headersSent) return;
|
|
762
|
+
raw.statusCode = 401;
|
|
763
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
764
|
+
raw.end(JSON.stringify({
|
|
765
|
+
error: 'Invalid verification code',
|
|
766
|
+
attemptsRemaining: Math.max(0, remaining),
|
|
767
|
+
}));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Store the used counter with TTL = period * (window + 1)
|
|
772
|
+
const ttl = period * (window + 1) * 1000;
|
|
773
|
+
await opts.replayStore.set(userId, usedCounter, ttl);
|
|
774
|
+
}
|
|
775
|
+
catch (err)
|
|
776
|
+
{
|
|
777
|
+
log.error('replay store error: %s', err.message);
|
|
778
|
+
// Fail open for store errors — don't block legitimate users
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Clear attempt counter on success
|
|
783
|
+
attempts.delete(ip);
|
|
784
|
+
|
|
785
|
+
// Mark session
|
|
786
|
+
if (req.session && typeof req.session.set === 'function')
|
|
787
|
+
{
|
|
788
|
+
req.session.set(sessionKey, true);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
log.info('2FA TOTP verified for IP %s (delta=%d)', ip, result.delta);
|
|
792
|
+
|
|
793
|
+
if (typeof onSuccess === 'function') onSuccess(req, res);
|
|
794
|
+
return next();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Track failed attempt
|
|
798
|
+
const current = attempts.get(ip) || { count: 0, firstAttempt: Date.now() };
|
|
799
|
+
current.count++;
|
|
800
|
+
attempts.set(ip, current);
|
|
801
|
+
|
|
802
|
+
const remaining = maxAttempts - current.count;
|
|
803
|
+
log.warn('2FA TOTP failed for IP %s (%d attempts remaining)', ip, remaining);
|
|
804
|
+
|
|
805
|
+
if (typeof onFailure === 'function')
|
|
806
|
+
{
|
|
807
|
+
onFailure(req, res, remaining);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (raw.headersSent) return;
|
|
812
|
+
raw.statusCode = 401;
|
|
813
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
814
|
+
raw.end(JSON.stringify({
|
|
815
|
+
error: 'Invalid verification code',
|
|
816
|
+
attemptsRemaining: Math.max(0, remaining),
|
|
817
|
+
}));
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// -- Combined 2FA Verification Middleware -------------------------
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Combined verification middleware that auto-detects and handles TOTP codes,
|
|
825
|
+
* backup codes, or WebAuthn/passkey assertions from a single endpoint.
|
|
826
|
+
*
|
|
827
|
+
* Detection logic (based on request body shape):
|
|
828
|
+
* - `{ code: "123456" }` → TOTP verification
|
|
829
|
+
* - `{ backupCode: "a1b2c3d4" }` → Backup code redemption
|
|
830
|
+
* - `{ id, response: { authenticatorData, clientDataJSON, signature } }` → WebAuthn assertion
|
|
831
|
+
*
|
|
832
|
+
* Rate-limits across all methods using a shared per-IP attempt tracker.
|
|
833
|
+
*
|
|
834
|
+
* @param {object} opts - Options.
|
|
835
|
+
* @param {Function} opts.getSecret - `(req) => string|Buffer|Promise<string|Buffer>`. TOTP secret.
|
|
836
|
+
* @param {Function} opts.getBackupHashes - `(req) => string[]|Promise<string[]>`. Stored backup hashes.
|
|
837
|
+
* @param {Function} [opts.onBackupUsed] - `(req, index) => void|Promise<void>`.
|
|
838
|
+
* Called when a backup code is redeemed so the caller can remove it.
|
|
839
|
+
* @param {Function} [opts.getCredential] - `(req, credId) => object|Promise<object>`.
|
|
840
|
+
* Retrieves a stored WebAuthn credential by ID. Return `{ publicKey, counter, ... }`.
|
|
841
|
+
* @param {Function} [opts.updateCredentialCounter] - `(req, credId, newCounter) => void|Promise<void>`.
|
|
842
|
+
* Persist the updated signature counter after WebAuthn verification.
|
|
843
|
+
* @param {string} [opts.expectedOrigin] - Expected origin for WebAuthn (e.g. `'https://myapp.com'`).
|
|
844
|
+
* @param {string} [opts.expectedRPID] - Expected relying party ID for WebAuthn.
|
|
845
|
+
* @param {string} [opts.codeField='code'] - Body field for TOTP code.
|
|
846
|
+
* @param {string} [opts.backupField='backupCode'] - Body field for backup code.
|
|
847
|
+
* @param {string} [opts.sessionKey='twoFactorVerified'] - Session key to set on success.
|
|
848
|
+
* @param {number} [opts.maxAttempts=5] - Max failed attempts before lockout.
|
|
849
|
+
* @param {number} [opts.lockoutMs=900000] - Lockout duration in ms (default 15 min).
|
|
850
|
+
* @param {number} [opts.window=1] - TOTP window.
|
|
851
|
+
* @param {number} [opts.period=30] - TOTP period.
|
|
852
|
+
* @param {string} [opts.algorithm='sha1'] - TOTP HMAC algorithm.
|
|
853
|
+
* @param {number} [opts.digits=6] - TOTP code length.
|
|
854
|
+
* @param {object} [opts.replayStore] - TOTP replay store.
|
|
855
|
+
* @param {Function} [opts.getUserId] - `(req) => string`. Required with replayStore.
|
|
856
|
+
* @param {Function} [opts.onSuccess] - `(req, res, method) => void`. Called on success with the method used.
|
|
857
|
+
* @param {Function} [opts.onFailure] - `(req, res, attemptsLeft) => void`. Called on failure.
|
|
858
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
859
|
+
*
|
|
860
|
+
* @example
|
|
861
|
+
* app.post('/verify-2fa', json(), verify2FA({
|
|
862
|
+
* getSecret: (req) => req.user.totpSecret,
|
|
863
|
+
* getBackupHashes: (req) => req.user.backupHashes,
|
|
864
|
+
* onBackupUsed: async (req, index) => {
|
|
865
|
+
* req.user.backupHashes.splice(index, 1);
|
|
866
|
+
* await req.user.save();
|
|
867
|
+
* },
|
|
868
|
+
* }));
|
|
869
|
+
*/
|
|
870
|
+
function verify2FA(opts = {})
|
|
871
|
+
{
|
|
872
|
+
if (typeof opts.getSecret !== 'function')
|
|
873
|
+
throw new Error('verify2FA requires a getSecret(req) function');
|
|
874
|
+
|
|
875
|
+
if (opts.replayStore && typeof opts.getUserId !== 'function')
|
|
876
|
+
throw new Error('verify2FA requires getUserId(req) when replayStore is set');
|
|
877
|
+
|
|
878
|
+
const codeField = opts.codeField || 'code';
|
|
879
|
+
const backupField = opts.backupField || 'backupCode';
|
|
880
|
+
const sessionKey = opts.sessionKey || 'twoFactorVerified';
|
|
881
|
+
const maxAttempts = opts.maxAttempts || 5;
|
|
882
|
+
const lockoutMs = opts.lockoutMs || 900000;
|
|
883
|
+
const window = opts.window != null ? opts.window : DEFAULT_WINDOW;
|
|
884
|
+
const period = opts.period || DEFAULT_PERIOD;
|
|
885
|
+
const algorithm = opts.algorithm || DEFAULT_ALGORITHM;
|
|
886
|
+
const digits = opts.digits || DEFAULT_DIGITS;
|
|
887
|
+
const onSuccess = opts.onSuccess || null;
|
|
888
|
+
const onFailure = opts.onFailure || null;
|
|
889
|
+
|
|
890
|
+
// Shared per-IP attempt tracker
|
|
891
|
+
const attempts = new Map();
|
|
892
|
+
const pruneTimer = setInterval(() =>
|
|
893
|
+
{
|
|
894
|
+
const now = Date.now();
|
|
895
|
+
for (const [key, entry] of attempts)
|
|
896
|
+
{
|
|
897
|
+
if (now - entry.firstAttempt > lockoutMs) attempts.delete(key);
|
|
898
|
+
}
|
|
899
|
+
}, 300000);
|
|
900
|
+
if (pruneTimer.unref) pruneTimer.unref();
|
|
901
|
+
|
|
902
|
+
function _sendJson(res, status, body)
|
|
903
|
+
{
|
|
904
|
+
const raw = res.raw || res;
|
|
905
|
+
if (raw.headersSent) return;
|
|
906
|
+
raw.statusCode = status;
|
|
907
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
908
|
+
raw.end(JSON.stringify(body));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function _trackFailure(ip, res, onFail, req)
|
|
912
|
+
{
|
|
913
|
+
const current = attempts.get(ip) || { count: 0, firstAttempt: Date.now() };
|
|
914
|
+
current.count++;
|
|
915
|
+
attempts.set(ip, current);
|
|
916
|
+
const remaining = maxAttempts - current.count;
|
|
917
|
+
if (typeof onFail === 'function') { onFail(req, res, remaining); return true; }
|
|
918
|
+
_sendJson(res, 401, { error: 'Invalid verification', attemptsRemaining: Math.max(0, remaining) });
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function _markSession(req)
|
|
923
|
+
{
|
|
924
|
+
if (req.session && typeof req.session.set === 'function')
|
|
925
|
+
{
|
|
926
|
+
req.session.set(sessionKey, true);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return async function _verify2FA(req, res, next)
|
|
931
|
+
{
|
|
932
|
+
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
933
|
+
|
|
934
|
+
// Check lockout
|
|
935
|
+
const record = attempts.get(ip);
|
|
936
|
+
if (record && record.count >= maxAttempts)
|
|
937
|
+
{
|
|
938
|
+
const elapsed = Date.now() - record.firstAttempt;
|
|
939
|
+
if (elapsed < lockoutMs)
|
|
940
|
+
{
|
|
941
|
+
const retryAfter = Math.ceil((lockoutMs - elapsed) / 1000);
|
|
942
|
+
log.warn('verify2FA locked out IP %s (%ds remaining)', ip, retryAfter);
|
|
943
|
+
const raw = res.raw || res;
|
|
944
|
+
if (raw.headersSent) return;
|
|
945
|
+
raw.statusCode = 429;
|
|
946
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
947
|
+
raw.setHeader('Retry-After', String(retryAfter));
|
|
948
|
+
raw.end(JSON.stringify({ error: 'Too many attempts. Try again later.', retryAfter }));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
attempts.delete(ip);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const body = req.body || {};
|
|
955
|
+
|
|
956
|
+
// --- Detect method ---
|
|
957
|
+
|
|
958
|
+
// WebAuthn assertion: { id, response: { authenticatorData, clientDataJSON, signature } }
|
|
959
|
+
if (body.id && body.response && body.response.authenticatorData && body.response.signature)
|
|
960
|
+
{
|
|
961
|
+
if (typeof opts.getCredential !== 'function')
|
|
962
|
+
{
|
|
963
|
+
_sendJson(res, 400, { error: 'WebAuthn not configured' });
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
try
|
|
968
|
+
{
|
|
969
|
+
const credential = await opts.getCredential(req, body.id);
|
|
970
|
+
if (!credential)
|
|
971
|
+
{
|
|
972
|
+
_trackFailure(ip, res, onFailure, req);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const { webauthn } = require('./webauthn');
|
|
977
|
+
const result = await webauthn.verifyAuthentication({
|
|
978
|
+
response: body,
|
|
979
|
+
expectedChallenge: body.challenge || req.session?.get?.('webauthnChallenge'),
|
|
980
|
+
expectedOrigin: opts.expectedOrigin,
|
|
981
|
+
expectedRPID: opts.expectedRPID,
|
|
982
|
+
credential,
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
if (!result.verified)
|
|
986
|
+
{
|
|
987
|
+
_trackFailure(ip, res, onFailure, req);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Update counter
|
|
992
|
+
if (typeof opts.updateCredentialCounter === 'function')
|
|
993
|
+
{
|
|
994
|
+
await opts.updateCredentialCounter(req, body.id, result.authData.signCount);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
attempts.delete(ip);
|
|
998
|
+
_markSession(req);
|
|
999
|
+
log.info('verify2FA: WebAuthn verified for IP %s', ip);
|
|
1000
|
+
if (typeof onSuccess === 'function') onSuccess(req, res, 'webauthn');
|
|
1001
|
+
return next();
|
|
1002
|
+
}
|
|
1003
|
+
catch (err)
|
|
1004
|
+
{
|
|
1005
|
+
log.error('verify2FA WebAuthn error: %s', err.message);
|
|
1006
|
+
_trackFailure(ip, res, onFailure, req);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Backup code: { backupCode: "a1b2c3d4" }
|
|
1012
|
+
if (typeof body[backupField] === 'string' && body[backupField].length > 0)
|
|
1013
|
+
{
|
|
1014
|
+
if (typeof opts.getBackupHashes !== 'function')
|
|
1015
|
+
{
|
|
1016
|
+
_sendJson(res, 400, { error: 'Backup codes not configured' });
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
try
|
|
1021
|
+
{
|
|
1022
|
+
const hashes = await opts.getBackupHashes(req);
|
|
1023
|
+
const result = verifyBackupCode(body[backupField], hashes);
|
|
1024
|
+
|
|
1025
|
+
if (!result.valid)
|
|
1026
|
+
{
|
|
1027
|
+
_trackFailure(ip, res, onFailure, req);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Notify caller to remove used code
|
|
1032
|
+
if (typeof opts.onBackupUsed === 'function')
|
|
1033
|
+
{
|
|
1034
|
+
await opts.onBackupUsed(req, result.index);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
attempts.delete(ip);
|
|
1038
|
+
_markSession(req);
|
|
1039
|
+
log.info('verify2FA: backup code redeemed for IP %s (index=%d)', ip, result.index);
|
|
1040
|
+
if (typeof onSuccess === 'function') onSuccess(req, res, 'backup');
|
|
1041
|
+
return next();
|
|
1042
|
+
}
|
|
1043
|
+
catch (err)
|
|
1044
|
+
{
|
|
1045
|
+
log.error('verify2FA backup code error: %s', err.message);
|
|
1046
|
+
_sendJson(res, 500, { error: 'Internal server error' });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// TOTP code: { code: "123456" }
|
|
1052
|
+
const code = body[codeField];
|
|
1053
|
+
if (!code || typeof code !== 'string')
|
|
1054
|
+
{
|
|
1055
|
+
_sendJson(res, 400, { error: 'Missing verification data. Send code, backupCode, or WebAuthn assertion.' });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
let secret;
|
|
1060
|
+
try
|
|
1061
|
+
{
|
|
1062
|
+
secret = await opts.getSecret(req);
|
|
1063
|
+
}
|
|
1064
|
+
catch (err)
|
|
1065
|
+
{
|
|
1066
|
+
log.error('verify2FA getSecret error: %s', err.message);
|
|
1067
|
+
_sendJson(res, 500, { error: 'Internal server error' });
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (!secret)
|
|
1072
|
+
{
|
|
1073
|
+
_sendJson(res, 400, { error: '2FA not configured for this account' });
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const result = verifyTOTP(code, secret, { window, period, algorithm, digits });
|
|
1078
|
+
|
|
1079
|
+
if (result.valid)
|
|
1080
|
+
{
|
|
1081
|
+
// Replay prevention
|
|
1082
|
+
if (opts.replayStore)
|
|
1083
|
+
{
|
|
1084
|
+
try
|
|
1085
|
+
{
|
|
1086
|
+
const userId = await opts.getUserId(req);
|
|
1087
|
+
const currentTime = Math.floor(Date.now() / 1000);
|
|
1088
|
+
const usedCounter = Math.floor(currentTime / period) + result.delta;
|
|
1089
|
+
const lastCounter = await opts.replayStore.get(userId);
|
|
1090
|
+
|
|
1091
|
+
if (lastCounter !== null && usedCounter <= lastCounter)
|
|
1092
|
+
{
|
|
1093
|
+
log.warn('verify2FA: TOTP replay detected for user %s', userId);
|
|
1094
|
+
_trackFailure(ip, res, onFailure, req);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const ttl = period * (window + 1) * 1000;
|
|
1099
|
+
await opts.replayStore.set(userId, usedCounter, ttl);
|
|
1100
|
+
}
|
|
1101
|
+
catch (err)
|
|
1102
|
+
{
|
|
1103
|
+
log.error('verify2FA replay store error: %s', err.message);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
attempts.delete(ip);
|
|
1108
|
+
_markSession(req);
|
|
1109
|
+
log.info('verify2FA: TOTP verified for IP %s (delta=%d)', ip, result.delta);
|
|
1110
|
+
if (typeof onSuccess === 'function') onSuccess(req, res, 'totp');
|
|
1111
|
+
return next();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
_trackFailure(ip, res, onFailure, req);
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// -- Exports -----------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
module.exports = {
|
|
1121
|
+
// Core TOTP
|
|
1122
|
+
generateSecret,
|
|
1123
|
+
generateTOTP,
|
|
1124
|
+
verifyTOTP,
|
|
1125
|
+
otpauthURI,
|
|
1126
|
+
|
|
1127
|
+
// Backup codes
|
|
1128
|
+
generateBackupCodes,
|
|
1129
|
+
verifyBackupCode,
|
|
1130
|
+
|
|
1131
|
+
// Middleware
|
|
1132
|
+
require2FA,
|
|
1133
|
+
verifyTOTPMiddleware,
|
|
1134
|
+
verify2FA,
|
|
1135
|
+
|
|
1136
|
+
// Replay prevention
|
|
1137
|
+
InMemoryReplayStore,
|
|
1138
|
+
|
|
1139
|
+
// Constants (for advanced usage / testing)
|
|
1140
|
+
DEFAULT_PERIOD,
|
|
1141
|
+
DEFAULT_DIGITS,
|
|
1142
|
+
DEFAULT_ALGORITHM,
|
|
1143
|
+
DEFAULT_WINDOW,
|
|
1144
|
+
SUPPORTED_TOTP_ALGORITHMS,
|
|
1145
|
+
|
|
1146
|
+
// Internal helpers (exported for testing)
|
|
1147
|
+
_base32Encode,
|
|
1148
|
+
_base32Decode,
|
|
1149
|
+
_generateHOTP,
|
|
1150
|
+
};
|