Files
sencho/backend/src/services/MfaService.ts
T
dependabot[bot] 71d164cf9e chore(deps): bump the all-npm-backend group across 1 directory with 10 updates (#783)
* chore(deps): bump the all-npm-backend group across 1 directory with 10 updates

Bumps the all-npm-backend group with 10 updates in the /backend directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1028.0` | `3.1037.0` |
| [axios](https://github.com/axios/axios) | `1.15.0` | `1.15.2` |
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.8.0` | `12.9.0` |
| [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | `8.3.2` | `8.4.1` |
| [openid-client](https://github.com/panva/openid-client) | `6.8.2` | `6.8.3` |
| [otplib](https://github.com/yeojz/otplib/tree/HEAD/packages/otplib) | `12.0.1` | `13.4.0` |
| [eslint](https://github.com/eslint/eslint) | `10.2.0` | `10.2.1` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.2` | `6.0.3` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.58.1` | `8.59.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.4` | `4.1.5` |



Updates `@aws-sdk/client-ecr` from 3.1028.0 to 3.1037.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-ecr)

Updates `axios` from 1.15.0 to 1.15.2
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2)

Updates `better-sqlite3` from 12.8.0 to 12.9.0
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.8.0...v12.9.0)

Updates `express-rate-limit` from 8.3.2 to 8.4.1
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.3.2...v8.4.1)

Updates `openid-client` from 6.8.2 to 6.8.3
- [Release notes](https://github.com/panva/openid-client/releases)
- [Changelog](https://github.com/panva/openid-client/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/openid-client/compare/v6.8.2...v6.8.3)

Updates `otplib` from 12.0.1 to 13.4.0
- [Release notes](https://github.com/yeojz/otplib/releases)
- [Commits](https://github.com/yeojz/otplib/commits/v13.4.0/packages/otplib)

Updates `eslint` from 10.2.0 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

Updates `typescript` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

Updates `typescript-eslint` from 8.58.1 to 8.59.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/typescript-eslint)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1037.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: better-sqlite3
  dependency-version: 12.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: express-rate-limit
  dependency-version: 8.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: openid-client
  dependency-version: 6.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: otplib
  dependency-version: 13.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all-npm-backend
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(mfa): migrate otplib API to v13

The v13 release removed the singleton authenticator export and renamed
HashAlgorithms to a string union type. Switch to the OTP class with
generateSync/verifySync for synchronous operation, passing per-call
options instead of setting global instance state.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: SaelixCode <dev@saelix.com>
2026-04-26 16:24:03 -04:00

170 lines
6.4 KiB
TypeScript

import crypto from 'crypto';
import bcrypt from 'bcrypt';
import { OTP } from 'otplib';
import { DatabaseService } from './DatabaseService';
import { MFA_REPLAY_TTL_MS, MFA_REPLAY_PURGE_INTERVAL_MS } from '../helpers/constants';
import { isDebugEnabled } from '../utils/debug';
// TOTP configuration: 6 digits, 30-second step, SHA-1, ±1 step tolerance.
// SHA-1 is the universally supported default for authenticator apps (RFC 6238).
const totp = new OTP({ strategy: 'totp' });
const TOTP_PARAMS = { algorithm: 'sha1' as const, digits: 6, period: 30 };
const BACKUP_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Crockford-like, no 0/O/1/I/L
const BACKUP_CODE_LENGTH = 10;
const BACKUP_CODE_COUNT = 10;
const BACKUP_HASH_COST = 10;
export interface BackupVerifyResult {
matched: boolean;
remainingHashes: string[];
}
export class MfaService {
private static instance: MfaService;
private purgeTimer: NodeJS.Timeout | null = null;
public static getInstance(): MfaService {
if (!MfaService.instance) MfaService.instance = new MfaService();
return MfaService.instance;
}
/**
* Start the periodic purge of used-MFA-code rows. The replay blacklist
* holds (user, code, window) tuples for the last ~2 minutes; older rows
* are safe to drop. Idempotent: calling start() twice is a no-op.
*/
public start(): void {
if (this.purgeTimer) return;
this.purgeTimer = setInterval(() => {
try {
const deleted = DatabaseService.getInstance().purgeOldMfaCodes(Date.now() - MFA_REPLAY_TTL_MS);
if (isDebugEnabled() && deleted > 0) {
console.log('[MFA:diag] replay purge deleted=', deleted);
}
} catch (err) {
console.warn('[MFA] Replay purge failed:', (err as Error).message);
}
}, MFA_REPLAY_PURGE_INTERVAL_MS);
this.purgeTimer.unref();
}
public stop(): void {
if (this.purgeTimer) {
clearInterval(this.purgeTimer);
this.purgeTimer = null;
}
}
/**
* Generate a fresh base32 TOTP secret ready for `buildOtpauthUri` and
* `verifyTotp`. Each user should receive a unique secret.
*/
public static generateSecret(): string {
return totp.generateSecret();
}
/**
* Build an `otpauth://` URI for QR-code rendering or manual entry. The
* label follows the RFC 6238 format `Issuer:account` so the authenticator
* app can label the entry clearly.
*/
public static buildOtpauthUri(secret: string, username: string, issuer = 'Sencho'): string {
return totp.generateURI({ issuer, label: username, secret, ...TOTP_PARAMS });
}
/**
* Verify a TOTP code against the stored secret. Uses the window tolerance
* configured above, so a code is accepted if it matches the previous,
* current, or next 30-second step.
*/
public static verifyTotp(secret: string, code: string): boolean {
if (!secret || !code) return false;
const trimmed = code.trim().replace(/\s+/g, '');
if (!/^\d{6}$/.test(trimmed)) return false;
try {
return totp.verifySync({ secret, token: trimmed, ...TOTP_PARAMS, epochTolerance: 30 }).valid;
} catch {
return false;
}
}
/**
* Return the integer Unix step for the current time. Used to key the
* replay-prevention blacklist so a given (user, code, window) combination
* can only be used once.
*/
public static currentWindow(nowMs: number = Date.now()): number {
return Math.floor(nowMs / 1000 / 30);
}
/**
* Generate a fresh set of backup codes in cleartext. Callers should pass
* these through `hashBackupCodes` before persistence and show the
* cleartext to the user exactly once.
*/
public static generateBackupCodes(count: number = BACKUP_CODE_COUNT): string[] {
const codes: string[] = [];
for (let i = 0; i < count; i++) {
codes.push(this.randomBackupCode());
}
return codes;
}
/**
* Hash each backup code with bcrypt so the stored form cannot be replayed
* even if the database is leaked.
*/
public static async hashBackupCodes(codes: string[]): Promise<string[]> {
return Promise.all(codes.map((code) => bcrypt.hash(this.normalizeBackupCode(code), BACKUP_HASH_COST)));
}
/**
* Check a user-supplied backup code against the stored hashes. Returns
* `{ matched, remainingHashes }`; when matched, the matched hash is
* removed so callers can persist the shrunk set and enforce single-use
* semantics.
*/
public static async verifyBackupCode(hashes: string[], code: string): Promise<BackupVerifyResult> {
const normalized = this.normalizeBackupCode(code);
if (!normalized) return { matched: false, remainingHashes: hashes };
for (let i = 0; i < hashes.length; i++) {
// bcrypt.compare is constant-time for a given hash. We still check
// every hash regardless of an early hit to avoid leaking which
// slot matched via timing.
const ok = await bcrypt.compare(normalized, hashes[i]);
if (ok) {
const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1));
return { matched: true, remainingHashes: remaining };
}
}
return { matched: false, remainingHashes: hashes };
}
/**
* Display helper: group a 10-character backup code as `ABCDE-FGHIJ` so
* it is easier for the user to read and transcribe.
*/
public static formatBackupCodeForDisplay(code: string): string {
const normalized = this.normalizeBackupCode(code);
if (normalized.length !== BACKUP_CODE_LENGTH) return normalized;
return `${normalized.slice(0, 5)}-${normalized.slice(5)}`;
}
/** Uppercase, strip non-alphanumeric separators (e.g. dashes, spaces). */
public static normalizeBackupCode(code: string): string {
if (!code) return '';
return code.toUpperCase().replace(/[^A-Z0-9]/g, '');
}
private static randomBackupCode(): string {
const bytes = crypto.randomBytes(BACKUP_CODE_LENGTH);
let out = '';
for (let i = 0; i < BACKUP_CODE_LENGTH; i++) {
out += BACKUP_CODE_ALPHABET[bytes[i] % BACKUP_CODE_ALPHABET.length];
}
return out;
}
}