mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
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:
@@ -60,3 +60,37 @@ export function cleanupTestDb(tmpDir: string): void {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a user with MFA enrolled. Returns the raw TOTP secret (for generating
|
||||
* valid codes in tests) and the cleartext backup codes. Callers are expected
|
||||
* to have already called `setupTestDb`.
|
||||
*/
|
||||
export async function seedMfaUser(
|
||||
username: string,
|
||||
password: string,
|
||||
opts: { role?: 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; ssoEnforce?: boolean } = {},
|
||||
): Promise<{ userId: number; secret: string; backupCodes: string[] }> {
|
||||
const bcryptMod = (await import('bcrypt')).default;
|
||||
const { DatabaseService } = await import('../../services/DatabaseService');
|
||||
const { CryptoService } = await import('../../services/CryptoService');
|
||||
const { MfaService } = await import('../../services/MfaService');
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const passwordHash = await bcryptMod.hash(password, 1);
|
||||
const userId = db.addUser({ username, password_hash: passwordHash, role: opts.role ?? 'viewer' });
|
||||
|
||||
const secret = MfaService.generateSecret();
|
||||
const backupCodes = MfaService.generateBackupCodes();
|
||||
const hashes = await MfaService.hashBackupCodes(backupCodes);
|
||||
db.upsertUserMfa(userId, {
|
||||
enabled: true,
|
||||
totp_secret_encrypted: CryptoService.getInstance().encrypt(secret),
|
||||
backup_codes_json: JSON.stringify(hashes),
|
||||
sso_enforce_mfa: opts.ssoEnforce === true,
|
||||
failed_attempts: 0,
|
||||
locked_until: null,
|
||||
});
|
||||
|
||||
return { userId, secret, backupCodes };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Tests for Multi-Factor Authentication (TOTP + backup codes):
|
||||
* - Enrolment flow (start + confirm) and rejection of wrong OTPs
|
||||
* - Login flow: password -> mfa_pending cookie -> /login/mfa -> session cookie
|
||||
* - Replay prevention: same (user, code, window) refused twice
|
||||
* - Backup code single-use semantics and remaining count
|
||||
* - Lockout after repeated failures
|
||||
* - Partial-auth session: mfa_pending token rejected on non-MFA routes
|
||||
* - Admin reset endpoint
|
||||
* - SSO bypass toggle
|
||||
* - CLI reset helper (direct import, no subprocess)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticator } from 'otplib';
|
||||
import { HashAlgorithms } from '@otplib/core';
|
||||
import {
|
||||
setupTestDb,
|
||||
cleanupTestDb,
|
||||
seedMfaUser,
|
||||
TEST_USERNAME,
|
||||
TEST_JWT_SECRET,
|
||||
} from './helpers/setupTestDb';
|
||||
|
||||
// Match the server-side otplib configuration so test-generated OTPs are
|
||||
// accepted by the verify path.
|
||||
authenticator.options = {
|
||||
digits: 6,
|
||||
step: 30,
|
||||
algorithm: HashAlgorithms.SHA1,
|
||||
window: 1,
|
||||
};
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let MfaService: typeof import('../services/MfaService').MfaService;
|
||||
|
||||
function adminToken(): string {
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(TEST_USERNAME)!;
|
||||
return jwt.sign(
|
||||
{ username: TEST_USERNAME, role: 'admin', tv: user.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
}
|
||||
|
||||
function cookieArray(headers: request.Response['headers']): string[] {
|
||||
const raw = headers['set-cookie'] as unknown;
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? (raw as string[]) : [raw as string];
|
||||
}
|
||||
|
||||
function parseCookie(headers: request.Response['headers'], name: string): string | null {
|
||||
for (const c of cookieArray(headers)) {
|
||||
if (c.startsWith(`${name}=`)) {
|
||||
const value = c.split(';')[0].split('=').slice(1).join('=');
|
||||
// express-server `clearCookie` sends an empty value with an expired date
|
||||
return value || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCookie(headers: request.Response['headers'], name: string): string | undefined {
|
||||
return cookieArray(headers).find((c) => c.startsWith(`${name}=`));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ MfaService } = await import('../services/MfaService'));
|
||||
|
||||
// Mock LicenseService to return paid/admiral so the admin routes pass gates
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
// ─── MfaService unit-ish tests ────────────────────────────────────────────────
|
||||
|
||||
describe('MfaService', () => {
|
||||
it('verifyTotp accepts a freshly generated code', () => {
|
||||
const secret = MfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(MfaService.verifyTotp(secret, code)).toBe(true);
|
||||
});
|
||||
|
||||
it('verifyTotp rejects garbage', () => {
|
||||
const secret = MfaService.generateSecret();
|
||||
expect(MfaService.verifyTotp(secret, '000000')).toBe(false);
|
||||
expect(MfaService.verifyTotp(secret, 'abcdef')).toBe(false);
|
||||
expect(MfaService.verifyTotp(secret, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('generateBackupCodes returns 10 uppercase-alnum codes', () => {
|
||||
const codes = MfaService.generateBackupCodes();
|
||||
expect(codes).toHaveLength(10);
|
||||
for (const c of codes) {
|
||||
expect(c).toMatch(/^[A-Z0-9]{10}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('verifyBackupCode matches and returns remaining set with the matched hash removed', async () => {
|
||||
const codes = MfaService.generateBackupCodes();
|
||||
const hashes = await MfaService.hashBackupCodes(codes);
|
||||
const result = await MfaService.verifyBackupCode(hashes, codes[3]);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.remainingHashes).toHaveLength(hashes.length - 1);
|
||||
expect(result.remainingHashes).not.toContain(hashes[3]);
|
||||
});
|
||||
|
||||
it('verifyBackupCode on non-match returns original hashes', async () => {
|
||||
const codes = MfaService.generateBackupCodes();
|
||||
const hashes = await MfaService.hashBackupCodes(codes);
|
||||
const result = await MfaService.verifyBackupCode(hashes, 'NOTACODE99');
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.remainingHashes).toBe(hashes);
|
||||
});
|
||||
|
||||
it('normalizeBackupCode strips spaces/dashes and uppercases', () => {
|
||||
expect(MfaService.normalizeBackupCode('abcde-fghij')).toBe('ABCDEFGHIJ');
|
||||
expect(MfaService.normalizeBackupCode('abcde fghij')).toBe('ABCDEFGHIJ');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Login flow ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/login with MFA-enabled user', () => {
|
||||
const username = 'mfauser-login';
|
||||
const password = 'mfapassword123';
|
||||
|
||||
it('returns mfaRequired and sets the partial-auth cookie only', async () => {
|
||||
await seedMfaUser(username, password);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username, password });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.mfaRequired).toBe(true);
|
||||
|
||||
expect(parseCookie(res.headers, 'sencho_mfa_pending')).toBeTruthy();
|
||||
// No full session cookie yet.
|
||||
expect(parseCookie(res.headers, 'sencho_token')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('/auth/status reports mfaPending=true for a valid pending cookie', async () => {
|
||||
const login = await request(app).post('/api/auth/login').send({ username, password });
|
||||
const pendingCookie = findCookie(login.headers, 'sencho_mfa_pending')!;
|
||||
|
||||
const status = await request(app).get('/api/auth/status').set('Cookie', pendingCookie);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.mfaPending).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MFA verify endpoint ──────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/login/mfa', () => {
|
||||
const username = 'mfauser-verify';
|
||||
const password = 'mfapassword123';
|
||||
let secret = '';
|
||||
let backupCodes: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ secret, backupCodes } = await seedMfaUser(username, password));
|
||||
});
|
||||
|
||||
async function startChallenge() {
|
||||
const res = await request(app).post('/api/auth/login').send({ username, password });
|
||||
return findCookie(res.headers, 'sencho_mfa_pending')!;
|
||||
}
|
||||
|
||||
it('401 when no pending cookie is present', async () => {
|
||||
const res = await request(app).post('/api/auth/login/mfa').send({ code: '123456' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts a valid TOTP, clears pending cookie, issues session', async () => {
|
||||
const pendingCookie = await startChallenge();
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login/mfa')
|
||||
.set('Cookie', pendingCookie)
|
||||
.send({ code });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Session cookie is issued.
|
||||
expect(findCookie(res.headers, 'sencho_token')).toBeDefined();
|
||||
// Pending cookie is cleared (empty value or Expires in the past).
|
||||
const cleared = findCookie(res.headers, 'sencho_mfa_pending');
|
||||
expect(cleared).toBeDefined();
|
||||
expect(cleared!).toMatch(/sencho_mfa_pending=;/);
|
||||
});
|
||||
|
||||
it('rejects a replayed TOTP within the same window', async () => {
|
||||
// Fresh user so previous test state does not pollute the replay table.
|
||||
const u = 'mfauser-replay';
|
||||
const p = 'mfapassword123';
|
||||
const { secret: s } = await seedMfaUser(u, p);
|
||||
|
||||
const login = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
||||
const pending = findCookie(login.headers, 'sencho_mfa_pending')!;
|
||||
const code = authenticator.generate(s);
|
||||
|
||||
const ok = await request(app).post('/api/auth/login/mfa').set('Cookie', pending).send({ code });
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
// Second login, same code, still within this 30s window
|
||||
const login2 = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
||||
const pending2 = findCookie(login2.headers, 'sencho_mfa_pending')!;
|
||||
const replay = await request(app).post('/api/auth/login/mfa').set('Cookie', pending2).send({ code });
|
||||
expect(replay.status).toBe(401);
|
||||
expect(replay.body.code).toBe('OTP_REPLAY');
|
||||
});
|
||||
|
||||
it('rejects an obviously wrong TOTP', async () => {
|
||||
const pending = await startChallenge();
|
||||
const res = await request(app).post('/api/auth/login/mfa').set('Cookie', pending).send({ code: '000000' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts a backup code and invalidates it on a second submission', async () => {
|
||||
const u = 'mfauser-backup';
|
||||
const p = 'mfapassword123';
|
||||
const { backupCodes: codes } = await seedMfaUser(u, p);
|
||||
const chosen = codes[0];
|
||||
|
||||
// First use: ok
|
||||
const login1 = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
||||
const pending1 = findCookie(login1.headers, 'sencho_mfa_pending')!;
|
||||
const first = await request(app)
|
||||
.post('/api/auth/login/mfa')
|
||||
.set('Cookie', pending1)
|
||||
.send({ code: chosen, isBackupCode: true });
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
// Second use of the same code: rejected
|
||||
const login2 = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
||||
const pending2 = findCookie(login2.headers, 'sencho_mfa_pending')!;
|
||||
const second = await request(app)
|
||||
.post('/api/auth/login/mfa')
|
||||
.set('Cookie', pending2)
|
||||
.send({ code: chosen, isBackupCode: true });
|
||||
expect(second.status).toBe(401);
|
||||
|
||||
// Remaining backup count decreased by exactly 1
|
||||
const remaining = backupCodes.length;
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(u)!;
|
||||
const mfa = db.getUserMfa(user.id)!;
|
||||
const hashes = mfa.backup_codes_json ? (JSON.parse(mfa.backup_codes_json) as string[]) : [];
|
||||
expect(hashes.length).toBe(remaining - 1);
|
||||
});
|
||||
|
||||
it('locks the user after MFA_MAX_FAILED (5) wrong codes and returns 423', async () => {
|
||||
const u = 'mfauser-lock';
|
||||
const p = 'mfapassword123';
|
||||
await seedMfaUser(u, p);
|
||||
|
||||
const login = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
||||
const pending = findCookie(login.headers, 'sencho_mfa_pending')!;
|
||||
|
||||
let lastStatus = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = await request(app)
|
||||
.post('/api/auth/login/mfa')
|
||||
.set('Cookie', pending)
|
||||
.send({ code: '000000' });
|
||||
lastStatus = r.status;
|
||||
}
|
||||
expect(lastStatus).toBe(423);
|
||||
|
||||
// Any further attempt still 423
|
||||
const blocked = await request(app)
|
||||
.post('/api/auth/login/mfa')
|
||||
.set('Cookie', pending)
|
||||
.send({ code: '111111' });
|
||||
expect(blocked.status).toBe(423);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Partial-auth session guard ───────────────────────────────────────────────
|
||||
|
||||
describe('authMiddleware partial-auth guard', () => {
|
||||
it('rejects mfa_pending token on a non-MFA route with 403 MFA_PENDING', async () => {
|
||||
const pendingToken = jwt.sign(
|
||||
{ scope: 'mfa_pending', user_id: 42, username: 'whoever' },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
const res = await request(app)
|
||||
.get('/api/stacks')
|
||||
.set('Authorization', `Bearer ${pendingToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('MFA_PENDING');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Enrol / confirm / disable ────────────────────────────────────────────────
|
||||
|
||||
describe('MFA enrol + confirm', () => {
|
||||
it('full enrol -> confirm activates MFA and returns 10 backup codes', async () => {
|
||||
// Create a dedicated user so we do not toggle MFA on the admin.
|
||||
const start = await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'enroller', password: 'enrolpass123', role: 'viewer' });
|
||||
expect(start.status).toBe(201);
|
||||
|
||||
const userId = start.body.id as number;
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername('enroller')!;
|
||||
const userToken = jwt.sign(
|
||||
{ username: 'enroller', role: 'viewer', tv: user.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
|
||||
const startRes = await request(app)
|
||||
.post('/api/auth/mfa/enroll/start')
|
||||
.set('Authorization', `Bearer ${userToken}`);
|
||||
expect(startRes.status).toBe(200);
|
||||
expect(typeof startRes.body.otpauthUri).toBe('string');
|
||||
expect(typeof startRes.body.secret).toBe('string');
|
||||
|
||||
// Reject wrong OTP
|
||||
const wrong = await request(app)
|
||||
.post('/api/auth/mfa/enroll/confirm')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send({ code: '000000' });
|
||||
expect(wrong.status).toBe(401);
|
||||
|
||||
const code = authenticator.generate(startRes.body.secret as string);
|
||||
const confirm = await request(app)
|
||||
.post('/api/auth/mfa/enroll/confirm')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
.send({ code });
|
||||
expect(confirm.status).toBe(200);
|
||||
expect(Array.isArray(confirm.body.backupCodes)).toBe(true);
|
||||
expect(confirm.body.backupCodes).toHaveLength(10);
|
||||
|
||||
const mfa = db.getUserMfa(userId);
|
||||
expect(mfa?.enabled).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects enroll/start when already enrolled', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername('enroller')!;
|
||||
const token = jwt.sign(
|
||||
{ username: 'enroller', role: 'viewer', tv: user.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/enroll/start')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('disable without a valid code returns 401 and MFA stays enabled', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername('enroller')!;
|
||||
const token = jwt.sign(
|
||||
{ username: 'enroller', role: 'viewer', tv: user.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '000000' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(db.getUserMfa(user.id)?.enabled).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Admin reset ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/users/:id/mfa/reset', () => {
|
||||
it('non-admin caller gets 403', async () => {
|
||||
// Create a viewer and seed MFA for someone else
|
||||
const db = DatabaseService.getInstance();
|
||||
const { userId: victimId } = await seedMfaUser('victim', 'victimpass123');
|
||||
await request(app)
|
||||
.post('/api/users')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ username: 'nonadmin', password: 'nonadminpass123', role: 'viewer' });
|
||||
const nonAdmin = db.getUserByUsername('nonadmin')!;
|
||||
const nonAdminToken = jwt.sign(
|
||||
{ username: 'nonadmin', role: 'viewer', tv: nonAdmin.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
const res = await request(app)
|
||||
.post(`/api/users/${victimId}/mfa/reset`)
|
||||
.set('Authorization', `Bearer ${nonAdminToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(db.getUserMfa(victimId)?.enabled).toBe(1);
|
||||
});
|
||||
|
||||
it('admin clears the target MFA and bumps their token_version', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const { userId } = await seedMfaUser('victim2', 'victim2pass123');
|
||||
const before = db.getUser(userId)!.token_version;
|
||||
const res = await request(app)
|
||||
.post(`/api/users/${userId}/mfa/reset`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(db.getUserMfa(userId)).toBeUndefined();
|
||||
expect(db.getUser(userId)!.token_version).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SSO bypass toggle ────────────────────────────────────────────────────────
|
||||
|
||||
describe('PUT /api/auth/mfa/sso-bypass', () => {
|
||||
it('persists the toggle on an enrolled user', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const { userId } = await seedMfaUser('ssouser', 'ssouserpass123');
|
||||
const user = db.getUserById(userId)!;
|
||||
const token = jwt.sign(
|
||||
{ username: user.username, role: user.role, tv: user.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
|
||||
const enable = await request(app)
|
||||
.put('/api/auth/mfa/sso-bypass')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ enforce: true });
|
||||
expect(enable.status).toBe(200);
|
||||
expect(db.getUserMfa(userId)?.sso_enforce_mfa).toBe(1);
|
||||
|
||||
const disable = await request(app)
|
||||
.put('/api/auth/mfa/sso-bypass')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ enforce: false });
|
||||
expect(disable.status).toBe(200);
|
||||
expect(db.getUserMfa(userId)?.sso_enforce_mfa).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CLI reset helper ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('resetMfaForUser CLI helper', () => {
|
||||
it('clears MFA and bumps token_version for the target user', async () => {
|
||||
const { resetMfaForUser } = await import('../cli/resetMfa');
|
||||
const db = DatabaseService.getInstance();
|
||||
const { userId } = await seedMfaUser('cliuser', 'cliuserpass123');
|
||||
const before = db.getUser(userId)!.token_version;
|
||||
|
||||
const result = await resetMfaForUser('cliuser');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(db.getUserMfa(userId)).toBeUndefined();
|
||||
expect(db.getUser(userId)!.token_version).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('returns ok:false for an unknown username', async () => {
|
||||
const { resetMfaForUser } = await import('../cli/resetMfa');
|
||||
const result = await resetMfaForUser('definitely-not-a-user');
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Emergency CLI: clear a user's MFA enrolment from a shell inside the
|
||||
* container, used when the UI is unreachable (e.g. sole admin locked out
|
||||
* after losing their authenticator AND backup codes).
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/resetMfa.js <username>
|
||||
*
|
||||
* The target's active sessions are invalidated by bumping `token_version`,
|
||||
* and the reset is written to the audit log with `actor: 'cli'`.
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
export interface ResetMfaResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import-friendly entry point for tests: resets MFA for `username` and
|
||||
* returns a structured result. The binary `main()` is a thin wrapper
|
||||
* around this.
|
||||
*/
|
||||
export function resetMfaForUser(username: string): ResetMfaResult {
|
||||
if (!username || typeof username !== 'string') {
|
||||
return { ok: false, message: 'Username is required' };
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(username);
|
||||
if (!user) {
|
||||
return { ok: false, message: `User not found: ${username}` };
|
||||
}
|
||||
db.deleteUserMfa(user.id);
|
||||
db.bumpTokenVersion(user.id);
|
||||
try {
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: 'cli',
|
||||
method: 'POST',
|
||||
path: `/cli/reset-mfa/${username}`,
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: 'cli',
|
||||
summary: `CLI reset two-factor authentication for ${username}`,
|
||||
});
|
||||
} catch (err) {
|
||||
// Audit failure should not block the reset itself.
|
||||
console.warn('[reset-mfa] audit log write failed:', (err as Error).message);
|
||||
}
|
||||
return { ok: true, message: `Two-factor authentication cleared for ${username}` };
|
||||
}
|
||||
|
||||
/** Binary entry: parse argv, run, exit with the right status code. */
|
||||
function main(): void {
|
||||
const username = process.argv[2];
|
||||
if (!username) {
|
||||
console.error('Usage: node dist/cli/resetMfa.js <username>');
|
||||
process.exit(2);
|
||||
}
|
||||
const result = resetMfaForUser(username);
|
||||
if (result.ok) {
|
||||
console.log(result.message);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(result.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
+563
-6
@@ -31,6 +31,8 @@ import { NodeRegistry } from './services/NodeRegistry';
|
||||
import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, normalizeTier, normalizeVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService';
|
||||
import { WebhookService } from './services/WebhookService';
|
||||
import { SSOService } from './services/SSOService';
|
||||
import { MfaService } from './services/MfaService';
|
||||
import { CryptoService } from './services/CryptoService';
|
||||
import { SchedulerService } from './services/SchedulerService';
|
||||
import { RegistryService } from './services/RegistryService';
|
||||
import { CacheService } from './services/CacheService';
|
||||
@@ -95,6 +97,9 @@ const PORT = 3000;
|
||||
|
||||
// Cookie settings
|
||||
const COOKIE_NAME = 'sencho_token';
|
||||
const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending';
|
||||
const MFA_PENDING_SCOPE = 'mfa_pending';
|
||||
const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge
|
||||
|
||||
// Helper to determine if request is secure (HTTPS or behind a proxy that terminates SSL)
|
||||
const isSecureRequest = (req: Request): boolean => {
|
||||
@@ -405,6 +410,10 @@ declare global {
|
||||
proxyTier?: LicenseTier;
|
||||
/** License variant asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
|
||||
proxyVariant?: LicenseVariant;
|
||||
/** User ID carried by a scoped `mfa_pending` token. Only set while the user is completing the MFA challenge. */
|
||||
mfaPendingUserId?: number;
|
||||
/** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */
|
||||
mfaPendingSso?: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,7 +445,7 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number };
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean };
|
||||
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session');
|
||||
|
||||
@@ -468,6 +477,23 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
return;
|
||||
}
|
||||
|
||||
// Partial-auth session: a password/SSO credential has verified, but the
|
||||
// TOTP second factor is still required. Such a token can only be used to
|
||||
// complete the MFA challenge or to abort the flow by logging out. Every
|
||||
// other route must reject it so no privileged action is reachable before
|
||||
// the second factor clears.
|
||||
if (decoded.scope === MFA_PENDING_SCOPE) {
|
||||
const allowedPath = req.path === '/api/auth/login/mfa' || req.path === '/api/auth/logout';
|
||||
if (!allowedPath) {
|
||||
res.status(403).json({ error: 'Two-factor authentication required', code: 'MFA_PENDING' });
|
||||
return;
|
||||
}
|
||||
req.mfaPendingUserId = typeof decoded.user_id === 'number' ? decoded.user_id : undefined;
|
||||
req.mfaPendingSso = decoded.sso === true;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Node proxy tokens: Sencho-to-Sencho communication, not user sessions.
|
||||
// Handle before user resolution since proxy tokens have no username.
|
||||
if (decoded.scope === 'node_proxy') {
|
||||
@@ -536,6 +562,36 @@ function issueSessionCookie(
|
||||
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a short-lived `mfa_pending` JWT and set it as an httpOnly cookie. This
|
||||
* represents the partial-auth session that exists between password (or SSO)
|
||||
* success and TOTP verification. The scope is enforced in `authMiddleware`, so
|
||||
* this cookie cannot be used to reach any route other than
|
||||
* `/api/auth/login/mfa` or `/api/auth/logout`.
|
||||
*/
|
||||
function issueMfaPendingCookie(
|
||||
res: Response,
|
||||
req: Request,
|
||||
user: { id: number; username: string },
|
||||
jwtSecret: string,
|
||||
opts: { sso?: boolean } = {},
|
||||
): void {
|
||||
const token = jwt.sign(
|
||||
{ scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true },
|
||||
jwtSecret,
|
||||
{ expiresIn: Math.floor(MFA_PENDING_TTL_MS / 1000) },
|
||||
);
|
||||
res.cookie(MFA_PENDING_COOKIE_NAME, token, {
|
||||
...getCookieOptions(req),
|
||||
maxAge: MFA_PENDING_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the partial-auth cookie. Called on successful MFA verification and on logout. */
|
||||
function clearMfaPendingCookie(res: Response, req: Request): void {
|
||||
res.clearCookie(MFA_PENDING_COOKIE_NAME, getCookieOptions(req));
|
||||
}
|
||||
|
||||
// Rate limiter for auth endpoints - prevents brute-force attacks.
|
||||
// Production: 5 attempts per 15-minute window per IP.
|
||||
// Development: 100 attempts (so E2E tests and local tooling are not blocked).
|
||||
@@ -570,15 +626,30 @@ app.get('/api/meta', (_req: Request, res: Response): void => {
|
||||
|
||||
// Auth Routes (no authentication required)
|
||||
|
||||
// Check if setup is needed
|
||||
// Check if setup is needed, and whether the caller currently holds a valid
|
||||
// `mfa_pending` partial-auth cookie (so the frontend can route to the
|
||||
// challenge screen on a page reload mid-flow, for example after an OIDC
|
||||
// redirect).
|
||||
app.get('/api/auth/status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
|
||||
res.json({ needsSetup });
|
||||
|
||||
let mfaPending = false;
|
||||
const mfaCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
|
||||
if (mfaCookie && settings.auth_jwt_secret) {
|
||||
try {
|
||||
const decoded = jwt.verify(mfaCookie, settings.auth_jwt_secret) as { scope?: string };
|
||||
mfaPending = decoded.scope === MFA_PENDING_SCOPE;
|
||||
} catch {
|
||||
// Expired or invalid cookie; treat as no pending challenge.
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ needsSetup, mfaPending });
|
||||
} catch (error) {
|
||||
console.error('Error checking setup status:', error);
|
||||
res.json({ needsSetup: true });
|
||||
res.json({ needsSetup: true, mfaPending: false });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -654,6 +725,18 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response)
|
||||
const settings = db.getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('JWT secret missing from DB');
|
||||
|
||||
// If MFA is enabled for this user, issue only the partial-auth cookie
|
||||
// and signal the client to complete the TOTP challenge. No session
|
||||
// cookie is set until the second factor is verified.
|
||||
const mfa = db.getUserMfa(user.id);
|
||||
if (mfa?.enabled) {
|
||||
issueMfaPendingCookie(res, req, user, jwtSecret);
|
||||
console.log('[Auth] Login password OK, MFA challenge pending:', user.username);
|
||||
res.json({ success: true, mfaRequired: true });
|
||||
return;
|
||||
}
|
||||
|
||||
issueSessionCookie(res, req, user, jwtSecret);
|
||||
console.log('[Auth] Login successful:', user.username);
|
||||
res.json({ success: true, message: 'Login successful' });
|
||||
@@ -726,6 +809,9 @@ app.post('/api/auth/logout', (req: Request, res: Response): void => {
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'strict',
|
||||
});
|
||||
// Also clear any partial-auth cookie so a user aborting the MFA challenge
|
||||
// is returned to a fully unauthenticated state.
|
||||
clearMfaPendingCookie(res, req);
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
@@ -806,6 +892,17 @@ app.post('/api/auth/sso/ldap', authRateLimiter, async (req: Request, res: Respon
|
||||
|
||||
// Issue JWT (same as local login)
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
|
||||
// If MFA is enabled AND the user has opted into SSO enforcement, route
|
||||
// through the TOTP challenge. Otherwise SSO bypasses MFA (default).
|
||||
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
|
||||
if (mfa?.enabled && mfa.sso_enforce_mfa) {
|
||||
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
|
||||
console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`);
|
||||
res.json({ success: true, mfaRequired: true });
|
||||
return;
|
||||
}
|
||||
|
||||
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
|
||||
console.log(`[SSO] LDAP login successful: ${user.username}`);
|
||||
res.json({ success: true, message: 'Login successful' });
|
||||
@@ -918,6 +1015,18 @@ app.get('/api/auth/sso/oidc/:provider/callback', ssoRateLimiter, async (req: Req
|
||||
|
||||
// Issue JWT + cookie (same as local login)
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
|
||||
// If MFA is enabled AND the user has opted into SSO enforcement, set only
|
||||
// the partial-auth cookie. The frontend surfaces the challenge screen
|
||||
// based on `/api/auth/status` after the redirect lands.
|
||||
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
|
||||
if (mfa?.enabled && mfa.sso_enforce_mfa) {
|
||||
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
|
||||
console.log(`[SSO] OIDC login password OK, MFA challenge pending: ${user.username} via ${provider}`);
|
||||
res.redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
|
||||
console.log(`[SSO] OIDC login successful: ${user.username} via ${provider}`);
|
||||
|
||||
@@ -929,6 +1038,382 @@ app.get('/api/auth/sso/oidc/:provider/callback', ssoRateLimiter, async (req: Req
|
||||
}
|
||||
});
|
||||
|
||||
// --- MFA (TOTP) Routes ---
|
||||
|
||||
const MFA_MAX_FAILED = 5;
|
||||
const MFA_LOCKOUT_MS = 15 * 60 * 1000;
|
||||
const MFA_REPLAY_TTL_MS = 120 * 1000;
|
||||
const MFA_REPLAY_PURGE_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* Complete the second factor of login. Consumes the short-lived
|
||||
* `sencho_mfa_pending` cookie and, on success, clears it and issues a full
|
||||
* session cookie. Accepts either a 6-digit TOTP or one of the user's backup
|
||||
* codes (single-use). Enforces per-user failure counter and lockout.
|
||||
*/
|
||||
app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const settings = db.getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) {
|
||||
res.status(500).json({ error: 'Server is not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
|
||||
if (!pendingCookie) {
|
||||
res.status(401).json({ error: 'No pending two-factor challenge. Please sign in again.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean };
|
||||
try {
|
||||
decoded = jwt.verify(pendingCookie, jwtSecret) as typeof decoded;
|
||||
} catch {
|
||||
clearMfaPendingCookie(res, req);
|
||||
res.status(401).json({ error: 'Two-factor challenge expired. Please sign in again.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decoded.scope !== MFA_PENDING_SCOPE || typeof decoded.user_id !== 'number') {
|
||||
clearMfaPendingCookie(res, req);
|
||||
res.status(401).json({ error: 'Invalid two-factor challenge' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = db.getUserById(decoded.user_id);
|
||||
const mfa = db.getUserMfa(decoded.user_id);
|
||||
if (!user || !mfa?.enabled || !mfa.totp_secret_encrypted) {
|
||||
clearMfaPendingCookie(res, req);
|
||||
res.status(401).json({ error: 'Two-factor authentication is not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Lockout check
|
||||
if (mfa.locked_until && mfa.locked_until > Date.now()) {
|
||||
const retryAfter = Math.ceil((mfa.locked_until - Date.now()) / 1000);
|
||||
res.setHeader('Retry-After', String(retryAfter));
|
||||
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawCode = typeof req.body?.code === 'string' ? req.body.code : '';
|
||||
const isBackup = req.body?.isBackupCode === true;
|
||||
if (!rawCode) {
|
||||
res.status(400).json({ error: 'A verification code is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cryptoSvc = CryptoService.getInstance();
|
||||
const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted);
|
||||
let verified = false;
|
||||
|
||||
if (isBackup) {
|
||||
const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
|
||||
const result = await MfaService.verifyBackupCode(hashes, rawCode);
|
||||
if (result.matched) {
|
||||
db.upsertUserMfa(decoded.user_id, { backup_codes_json: JSON.stringify(result.remainingHashes) });
|
||||
verified = true;
|
||||
}
|
||||
} else {
|
||||
const trimmed = rawCode.trim().replace(/\s+/g, '');
|
||||
if (MfaService.verifyTotp(secret, trimmed)) {
|
||||
const window = MfaService.currentWindow();
|
||||
if (db.isMfaCodeUsed(decoded.user_id, trimmed, window)) {
|
||||
db.recordMfaFailure(decoded.user_id);
|
||||
res.status(401).json({ error: 'This code was already used. Please wait for the next one.', code: 'OTP_REPLAY' });
|
||||
return;
|
||||
}
|
||||
db.markMfaCodeUsed(decoded.user_id, trimmed, window);
|
||||
verified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
const failedCount = db.recordMfaFailure(decoded.user_id);
|
||||
if (failedCount >= MFA_MAX_FAILED) {
|
||||
db.lockMfa(decoded.user_id, Date.now() + MFA_LOCKOUT_MS);
|
||||
res.setHeader('Retry-After', String(Math.ceil(MFA_LOCKOUT_MS / 1000)));
|
||||
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter: Math.ceil(MFA_LOCKOUT_MS / 1000) });
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: 'Invalid verification code' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.clearMfaFailures(decoded.user_id);
|
||||
clearMfaPendingCookie(res, req);
|
||||
issueSessionCookie(res, req, user, jwtSecret);
|
||||
console.log('[Auth] MFA challenge cleared:', user.username);
|
||||
res.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
console.error('[Auth] MFA verification error:', (error as Error).message);
|
||||
res.status(500).json({ error: 'Two-factor verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Report the current user's MFA state. Used by the Account settings UI. */
|
||||
app.get('/api/auth/mfa/status', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const mfa = db.getUserMfa(req.user.userId);
|
||||
const hashes: string[] = mfa?.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
|
||||
res.json({
|
||||
enabled: mfa?.enabled === 1,
|
||||
backupCodesRemaining: hashes.length,
|
||||
sso_enforce_mfa: mfa?.sso_enforce_mfa === 1,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('[MFA] status error:', (error as Error).message);
|
||||
res.status(500).json({ error: 'Failed to load MFA status' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Begin enrolment: generate a fresh TOTP secret, store it encrypted with
|
||||
* `enabled=0`, and return the otpauth URI plus the raw base32 secret so the
|
||||
* frontend can render a QR code and the manual-entry fallback.
|
||||
*/
|
||||
app.post('/api/auth/mfa/enroll/start', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getUserMfa(req.user.userId);
|
||||
if (existing?.enabled) {
|
||||
res.status(409).json({ error: 'Two-factor authentication is already enabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = MfaService.generateSecret();
|
||||
const cryptoSvc = CryptoService.getInstance();
|
||||
db.upsertUserMfa(req.user.userId, {
|
||||
enabled: false,
|
||||
totp_secret_encrypted: cryptoSvc.encrypt(secret),
|
||||
backup_codes_json: null,
|
||||
failed_attempts: 0,
|
||||
locked_until: null,
|
||||
});
|
||||
|
||||
const otpauthUri = MfaService.buildOtpauthUri(secret, req.user.username);
|
||||
res.json({ otpauthUri, secret });
|
||||
} catch (error: unknown) {
|
||||
console.error('[MFA] enroll start error:', (error as Error).message);
|
||||
res.status(500).json({ error: 'Failed to start enrolment' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Finalise enrolment: verify the user's first TOTP against the pending
|
||||
* secret, flip `enabled=1`, generate + hash + return the backup codes ONCE,
|
||||
* and bump `token_version` so any other sessions re-authenticate.
|
||||
*/
|
||||
app.post('/api/auth/mfa/enroll/confirm', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
const code = typeof req.body?.code === 'string' ? req.body.code : '';
|
||||
if (!code) {
|
||||
res.status(400).json({ error: 'A verification code is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const mfa = db.getUserMfa(req.user.userId);
|
||||
if (!mfa?.totp_secret_encrypted) {
|
||||
res.status(400).json({ error: 'No enrolment in progress. Start enrolment first.' });
|
||||
return;
|
||||
}
|
||||
if (mfa.enabled) {
|
||||
res.status(409).json({ error: 'Two-factor authentication is already enabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cryptoSvc = CryptoService.getInstance();
|
||||
const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted);
|
||||
if (!MfaService.verifyTotp(secret, code)) {
|
||||
res.status(401).json({ error: 'Invalid verification code' });
|
||||
return;
|
||||
}
|
||||
|
||||
const backupCodes = MfaService.generateBackupCodes();
|
||||
const hashes = await MfaService.hashBackupCodes(backupCodes);
|
||||
db.upsertUserMfa(req.user.userId, {
|
||||
enabled: true,
|
||||
backup_codes_json: JSON.stringify(hashes),
|
||||
failed_attempts: 0,
|
||||
locked_until: null,
|
||||
});
|
||||
db.bumpTokenVersion(req.user.userId);
|
||||
|
||||
// The token_version bump invalidates the caller's current session cookie.
|
||||
// Since the user has just proven possession of the TOTP secret, re-issue a
|
||||
// session cookie that carries the new token_version so they stay signed in
|
||||
// long enough to see and save the backup codes.
|
||||
const refreshed = db.getUserById(req.user.userId);
|
||||
const settings = db.getGlobalSettings();
|
||||
if (refreshed && settings.auth_jwt_secret) {
|
||||
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
|
||||
}
|
||||
|
||||
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
|
||||
} catch (error: unknown) {
|
||||
console.error('[MFA] enroll confirm error:', (error as Error).message);
|
||||
res.status(500).json({ error: 'Failed to confirm enrolment' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Disable MFA for the current user. Requires a valid TOTP or backup code to
|
||||
* prove possession, so a stolen session cookie alone cannot turn off the
|
||||
* second factor. Bumps `token_version` on success.
|
||||
*/
|
||||
app.post('/api/auth/mfa/disable', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
const code = typeof req.body?.code === 'string' ? req.body.code : '';
|
||||
const isBackup = req.body?.isBackupCode === true;
|
||||
if (!code) {
|
||||
res.status(400).json({ error: 'A verification code is required to disable two-factor authentication' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const mfa = db.getUserMfa(req.user.userId);
|
||||
if (!mfa?.enabled || !mfa.totp_secret_encrypted) {
|
||||
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
if (isBackup) {
|
||||
const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
|
||||
ok = (await MfaService.verifyBackupCode(hashes, code)).matched;
|
||||
} else {
|
||||
const cryptoSvc = CryptoService.getInstance();
|
||||
ok = MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted), code);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
res.status(401).json({ error: 'Invalid verification code' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.deleteUserMfa(req.user.userId);
|
||||
db.bumpTokenVersion(req.user.userId);
|
||||
|
||||
// Re-issue the session cookie so the user stays signed in after the bump.
|
||||
// They just proved possession of a current factor, so granting them the
|
||||
// new token_version is safe and avoids a surprising forced re-login.
|
||||
const refreshed = db.getUserById(req.user.userId);
|
||||
const settings = db.getGlobalSettings();
|
||||
if (refreshed && settings.auth_jwt_secret) {
|
||||
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
console.error('[MFA] disable error:', (error as Error).message);
|
||||
res.status(500).json({ error: 'Failed to disable two-factor authentication' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Regenerate backup codes. Requires a valid TOTP so a stolen session alone
|
||||
* cannot print new codes. The old set is invalidated immediately; the new
|
||||
* set is returned in cleartext ONCE.
|
||||
*/
|
||||
app.post('/api/auth/mfa/backup-codes/regenerate', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
const code = typeof req.body?.code === 'string' ? req.body.code : '';
|
||||
if (!code) {
|
||||
res.status(400).json({ error: 'A verification code is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const mfa = db.getUserMfa(req.user.userId);
|
||||
if (!mfa?.enabled || !mfa.totp_secret_encrypted) {
|
||||
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cryptoSvc = CryptoService.getInstance();
|
||||
if (!MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted), code)) {
|
||||
res.status(401).json({ error: 'Invalid verification code' });
|
||||
return;
|
||||
}
|
||||
|
||||
const backupCodes = MfaService.generateBackupCodes();
|
||||
const hashes = await MfaService.hashBackupCodes(backupCodes);
|
||||
db.upsertUserMfa(req.user.userId, { backup_codes_json: JSON.stringify(hashes) });
|
||||
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
|
||||
} catch (error: unknown) {
|
||||
console.error('[MFA] regenerate backup codes error:', (error as Error).message);
|
||||
res.status(500).json({ error: 'Failed to regenerate backup codes' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Toggle whether SSO logins must also complete the TOTP challenge. */
|
||||
app.put('/api/auth/mfa/sso-bypass', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
const enforce = req.body?.enforce === true;
|
||||
const db = DatabaseService.getInstance();
|
||||
const mfa = db.getUserMfa(req.user.userId);
|
||||
if (!mfa?.enabled) {
|
||||
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
|
||||
return;
|
||||
}
|
||||
if ((mfa.sso_enforce_mfa === 1) !== enforce) {
|
||||
db.upsertUserMfa(req.user.userId, { sso_enforce_mfa: enforce });
|
||||
}
|
||||
res.json({ success: true, sso_enforce_mfa: enforce });
|
||||
} catch (error: unknown) {
|
||||
console.error('[MFA] sso-bypass error:', (error as Error).message);
|
||||
res.status(500).json({ error: 'Failed to update SSO enforcement' });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply authentication middleware to all /api/* routes except /api/auth/*
|
||||
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (req.path.startsWith('/auth/') || /^\/webhooks\/\d+\/trigger$/.test(req.path)) {
|
||||
@@ -2393,8 +2878,14 @@ app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promi
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const users = DatabaseService.getInstance().getUsers();
|
||||
res.json(users);
|
||||
const db = DatabaseService.getInstance();
|
||||
const users = db.getUsers();
|
||||
const mfaUserIds = db.getUsersWithMfaEnabled();
|
||||
const enriched = users.map((u) => ({
|
||||
...u,
|
||||
mfaEnabled: mfaUserIds.has(u.id),
|
||||
}));
|
||||
res.json(enriched);
|
||||
} catch (error) {
|
||||
console.error('[Users] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch users' });
|
||||
@@ -2574,6 +3065,54 @@ app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response)
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin reset: clear a target user's MFA enrolment and force re-auth. Used
|
||||
* when a user has lost their authenticator AND exhausted their backup codes,
|
||||
* and another admin is available. For total lockout (including sole admin),
|
||||
* see the CLI `reset-mfa` command.
|
||||
*/
|
||||
app.post('/api/users/:id/mfa/reset', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
res.status(400).json({ error: 'Invalid user id' });
|
||||
return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const target = db.getUser(id);
|
||||
if (!target) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
db.deleteUserMfa(id);
|
||||
db.bumpTokenVersion(id);
|
||||
try {
|
||||
db.insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: req.user!.username,
|
||||
method: 'POST',
|
||||
path: req.originalUrl,
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: req.ip || 'unknown',
|
||||
summary: `Admin reset two-factor authentication for ${target.username}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[MFA] Admin reset audit log write failed:', (err as Error).message);
|
||||
}
|
||||
console.log('[MFA] Admin reset: target=', target.username, 'by=', req.user!.username);
|
||||
res.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
console.error('[MFA] Admin reset error:', (error as Error).message);
|
||||
res.status(500).json({ error: 'Failed to reset two-factor authentication' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Scoped Role Assignments (Admiral) ---
|
||||
|
||||
app.get('/api/users/:id/roles', authMiddleware, (req: Request, res: Response): void => {
|
||||
@@ -6798,6 +7337,8 @@ if (process.env.NODE_ENV === 'production') {
|
||||
}
|
||||
|
||||
// Start server with migration
|
||||
let mfaReplayPurgeTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
// Run migration before starting server
|
||||
@@ -6833,6 +7374,18 @@ async function startServer() {
|
||||
console.warn('[GitSource] Temp dir sweep failed:', (err as Error).message);
|
||||
});
|
||||
|
||||
// Periodic purge of used-MFA-code rows so the replay blacklist stays
|
||||
// bounded even without verification traffic. The table holds (user, code,
|
||||
// window) tuples for the last ~2 minutes; older rows are safe to drop.
|
||||
mfaReplayPurgeTimer = setInterval(() => {
|
||||
try {
|
||||
DatabaseService.getInstance().purgeOldMfaCodes(Date.now() - MFA_REPLAY_TTL_MS);
|
||||
} catch (err) {
|
||||
console.warn('[MFA] Replay purge failed:', (err as Error).message);
|
||||
}
|
||||
}, MFA_REPLAY_PURGE_INTERVAL_MS);
|
||||
mfaReplayPurgeTimer.unref();
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
@@ -6869,6 +7422,10 @@ const gracefulShutdown = (signal: string) => {
|
||||
try { SchedulerService.getInstance().stop(); } catch (e) {
|
||||
console.warn('[Shutdown] SchedulerService cleanup failed:', (e as Error).message);
|
||||
}
|
||||
if (mfaReplayPurgeTimer) {
|
||||
clearInterval(mfaReplayPurgeTimer);
|
||||
mfaReplayPurgeTimer = null;
|
||||
}
|
||||
try { DatabaseService.getInstance().getDb().close(); } catch (e) {
|
||||
console.warn('[Shutdown] Database close failed:', (e as Error).message);
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user