feat(auth): add TOTP two-factor authentication with backup codes (#615)

* feat(auth): add TOTP two-factor authentication with backup codes

Adds RFC 6238 time-based one-time password support to every tier,
integrated with the existing password and SSO login paths.

Backend:
- New MfaService wrapping otplib with a plus or minus 1 step tolerance,
  base32 secret generation, and hashed single-use backup codes (bcrypt).
- user_mfa and mfa_used_tokens tables in DatabaseService. The second
  table is a DB-backed replay blacklist, purged on a 60s interval.
- authMiddleware now recognizes an mfa_pending scope. A token carrying
  that scope is rejected on every route except the MFA challenge and
  logout, so no API surface is reachable before the second factor
  clears.
- /api/auth/login issues only a short-lived mfa_pending cookie when the
  user has MFA enrolled. /api/auth/login/mfa consumes that cookie,
  verifies the code (or backup code), and swaps in a real session.
- /api/auth/mfa/* routes for status, enrol/start, enrol/confirm,
  disable, backup-code regenerate, and SSO-bypass opt-in.
- Admin recovery path: POST /api/users/:id/mfa/reset clears the target's
  MFA state, bumps token_version, and writes an audit log entry.
- CLI emergency fallback: backend/src/cli/resetMfa.ts is wired via
  `npm run reset-mfa <username>` and also exported for tests.
- SSO flows (LDAP and OIDC) gate on user_mfa.sso_enforce_mfa before
  issuing a session; default behaviour keeps the SSO path frictionless.
- Per-user lockout after 5 consecutive failed codes (15 min).

Frontend:
- AppStatus gains an mfa-challenge branch driven by /api/auth/status.
- New MfaChallenge screen, MfaEnrollDialog (QR plus manual secret plus
  backup codes), MfaDisableDialog, MfaBackupCodesDialog.
- Account section shows a Two-factor authentication card with enrol,
  regenerate, disable, and the SSO-enforce toggle (shown only when SSO
  providers are configured).
- Users section gains a Reset 2FA action for admins.

Docs:
- New user guide at features/two-factor-authentication.mdx.
- New admin guide at operations/two-factor-admin.mdx.
- SSO page cross-links to the 2FA doc.

* fix(mfa): drop unused TEST_PASSWORD import and stale eslint disable

* fix(mfa): simplify e2e openAccountSettings helper to match working pattern

* fix(mfa): make e2e suite self-contained and always clean up

Test #2 called loginAs() before the MFA challenge step, which waited for
the dashboard indicator that never appears once the previous test enrolled
the user. That timeout skipped the rest of the serial block, including
the disable step, leaving MFA enabled and breaking every later spec.

Two fixes:

- Tests #2 and #3 now navigate directly to the login page instead of
  piggybacking on loginAs, which only handles the password-only path.
- A new afterAll hook unconditionally disables MFA via the API using two
  unused backup codes, so the DB is reset even if a test fails midway.

* fix(e2e): use backup code for mfa recovery to avoid totp replay race

The final recovery step in the backup-code replay test previously
generated a fresh TOTP to sign back in. When the timing landed inside
the same 30-second window that test #2 consumed, the server's replay
blacklist correctly rejected it, producing a ~50% flake rate. Backup
codes are single-use and sidestep the replay window, so the recovery
becomes deterministic.

* fix(e2e): drive mfa disable test through the challenge screen

Test #4 called loginAs after test #3 left MFA enabled, but loginAs
waits for the dashboard indicator and does not handle the challenge
screen, so it timed out. Drive the login manually, satisfy the
challenge with a backup code, and use a backup code for the disable
step too to avoid any TOTP replay-window race against earlier tests
in the serial block.
This commit is contained in:
Anso
2026-04-15 18:45:51 -04:00
committed by GitHub
parent 87263ff357
commit 7d78c9fe22
32 changed files with 2904 additions and 17 deletions
+152
View File
@@ -112,6 +112,27 @@ export interface User {
updated_at: number;
}
export interface UserMfa {
user_id: number;
enabled: number;
totp_secret_encrypted: string | null;
backup_codes_json: string | null;
sso_enforce_mfa: number;
failed_attempts: number;
locked_until: number | null;
created_at: number;
updated_at: number;
}
export type UserMfaUpdate = Partial<{
enabled: boolean;
totp_secret_encrypted: string | null;
backup_codes_json: string | null;
sso_enforce_mfa: boolean;
failed_attempts: number;
locked_until: number | null;
}>;
export interface RoleAssignment {
id: number;
user_id: number;
@@ -487,6 +508,28 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_label_assignments_stack
ON stack_label_assignments(stack_name, node_id);
CREATE TABLE IF NOT EXISTS user_mfa (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 0,
totp_secret_encrypted TEXT,
backup_codes_json TEXT,
sso_enforce_mfa INTEGER NOT NULL DEFAULT 0,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS mfa_used_tokens (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code TEXT NOT NULL,
window INTEGER NOT NULL,
used_at INTEGER NOT NULL,
PRIMARY KEY (user_id, code, window)
);
CREATE INDEX IF NOT EXISTS idx_mfa_used_tokens_used_at ON mfa_used_tokens(used_at);
CREATE TABLE IF NOT EXISTS stack_git_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stack_name TEXT NOT NULL UNIQUE,
@@ -1250,6 +1293,115 @@ export class DatabaseService {
this.db.prepare('UPDATE users SET token_version = token_version + 1, updated_at = ? WHERE id = ?').run(Date.now(), userId);
}
// --- User MFA ---
public getUserMfa(userId: number): UserMfa | undefined {
return this.db.prepare('SELECT * FROM user_mfa WHERE user_id = ?').get(userId) as UserMfa | undefined;
}
/**
* Create or merge a user_mfa row. Any field left undefined on the update
* object is preserved. Boolean flags are normalized to 0/1.
*/
public upsertUserMfa(userId: number, updates: UserMfaUpdate): void {
const now = Date.now();
const existing = this.getUserMfa(userId);
if (!existing) {
this.db.prepare(
`INSERT INTO user_mfa
(user_id, enabled, totp_secret_encrypted, backup_codes_json, sso_enforce_mfa,
failed_attempts, locked_until, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
userId,
updates.enabled ? 1 : 0,
updates.totp_secret_encrypted ?? null,
updates.backup_codes_json ?? null,
updates.sso_enforce_mfa ? 1 : 0,
updates.failed_attempts ?? 0,
updates.locked_until ?? null,
now,
now,
);
return;
}
const fields: string[] = [];
const values: (string | number | null)[] = [];
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (updates.totp_secret_encrypted !== undefined) { fields.push('totp_secret_encrypted = ?'); values.push(updates.totp_secret_encrypted); }
if (updates.backup_codes_json !== undefined) { fields.push('backup_codes_json = ?'); values.push(updates.backup_codes_json); }
if (updates.sso_enforce_mfa !== undefined) { fields.push('sso_enforce_mfa = ?'); values.push(updates.sso_enforce_mfa ? 1 : 0); }
if (updates.failed_attempts !== undefined) { fields.push('failed_attempts = ?'); values.push(updates.failed_attempts); }
if (updates.locked_until !== undefined) { fields.push('locked_until = ?'); values.push(updates.locked_until); }
if (fields.length === 0) return;
fields.push('updated_at = ?');
values.push(now);
values.push(userId);
this.db.prepare(`UPDATE user_mfa SET ${fields.join(', ')} WHERE user_id = ?`).run(...values);
}
public deleteUserMfa(userId: number): void {
this.db.prepare('DELETE FROM user_mfa WHERE user_id = ?').run(userId);
this.db.prepare('DELETE FROM mfa_used_tokens WHERE user_id = ?').run(userId);
}
/**
* Single-query helper to enrich a user list with MFA status without the
* N+1 cost of calling getUserMfa() per row.
*/
public getUsersWithMfaEnabled(): Set<number> {
const rows = this.db.prepare('SELECT user_id FROM user_mfa WHERE enabled = 1').all() as { user_id: number }[];
return new Set(rows.map((r) => r.user_id));
}
public recordMfaFailure(userId: number): number {
const row = this.db.prepare(
`UPDATE user_mfa
SET failed_attempts = failed_attempts + 1,
updated_at = ?
WHERE user_id = ?
RETURNING failed_attempts`
).get(Date.now(), userId) as { failed_attempts: number } | undefined;
return row?.failed_attempts ?? 0;
}
public clearMfaFailures(userId: number): void {
this.db.prepare(
`UPDATE user_mfa
SET failed_attempts = 0,
locked_until = NULL,
updated_at = ?
WHERE user_id = ?`
).run(Date.now(), userId);
}
public lockMfa(userId: number, untilMs: number): void {
this.db.prepare(
`UPDATE user_mfa SET locked_until = ?, updated_at = ? WHERE user_id = ?`
).run(untilMs, Date.now(), userId);
}
public isMfaCodeUsed(userId: number, code: string, window: number): boolean {
const row = this.db.prepare(
'SELECT 1 FROM mfa_used_tokens WHERE user_id = ? AND code = ? AND window = ?'
).get(userId, code, window);
return !!row;
}
public markMfaCodeUsed(userId: number, code: string, window: number): void {
this.db.prepare(
'INSERT OR IGNORE INTO mfa_used_tokens (user_id, code, window, used_at) VALUES (?, ?, ?, ?)'
).run(userId, code, window, Date.now());
}
public purgeOldMfaCodes(olderThanMs: number): void {
this.db.prepare('DELETE FROM mfa_used_tokens WHERE used_at < ?').run(olderThanMs);
}
// --- Role Assignments ---
public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] {
+140
View File
@@ -0,0 +1,140 @@
import crypto from 'crypto';
import bcrypt from 'bcrypt';
import { authenticator } from 'otplib';
import { HashAlgorithms } from '@otplib/core';
// Configure otplib for the default TOTP contract we present to users:
// - 6 digits
// - 30-second step
// - SHA-1 (the universally supported default for authenticator apps)
// - ±1 step tolerance, so the server accepts the previous, current, and next code
// to cover small clock drift between the device and the server.
authenticator.options = {
digits: 6,
step: 30,
algorithm: HashAlgorithms.SHA1,
window: 1,
};
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 {
/**
* Generate a fresh base32 TOTP secret ready for `buildOtpauthUri` and
* `verifyTotp`. Each user should receive a unique secret.
*/
public static generateSecret(): string {
return authenticator.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 authenticator.keyuri(username, issuer, secret);
}
/**
* 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 authenticator.check(trimmed, secret);
} 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;
}
}