mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
b329916a0c
Second slice of Phase 4. Pulls the three auth-family route groups out of
index.ts into focused routers. All handlers move verbatim; index.ts drops
~845 lines.
New route files:
- routes/auth.ts: /api/auth core (status, setup, login, password, logout,
check, generate-node-token)
- routes/mfa.ts: /api/auth/login/mfa + full /api/auth/mfa/* surface
(status, enroll start/confirm, disable, backup-codes/regenerate,
sso-bypass)
- routes/sso.ts: /api/auth/sso/{providers,ldap,oidc/:provider/authorize,
oidc/:provider/callback} + getSSOBaseUrl helper. Module-load calls
SSOService.getInstance().seedFromEnv() so env-seeded providers are
available on the first request.
Shared lifts:
- helpers/constants.ts: MFA_REPLAY_TTL_MS + MFA_REPLAY_PURGE_INTERVAL_MS
(used by mfa.ts and the startup purge timer in index.ts) and
BCRYPT_SALT_ROUNDS (shared between setup and password-change handlers).
- middleware/auth.ts: new reissueSessionAfterTokenBump(req, res, userId)
helper collapses three copies of "bump → fetch user → re-sign cookie"
across auth.ts (password change) and mfa.ts (enrol confirm, disable).
Code review fixes:
- File-local requireEnrolledMfaUser helper in mfa.ts eliminates four
copies of "auth check + rejectApiTokenScope + load enrolled MFA" with
near-identical shape.
- Applied BCRYPT_SALT_ROUNDS to auth.ts setup + password handlers.
Mount order in index.ts: authRouter / mfaRouter / ssoRouter sit before
authGate because login / setup / SSO-callback are public; handlers that
need auth use authMiddleware directly on the route.
406 lines
17 KiB
TypeScript
406 lines
17 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import jwt from 'jsonwebtoken';
|
|
import { DatabaseService, type UserMfa } from '../services/DatabaseService';
|
|
import { MfaService } from '../services/MfaService';
|
|
import { CryptoService } from '../services/CryptoService';
|
|
import {
|
|
authMiddleware,
|
|
issueSessionCookie,
|
|
clearMfaPendingCookie,
|
|
reissueSessionAfterTokenBump,
|
|
} from '../middleware/auth';
|
|
import { authRateLimiter } from '../middleware/rateLimiters';
|
|
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
|
import {
|
|
MFA_PENDING_COOKIE_NAME,
|
|
MFA_PENDING_SCOPE,
|
|
} from '../helpers/constants';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
|
|
// Lockout: MFA_MAX_FAILED bad verifications in a row lock the account for
|
|
// MFA_LOCKOUT_MS. Used only in the login/mfa handler; other endpoints
|
|
// require a fresh TOTP/backup code but don't count toward the lockout.
|
|
const MFA_MAX_FAILED = 5;
|
|
const MFA_LOCKOUT_MS = 15 * 60 * 1000;
|
|
|
|
const MFA_SCOPE_MESSAGE = 'API tokens cannot manage MFA.';
|
|
|
|
/**
|
|
* Gate an MFA management endpoint: require an authenticated user session
|
|
* (not an API token) with MFA already enabled + a stored TOTP secret.
|
|
* Writes the appropriate error response and returns null on any failure;
|
|
* callers should early-return when null is returned.
|
|
*/
|
|
function requireEnrolledMfaUser(req: Request, res: Response): { userId: number; mfa: UserMfa } | null {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'Authentication required' });
|
|
return null;
|
|
}
|
|
if (rejectApiTokenScope(req, res, MFA_SCOPE_MESSAGE)) return null;
|
|
const mfa = DatabaseService.getInstance().getUserMfa(req.user.userId);
|
|
if (!mfa?.enabled || !mfa.totp_secret_encrypted) {
|
|
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
|
|
return null;
|
|
}
|
|
return { userId: req.user.userId, mfa };
|
|
}
|
|
|
|
export const mfaRouter = Router();
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
mfaRouter.post('/login/mfa', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
|
const startedAt = Date.now();
|
|
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) {
|
|
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: no pending cookie');
|
|
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);
|
|
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: pending cookie expired or invalid');
|
|
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);
|
|
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: bad cookie scope=', decoded.scope, 'userId=', decoded.user_id);
|
|
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);
|
|
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: mfa not configured for userId=', decoded.user_id);
|
|
res.status(401).json({ error: 'Two-factor authentication is not configured' });
|
|
return;
|
|
}
|
|
|
|
if (isDebugEnabled()) {
|
|
console.log('[MFA:diag] login/mfa: entry user=', user.username, 'sso=', !!decoded.sso, 'failedAttempts=', mfa.failed_attempts, 'lockedUntil=', mfa.locked_until ?? null, 'lockedRemainingMs=', mfa.locked_until ? Math.max(0, mfa.locked_until - Date.now()) : 0);
|
|
}
|
|
|
|
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));
|
|
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: rejected (locked) user=', user.username, 'retryAfter=', 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 bcryptStart = Date.now();
|
|
const result = await MfaService.verifyBackupCode(hashes, rawCode);
|
|
const bcryptMs = Date.now() - bcryptStart;
|
|
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=backup user=', user.username, 'matched=', result.matched, 'bcryptMs=', bcryptMs, 'hashesChecked=', hashes.length);
|
|
if (bcryptMs > 500) console.warn('[MFA] Slow backup-code verify for user=', user.username, 'durationMs=', bcryptMs);
|
|
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, '');
|
|
const totpOk = MfaService.verifyTotp(secret, trimmed);
|
|
const window = MfaService.currentWindow();
|
|
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=totp user=', user.username, 'formatOk=', /^\d{6}$/.test(trimmed), 'totpOk=', totpOk, 'window=', window);
|
|
if (totpOk) {
|
|
if (db.isMfaCodeUsed(decoded.user_id, trimmed, window)) {
|
|
db.recordMfaFailure(decoded.user_id);
|
|
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: replay rejected user=', user.username, 'window=', window);
|
|
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 (isDebugEnabled()) console.log('[MFA:diag] login/mfa: verify failed user=', user.username, 'failedCount=', failedCount, 'lockoutThreshold=', MFA_MAX_FAILED);
|
|
if (failedCount >= MFA_MAX_FAILED) {
|
|
const lockedUntil = Date.now() + MFA_LOCKOUT_MS;
|
|
db.lockMfa(decoded.user_id, lockedUntil);
|
|
res.setHeader('Retry-After', String(Math.ceil(MFA_LOCKOUT_MS / 1000)));
|
|
console.warn('[MFA] Lockout engaged: user=', user.username, 'lockedUntil=', new Date(lockedUntil).toISOString());
|
|
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);
|
|
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: success user=', user.username, 'durationMs=', Date.now() - startedAt);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('[Auth] MFA verification error:', getErrorMessage(error, 'unknown'));
|
|
res.status(500).json({ error: 'Two-factor verification failed' });
|
|
}
|
|
});
|
|
|
|
/** Report the current user's MFA state. Used by the Account settings UI. */
|
|
mfaRouter.get('/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) {
|
|
console.error('[MFA] status error:', getErrorMessage(error, 'unknown'));
|
|
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.
|
|
*/
|
|
mfaRouter.post('/mfa/enroll/start', authMiddleware, (req: Request, res: Response): void => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'Authentication required' });
|
|
return;
|
|
}
|
|
if (rejectApiTokenScope(req, res, MFA_SCOPE_MESSAGE)) 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;
|
|
}
|
|
if (isDebugEnabled()) {
|
|
console.log('[MFA:diag] enroll/start user=', req.user.username, 'hadPendingSecret=', Boolean(existing?.totp_secret_encrypted));
|
|
}
|
|
|
|
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) {
|
|
console.error('[MFA] enroll start error:', getErrorMessage(error, 'unknown'));
|
|
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.
|
|
*/
|
|
mfaRouter.post('/mfa/enroll/confirm', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'Authentication required' });
|
|
return;
|
|
}
|
|
if (rejectApiTokenScope(req, res, MFA_SCOPE_MESSAGE)) 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 bump invalidates the caller's cookie; they just proved TOTP
|
|
// possession so they can be resealed under the new token_version.
|
|
reissueSessionAfterTokenBump(req, res, req.user.userId);
|
|
|
|
console.log('[MFA] Enrolment completed:', req.user.username);
|
|
if (isDebugEnabled()) {
|
|
console.log('[MFA:diag] enroll/confirm backupCodesIssued=', backupCodes.length, 'user=', req.user.username);
|
|
}
|
|
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
|
|
} catch (error) {
|
|
console.error('[MFA] enroll confirm error:', getErrorMessage(error, 'unknown'));
|
|
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.
|
|
*/
|
|
mfaRouter.post('/mfa/disable', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const gate = requireEnrolledMfaUser(req, res);
|
|
if (!gate) return;
|
|
const { userId, mfa } = gate;
|
|
|
|
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;
|
|
}
|
|
|
|
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 (isDebugEnabled()) {
|
|
console.log('[MFA:diag] disable user=', req.user!.username, 'codeType=', isBackup ? 'backup' : 'totp', 'verified=', ok);
|
|
}
|
|
|
|
if (!ok) {
|
|
res.status(401).json({ error: 'Invalid verification code' });
|
|
return;
|
|
}
|
|
|
|
const db = DatabaseService.getInstance();
|
|
db.deleteUserMfa(userId);
|
|
db.bumpTokenVersion(userId);
|
|
// The user just proved possession of a current factor, so reseal their
|
|
// cookie under the new token_version to avoid a surprising forced re-login.
|
|
reissueSessionAfterTokenBump(req, res, userId);
|
|
|
|
console.log('[MFA] Disabled by user:', req.user!.username);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('[MFA] disable error:', getErrorMessage(error, 'unknown'));
|
|
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.
|
|
*/
|
|
mfaRouter.post('/mfa/backup-codes/regenerate', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const gate = requireEnrolledMfaUser(req, res);
|
|
if (!gate) return;
|
|
const { userId, mfa } = gate;
|
|
|
|
const code = typeof req.body?.code === 'string' ? req.body.code : '';
|
|
if (!code) {
|
|
res.status(400).json({ error: 'A verification code is required' });
|
|
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);
|
|
DatabaseService.getInstance().upsertUserMfa(userId, { backup_codes_json: JSON.stringify(hashes) });
|
|
console.log('[MFA] Backup codes regenerated:', req.user!.username);
|
|
if (isDebugEnabled()) {
|
|
console.log('[MFA:diag] backup-codes/regenerate user=', req.user!.username, 'codesIssued=', backupCodes.length);
|
|
}
|
|
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
|
|
} catch (error) {
|
|
console.error('[MFA] regenerate backup codes error:', getErrorMessage(error, 'unknown'));
|
|
res.status(500).json({ error: 'Failed to regenerate backup codes' });
|
|
}
|
|
});
|
|
|
|
/** Toggle whether SSO logins must also complete the TOTP challenge. */
|
|
mfaRouter.put('/mfa/sso-bypass', authMiddleware, (req: Request, res: Response): void => {
|
|
try {
|
|
const gate = requireEnrolledMfaUser(req, res);
|
|
if (!gate) return;
|
|
const { userId, mfa } = gate;
|
|
|
|
const enforce = req.body?.enforce === true;
|
|
if ((mfa.sso_enforce_mfa === 1) !== enforce) {
|
|
DatabaseService.getInstance().upsertUserMfa(userId, { sso_enforce_mfa: enforce });
|
|
console.log('[MFA] SSO bypass toggled:', req.user!.username, 'enforce=', enforce);
|
|
}
|
|
res.json({ success: true, sso_enforce_mfa: enforce });
|
|
} catch (error) {
|
|
console.error('[MFA] sso-bypass error:', getErrorMessage(error, 'unknown'));
|
|
res.status(500).json({ error: 'Failed to update SSO enforcement' });
|
|
}
|
|
});
|