mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
71d164cf9e
* 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>
170 lines
6.4 KiB
TypeScript
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;
|
|
}
|
|
}
|