@zero-server/sdk 0.9.1 → 0.9.3

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.
Files changed (128) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +460 -443
  3. package/index.js +414 -412
  4. package/lib/app.js +1172 -1172
  5. package/lib/auth/authorize.js +399 -399
  6. package/lib/auth/enrollment.js +367 -367
  7. package/lib/auth/index.js +57 -57
  8. package/lib/auth/jwt.js +731 -731
  9. package/lib/auth/oauth.js +362 -362
  10. package/lib/auth/session.js +588 -588
  11. package/lib/auth/trustedDevice.js +409 -409
  12. package/lib/auth/twoFactor.js +1150 -1150
  13. package/lib/auth/webauthn.js +946 -946
  14. package/lib/body/index.js +14 -14
  15. package/lib/body/json.js +109 -109
  16. package/lib/body/multipart.js +440 -440
  17. package/lib/body/raw.js +71 -71
  18. package/lib/body/rawBuffer.js +160 -160
  19. package/lib/body/sendError.js +25 -25
  20. package/lib/body/text.js +75 -75
  21. package/lib/body/typeMatch.js +41 -41
  22. package/lib/body/urlencoded.js +235 -235
  23. package/lib/cli.js +845 -845
  24. package/lib/cluster.js +666 -666
  25. package/lib/debug.js +372 -372
  26. package/lib/env/index.js +465 -465
  27. package/lib/errors.js +683 -683
  28. package/lib/fetch/index.js +256 -256
  29. package/lib/grpc/balancer.js +378 -378
  30. package/lib/grpc/call.js +708 -708
  31. package/lib/grpc/client.js +764 -764
  32. package/lib/grpc/codec.js +1221 -1221
  33. package/lib/grpc/credentials.js +398 -398
  34. package/lib/grpc/frame.js +262 -262
  35. package/lib/grpc/health.js +287 -287
  36. package/lib/grpc/index.js +121 -121
  37. package/lib/grpc/metadata.js +461 -461
  38. package/lib/grpc/proto.js +821 -821
  39. package/lib/grpc/reflection.js +590 -590
  40. package/lib/grpc/server.js +445 -445
  41. package/lib/grpc/status.js +118 -118
  42. package/lib/grpc/watch.js +173 -173
  43. package/lib/http/index.js +10 -10
  44. package/lib/http/request.js +727 -727
  45. package/lib/http/response.js +799 -799
  46. package/lib/lifecycle.js +557 -557
  47. package/lib/middleware/compress.js +230 -230
  48. package/lib/middleware/cookieParser.js +237 -237
  49. package/lib/middleware/cors.js +93 -93
  50. package/lib/middleware/csrf.js +137 -137
  51. package/lib/middleware/errorHandler.js +101 -101
  52. package/lib/middleware/helmet.js +175 -175
  53. package/lib/middleware/index.js +19 -17
  54. package/lib/middleware/logger.js +74 -74
  55. package/lib/middleware/rateLimit.js +88 -88
  56. package/lib/middleware/requestId.js +53 -53
  57. package/lib/middleware/static.js +326 -326
  58. package/lib/middleware/timeout.js +71 -71
  59. package/lib/middleware/validator.js +255 -255
  60. package/lib/observe/health.js +326 -326
  61. package/lib/observe/index.js +50 -50
  62. package/lib/observe/logger.js +359 -359
  63. package/lib/observe/metrics.js +805 -805
  64. package/lib/observe/tracing.js +592 -592
  65. package/lib/orm/adapters/json.js +290 -290
  66. package/lib/orm/adapters/memory.js +764 -764
  67. package/lib/orm/adapters/mongo.js +764 -764
  68. package/lib/orm/adapters/mysql.js +933 -933
  69. package/lib/orm/adapters/postgres.js +1144 -1144
  70. package/lib/orm/adapters/redis.js +1534 -1534
  71. package/lib/orm/adapters/sql-base.js +212 -212
  72. package/lib/orm/adapters/sqlite.js +858 -858
  73. package/lib/orm/audit.js +649 -649
  74. package/lib/orm/cache.js +394 -394
  75. package/lib/orm/geo.js +387 -387
  76. package/lib/orm/index.js +784 -784
  77. package/lib/orm/migrate.js +432 -432
  78. package/lib/orm/model.js +1706 -1706
  79. package/lib/orm/plugin.js +375 -375
  80. package/lib/orm/procedures.js +836 -836
  81. package/lib/orm/profiler.js +233 -233
  82. package/lib/orm/query.js +1772 -1772
  83. package/lib/orm/replicas.js +241 -241
  84. package/lib/orm/schema.js +307 -307
  85. package/lib/orm/search.js +380 -380
  86. package/lib/orm/seed/data/commerce.js +136 -136
  87. package/lib/orm/seed/data/internet.js +111 -111
  88. package/lib/orm/seed/data/locations.js +204 -204
  89. package/lib/orm/seed/data/names.js +338 -338
  90. package/lib/orm/seed/data/person.js +128 -128
  91. package/lib/orm/seed/data/phone.js +211 -211
  92. package/lib/orm/seed/data/words.js +134 -134
  93. package/lib/orm/seed/factory.js +178 -178
  94. package/lib/orm/seed/fake.js +1186 -1186
  95. package/lib/orm/seed/index.js +18 -18
  96. package/lib/orm/seed/rng.js +70 -70
  97. package/lib/orm/seed/seeder.js +124 -124
  98. package/lib/orm/seed/unique.js +68 -68
  99. package/lib/orm/snapshot.js +366 -366
  100. package/lib/orm/tenancy.js +605 -605
  101. package/lib/orm/views.js +350 -350
  102. package/lib/router/index.js +436 -436
  103. package/lib/sse/index.js +8 -8
  104. package/lib/sse/stream.js +349 -349
  105. package/lib/ws/connection.js +451 -451
  106. package/lib/ws/handshake.js +125 -125
  107. package/lib/ws/index.js +14 -14
  108. package/lib/ws/room.js +223 -223
  109. package/package.json +73 -73
  110. package/types/app.d.ts +223 -223
  111. package/types/auth.d.ts +520 -520
  112. package/types/body.d.ts +14 -0
  113. package/types/cli.d.ts +2 -0
  114. package/types/cluster.d.ts +75 -75
  115. package/types/env.d.ts +80 -80
  116. package/types/errors.d.ts +316 -316
  117. package/types/fetch.d.ts +43 -43
  118. package/types/grpc.d.ts +432 -432
  119. package/types/index.d.ts +384 -384
  120. package/types/lifecycle.d.ts +60 -60
  121. package/types/middleware.d.ts +320 -320
  122. package/types/observe.d.ts +304 -304
  123. package/types/orm.d.ts +1887 -1887
  124. package/types/request.d.ts +109 -109
  125. package/types/response.d.ts +157 -157
  126. package/types/router.d.ts +78 -78
  127. package/types/sse.d.ts +78 -78
  128. package/types/websocket.d.ts +126 -126
@@ -1,1150 +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
- };
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
+ };