mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-04 16:07:55 +00:00
refactor(backend): extract auth/MFA/SSO routers from index.ts (phase 4a-2) (#735)
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.
This commit is contained in:
@@ -7,6 +7,8 @@ export const PORT = 3000;
|
||||
|
||||
// Password policy
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
/** bcrypt cost factor. 10 is current Sencho default; roughly ~75ms/hash on modern hardware. */
|
||||
export const BCRYPT_SALT_ROUNDS = 10;
|
||||
|
||||
// Labels
|
||||
export const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const;
|
||||
@@ -20,6 +22,12 @@ export const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending';
|
||||
export const MFA_PENDING_SCOPE = 'mfa_pending';
|
||||
export const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge
|
||||
|
||||
// MFA replay-prevention: recently-used codes are blacklisted for
|
||||
// MFA_REPLAY_TTL_MS to block replay within a single 30-second TOTP window
|
||||
// (plus drift tolerance). A periodic purge keeps the table bounded.
|
||||
export const MFA_REPLAY_TTL_MS = 120 * 1000;
|
||||
export const MFA_REPLAY_PURGE_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
// Hot-path cache TTLs.
|
||||
// Short TTLs collapse concurrent polling pressure across browser tabs and
|
||||
// overlapping service samplers without introducing noticeable UI staleness.
|
||||
|
||||
+13
-858
@@ -8,7 +8,7 @@ import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import si from 'systeminformation';
|
||||
import path from 'path';
|
||||
import { DatabaseService, Node, AuthProvider, ScheduledTask, UserRole, ResourceType, parsePolicyEvaluation, type VulnerabilityScan } from './services/DatabaseService';
|
||||
import { DatabaseService, Node, ScheduledTask, UserRole, ResourceType, parsePolicyEvaluation, type VulnerabilityScan } from './services/DatabaseService';
|
||||
import { NotificationService } from './services/NotificationService';
|
||||
import { MonitorService } from './services/MonitorService';
|
||||
import { AutoHealService } from './services/AutoHealService';
|
||||
@@ -24,8 +24,6 @@ import { FleetSyncService } from './services/FleetSyncService';
|
||||
import { LicenseService } 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';
|
||||
@@ -36,14 +34,12 @@ import './types/express';
|
||||
import {
|
||||
PORT,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
COOKIE_NAME,
|
||||
MFA_PENDING_COOKIE_NAME,
|
||||
MFA_PENDING_SCOPE,
|
||||
MFA_REPLAY_TTL_MS,
|
||||
MFA_REPLAY_PURGE_INTERVAL_MS,
|
||||
STATS_CACHE_TTL_MS,
|
||||
SYSTEM_STATS_CACHE_TTL_MS,
|
||||
STACK_STATUSES_CACHE_TTL_MS,
|
||||
} from './helpers/constants';
|
||||
import { isSecureRequest } from './helpers/cookies';
|
||||
import {
|
||||
checkPermission,
|
||||
requirePermission,
|
||||
@@ -62,20 +58,13 @@ import {
|
||||
} from './helpers/policyGate';
|
||||
import {
|
||||
webhookTriggerLimiter,
|
||||
authRateLimiter,
|
||||
ssoRateLimiter,
|
||||
trivyInstallLimiter,
|
||||
} from './middleware/rateLimiters';
|
||||
import { authGate, auditLog } from './middleware/authGate';
|
||||
import { enforceApiTokenScope } from './middleware/apiTokenScope';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { createApp } from './app';
|
||||
import {
|
||||
authMiddleware,
|
||||
issueSessionCookie,
|
||||
issueMfaPendingCookie,
|
||||
clearMfaPendingCookie,
|
||||
} from './middleware/auth';
|
||||
import { authMiddleware } from './middleware/auth';
|
||||
import { createRemoteProxyMiddleware } from './proxy/remoteNodeProxy';
|
||||
import { createServer } from './server';
|
||||
import { attachUpgrade } from './websocket/upgradeHandler';
|
||||
@@ -84,6 +73,9 @@ import { FleetUpdateTrackerService } from './services/FleetUpdateTrackerService'
|
||||
import { mintConsoleSession } from './helpers/consoleSession';
|
||||
import { invalidateNodeCaches } from './helpers/cacheInvalidation';
|
||||
import { metaRouter } from './routes/meta';
|
||||
import { authRouter } from './routes/auth';
|
||||
import { mfaRouter } from './routes/mfa';
|
||||
import { ssoRouter } from './routes/sso';
|
||||
import { licenseRouter, systemUpdateRouter, scheduleLocalUpdate } from './routes/license';
|
||||
import { permissionsRouter } from './routes/permissions';
|
||||
import { convertRouter } from './routes/convert';
|
||||
@@ -133,850 +125,13 @@ const app = createApp();
|
||||
// Public /api/health and /api/meta (no auth). Mounted before authGate.
|
||||
app.use('/api', metaRouter);
|
||||
|
||||
// Auth Routes (no authentication required)
|
||||
// Auth / MFA / SSO routers. Mounted before authGate because some paths are
|
||||
// public (login, setup, SSO callbacks); handlers that need auth use the
|
||||
// authMiddleware directly.
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/auth', mfaRouter);
|
||||
app.use('/api/auth/sso', ssoRouter);
|
||||
|
||||
// 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;
|
||||
|
||||
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, mfaPending: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Initial setup endpoint
|
||||
app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const settings = dbSvc.getGlobalSettings();
|
||||
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
|
||||
if (!needsSetup) {
|
||||
res.status(400).json({ error: 'Setup has already been completed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!username || !password || !confirmPassword) {
|
||||
res.status(400).json({ error: 'All fields are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
res.status(400).json({ error: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Save credentials (this also generates the JWT secret)
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const jwtSecret = crypto.randomBytes(64).toString('hex');
|
||||
dbSvc.updateGlobalSetting('auth_username', username);
|
||||
dbSvc.updateGlobalSetting('auth_password_hash', passwordHash);
|
||||
dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret);
|
||||
|
||||
// Create admin user in users table
|
||||
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
|
||||
|
||||
// Issue JWT and log user in
|
||||
issueSessionCookie(res, req, { username, role: 'admin', token_version: 1 }, jwtSecret);
|
||||
res.json({ success: true, message: 'Setup completed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
res.status(500).json({ error: 'Failed to complete setup' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login endpoint
|
||||
app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(username);
|
||||
|
||||
if (user) {
|
||||
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||
if (isValid) {
|
||||
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 (isDebugEnabled()) {
|
||||
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
|
||||
}
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[Auth] Login failed for username:', username);
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update password endpoint - any authenticated user can change their own password
|
||||
app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot change passwords.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
if (!oldPassword || !newPassword) {
|
||||
res.status(400).json({ error: 'Old password and new password are required' });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
return;
|
||||
}
|
||||
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const user = dbSvc.getUserByUsername(req.user!.username);
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(oldPassword, user.password_hash);
|
||||
if (!isValid) {
|
||||
res.status(401).json({ error: 'Invalid old password' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, 10);
|
||||
dbSvc.updateUser(user.id, { password_hash: newHash });
|
||||
// Keep global_settings in sync for backward compat
|
||||
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
|
||||
// Invalidate all other sessions for this user
|
||||
dbSvc.bumpTokenVersion(user.id);
|
||||
// Re-issue cookie with new token version so the current session survives
|
||||
const settings = dbSvc.getGlobalSettings();
|
||||
const updatedUser = dbSvc.getUserById(user.id);
|
||||
if (settings.auth_jwt_secret && updatedUser) {
|
||||
issueSessionCookie(res, req, updatedUser, settings.auth_jwt_secret);
|
||||
}
|
||||
console.log('[Auth] Password changed by:', req.user!.username);
|
||||
res.json({ success: true, message: 'Password updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('[Auth] Password update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update password' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req: Request, res: Response): void => {
|
||||
res.clearCookie(COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
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' });
|
||||
});
|
||||
|
||||
// Check authentication status
|
||||
app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void => {
|
||||
res.json({ authenticated: true, user: req.user });
|
||||
});
|
||||
|
||||
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication
|
||||
app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (req.apiTokenScope) {
|
||||
res.status(403).json({ error: 'API tokens cannot generate node tokens.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) {
|
||||
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
|
||||
return;
|
||||
}
|
||||
// Default 1-year expiry; admin should rotate tokens periodically.
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret, { expiresIn: '365d' });
|
||||
res.json({ token });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message || 'Failed to generate node token' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSO Auth Routes (public, under /api/auth/sso/*) ---
|
||||
|
||||
// Seed SSO config from environment variables on startup
|
||||
SSOService.getInstance().seedFromEnv();
|
||||
|
||||
/** Derive the OAuth callback base URL from SSO_CALLBACK_URL or the request Host header, with injection validation. */
|
||||
function getSSOBaseUrl(req: Request, res: Response): string | null {
|
||||
const host = req.get('host') || '';
|
||||
if (!process.env.SSO_CALLBACK_URL && /[\s<>\r\n]/.test(host)) {
|
||||
console.error('[SSO] Rejected suspicious Host header');
|
||||
res.redirect('/?sso_error=Invalid+request');
|
||||
return null;
|
||||
}
|
||||
if (!process.env.SSO_CALLBACK_URL && isDebugEnabled()) {
|
||||
console.debug('[SSO:debug] SSO_CALLBACK_URL not set; using Host header for callback URL:', host);
|
||||
}
|
||||
return process.env.SSO_CALLBACK_URL || `${req.protocol}://${host}`;
|
||||
}
|
||||
|
||||
// List enabled SSO providers (for login page)
|
||||
app.get('/api/auth/sso/providers', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const providers = SSOService.getInstance().getEnabledProviders();
|
||||
res.json(providers);
|
||||
} catch (e) {
|
||||
console.warn('[SSO] Failed to list enabled providers, returning empty list:', (e as Error).message);
|
||||
res.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
// LDAP login
|
||||
app.post('/api/auth/sso/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await SSOService.getInstance().authenticateLDAP(username, password);
|
||||
if (!result.success || !result.user) {
|
||||
res.status(401).json({ error: result.error || 'Authentication failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Provision or find existing user
|
||||
const user = SSOService.getInstance().provisionUser({
|
||||
authProvider: 'ldap',
|
||||
providerId: result.user.providerId,
|
||||
preferredUsername: result.user.preferredUsername,
|
||||
email: result.user.email,
|
||||
role: result.user.role,
|
||||
});
|
||||
|
||||
// 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 (isDebugEnabled()) {
|
||||
console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
|
||||
}
|
||||
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' });
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'LDAP login failed';
|
||||
console.error('[SSO] LDAP login error:', msg);
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
// OIDC: Initiate authorization flow
|
||||
app.get('/api/auth/sso/oidc/:provider/authorize', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = String(req.params.provider);
|
||||
const validProviders = ['oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'];
|
||||
if (!validProviders.includes(provider)) {
|
||||
res.status(400).json({ error: 'Invalid SSO provider' });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = getSSOBaseUrl(req, res);
|
||||
if (!baseUrl) return;
|
||||
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
|
||||
|
||||
const { url, state, codeVerifier } = await SSOService.getInstance().getOIDCAuthorizationUrl(provider, callbackUrl);
|
||||
|
||||
// Store state + codeVerifier in an encrypted short-lived cookie
|
||||
const cryptoSvc = (await import('./services/CryptoService')).CryptoService.getInstance();
|
||||
const statePayload = JSON.stringify({ state, codeVerifier, provider });
|
||||
res.cookie('sencho_sso_state', cryptoSvc.encrypt(statePayload), {
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'lax', // Must be lax for cross-site IdP redirect
|
||||
maxAge: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
res.redirect(url);
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'SSO initialization failed';
|
||||
console.error('[SSO] OIDC authorize error:', msg);
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// OIDC: Callback from identity provider
|
||||
app.get('/api/auth/sso/oidc/:provider/callback', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = String(req.params.provider);
|
||||
const code = String(req.query.code || '');
|
||||
const state = String(req.query.state || '');
|
||||
const oidcError = req.query.error ? String(req.query.error) : '';
|
||||
const error_description = req.query.error_description ? String(req.query.error_description) : '';
|
||||
|
||||
if (oidcError) {
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(error_description || oidcError)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.redirect('/?sso_error=Missing+authorization+code');
|
||||
return;
|
||||
}
|
||||
|
||||
// Read and validate state cookie
|
||||
const stateCookie = req.cookies?.sencho_sso_state;
|
||||
// Always clear the one-time state cookie, regardless of outcome
|
||||
res.clearCookie('sencho_sso_state', { httpOnly: true, secure: isSecureRequest(req), sameSite: 'lax' });
|
||||
if (!stateCookie) {
|
||||
res.redirect('/?sso_error=SSO+session+expired.+Please+try+again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const cryptoSvc = (await import('./services/CryptoService')).CryptoService.getInstance();
|
||||
let statePayload: { state: string; codeVerifier: string; provider: string };
|
||||
try {
|
||||
statePayload = JSON.parse(cryptoSvc.decrypt(stateCookie));
|
||||
} catch (e) {
|
||||
console.error('[SSO] Failed to decrypt SSO state cookie:', (e as Error).message);
|
||||
res.redirect('/?sso_error=Invalid+SSO+session');
|
||||
return;
|
||||
}
|
||||
|
||||
if (statePayload.provider !== provider) {
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(`Provider mismatch: expected ${statePayload.provider}, got ${provider}`)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = getSSOBaseUrl(req, res);
|
||||
if (!baseUrl) return;
|
||||
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
|
||||
|
||||
const result = await SSOService.getInstance().handleOIDCCallback(
|
||||
provider, callbackUrl,
|
||||
{ code, state },
|
||||
statePayload.state,
|
||||
statePayload.codeVerifier
|
||||
);
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(result.error || 'Authentication failed')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Provision or find existing user
|
||||
const user = SSOService.getInstance().provisionUser({
|
||||
authProvider: provider as AuthProvider,
|
||||
providerId: result.user.providerId,
|
||||
preferredUsername: result.user.preferredUsername,
|
||||
email: result.user.email,
|
||||
role: result.user.role,
|
||||
});
|
||||
|
||||
// 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 (isDebugEnabled()) {
|
||||
console.log('[MFA:diag] login: path=oidc provider=', provider, 'user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
|
||||
}
|
||||
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}`);
|
||||
|
||||
res.redirect('/');
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'SSO callback failed';
|
||||
console.error('[SSO] OIDC callback error:', msg);
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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> => {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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));
|
||||
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: 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;
|
||||
}
|
||||
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: 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);
|
||||
}
|
||||
|
||||
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: 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 (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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
console.log('[MFA] Disabled by user:', req.user.username);
|
||||
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) });
|
||||
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: 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 });
|
||||
console.log('[MFA] SSO bypass toggled:', req.user.username, 'enforce=', 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// Auth gate on all /api/* routes (exempts /auth/* and webhook triggers).
|
||||
app.use('/api', authGate);
|
||||
|
||||
@@ -198,3 +198,19 @@ export function issueMfaPendingCookie(
|
||||
export function clearMfaPendingCookie(res: Response, req: Request): void {
|
||||
res.clearCookie(MFA_PENDING_COOKIE_NAME, getCookieOptions(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-issue the session cookie after bumping `token_version`. Routes that
|
||||
* call `bumpTokenVersion` (password change, MFA enrol, MFA disable) use this
|
||||
* so the caller stays signed in after their previous cookie is invalidated.
|
||||
* No-ops silently when the JWT secret is missing or the user has been
|
||||
* deleted mid-request.
|
||||
*/
|
||||
export function reissueSessionAfterTokenBump(req: Request, res: Response, userId: number): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
const refreshed = db.getUserById(userId);
|
||||
const settings = db.getGlobalSettings();
|
||||
if (refreshed && settings.auth_jwt_secret) {
|
||||
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import {
|
||||
authMiddleware,
|
||||
issueSessionCookie,
|
||||
issueMfaPendingCookie,
|
||||
clearMfaPendingCookie,
|
||||
reissueSessionAfterTokenBump,
|
||||
} from '../middleware/auth';
|
||||
import { authRateLimiter } from '../middleware/rateLimiters';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import {
|
||||
BCRYPT_SALT_ROUNDS,
|
||||
COOKIE_NAME,
|
||||
MFA_PENDING_COOKIE_NAME,
|
||||
MFA_PENDING_SCOPE,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
} from '../helpers/constants';
|
||||
import { isSecureRequest } from '../helpers/cookies';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
// 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, e.g. after an OIDC redirect).
|
||||
authRouter.get('/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;
|
||||
|
||||
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, mfaPending: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Initial setup endpoint
|
||||
authRouter.post('/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const settings = dbSvc.getGlobalSettings();
|
||||
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
|
||||
if (!needsSetup) {
|
||||
res.status(400).json({ error: 'Setup has already been completed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
if (!username || !password || !confirmPassword) {
|
||||
res.status(400).json({ error: 'All fields are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
res.status(400).json({ error: 'Passwords do not match' });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||
const jwtSecret = crypto.randomBytes(64).toString('hex');
|
||||
dbSvc.updateGlobalSetting('auth_username', username);
|
||||
dbSvc.updateGlobalSetting('auth_password_hash', passwordHash);
|
||||
dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret);
|
||||
|
||||
// Create admin user in users table
|
||||
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
|
||||
|
||||
issueSessionCookie(res, req, { username, role: 'admin', token_version: 1 }, jwtSecret);
|
||||
res.json({ success: true, message: 'Setup completed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
res.status(500).json({ error: 'Failed to complete setup' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login endpoint
|
||||
authRouter.post('/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(username);
|
||||
|
||||
if (user) {
|
||||
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||
if (isValid) {
|
||||
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 (isDebugEnabled()) {
|
||||
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
|
||||
}
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[Auth] Login failed for username:', username);
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Any authenticated user can change their own password.
|
||||
authRouter.put('/password', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, 'API tokens cannot change passwords.')) return;
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
if (!oldPassword || !newPassword) {
|
||||
res.status(400).json({ error: 'Old password and new password are required' });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
||||
return;
|
||||
}
|
||||
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const user = dbSvc.getUserByUsername(req.user!.username);
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(oldPassword, user.password_hash);
|
||||
if (!isValid) {
|
||||
res.status(401).json({ error: 'Invalid old password' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
|
||||
dbSvc.updateUser(user.id, { password_hash: newHash });
|
||||
// Keep global_settings in sync for backward compat.
|
||||
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
|
||||
// Invalidate all other sessions for this user, then re-issue the caller's
|
||||
// cookie so the current session survives the token-version bump.
|
||||
dbSvc.bumpTokenVersion(user.id);
|
||||
reissueSessionAfterTokenBump(req, res, user.id);
|
||||
console.log('[Auth] Password changed by:', req.user!.username);
|
||||
res.json({ success: true, message: 'Password updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('[Auth] Password update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update password' });
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.post('/logout', (req: Request, res: Response): void => {
|
||||
res.clearCookie(COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'strict',
|
||||
});
|
||||
// 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' });
|
||||
});
|
||||
|
||||
authRouter.get('/check', authMiddleware, (req: Request, res: Response): void => {
|
||||
res.json({ authenticated: true, user: req.user });
|
||||
});
|
||||
|
||||
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication.
|
||||
authRouter.post('/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (rejectApiTokenScope(req, res, 'API tokens cannot generate node tokens.')) return;
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) {
|
||||
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
|
||||
return;
|
||||
}
|
||||
// Default 1-year expiry; admin should rotate tokens periodically.
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret, { expiresIn: '365d' });
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: getErrorMessage(error, 'Failed to generate node token') });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
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' });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService, type AuthProvider } from '../services/DatabaseService';
|
||||
import { SSOService } from '../services/SSOService';
|
||||
import { CryptoService } from '../services/CryptoService';
|
||||
import { issueSessionCookie, issueMfaPendingCookie } from '../middleware/auth';
|
||||
import { authRateLimiter, ssoRateLimiter } from '../middleware/rateLimiters';
|
||||
import { isSecureRequest } from '../helpers/cookies';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
// Seed SSO config from environment variables on module load. One-shot side
|
||||
// effect at startup; safe to repeat (upsert).
|
||||
SSOService.getInstance().seedFromEnv();
|
||||
|
||||
/** Derive the OAuth callback base URL from SSO_CALLBACK_URL or the request
|
||||
* Host header, with injection validation. */
|
||||
function getSSOBaseUrl(req: Request, res: Response): string | null {
|
||||
const host = req.get('host') || '';
|
||||
if (!process.env.SSO_CALLBACK_URL && /[\s<>\r\n]/.test(host)) {
|
||||
console.error('[SSO] Rejected suspicious Host header');
|
||||
res.redirect('/?sso_error=Invalid+request');
|
||||
return null;
|
||||
}
|
||||
if (!process.env.SSO_CALLBACK_URL && isDebugEnabled()) {
|
||||
console.debug('[SSO:debug] SSO_CALLBACK_URL not set; using Host header for callback URL:', host);
|
||||
}
|
||||
return process.env.SSO_CALLBACK_URL || `${req.protocol}://${host}`;
|
||||
}
|
||||
|
||||
export const ssoRouter = Router();
|
||||
|
||||
ssoRouter.get('/providers', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const providers = SSOService.getInstance().getEnabledProviders();
|
||||
res.json(providers);
|
||||
} catch (e) {
|
||||
console.warn('[SSO] Failed to list enabled providers, returning empty list:', getErrorMessage(e, 'unknown'));
|
||||
res.json([]);
|
||||
}
|
||||
});
|
||||
|
||||
ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await SSOService.getInstance().authenticateLDAP(username, password);
|
||||
if (!result.success || !result.user) {
|
||||
res.status(401).json({ error: result.error || 'Authentication failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = SSOService.getInstance().provisionUser({
|
||||
authProvider: 'ldap',
|
||||
providerId: result.user.providerId,
|
||||
preferredUsername: result.user.preferredUsername,
|
||||
email: result.user.email,
|
||||
role: result.user.role,
|
||||
});
|
||||
|
||||
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 (isDebugEnabled()) {
|
||||
console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
|
||||
}
|
||||
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' });
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error, 'LDAP login failed');
|
||||
console.error('[SSO] LDAP login error:', msg);
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
ssoRouter.get('/oidc/:provider/authorize', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = String(req.params.provider);
|
||||
const validProviders = ['oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'];
|
||||
if (!validProviders.includes(provider)) {
|
||||
res.status(400).json({ error: 'Invalid SSO provider' });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = getSSOBaseUrl(req, res);
|
||||
if (!baseUrl) return;
|
||||
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
|
||||
|
||||
const { url, state, codeVerifier } = await SSOService.getInstance().getOIDCAuthorizationUrl(provider, callbackUrl);
|
||||
|
||||
// Store state + codeVerifier in an encrypted short-lived cookie.
|
||||
const cryptoSvc = CryptoService.getInstance();
|
||||
const statePayload = JSON.stringify({ state, codeVerifier, provider });
|
||||
res.cookie('sencho_sso_state', cryptoSvc.encrypt(statePayload), {
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'lax', // Must be lax for cross-site IdP redirect
|
||||
maxAge: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
res.redirect(url);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error, 'SSO initialization failed');
|
||||
console.error('[SSO] OIDC authorize error:', msg);
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
});
|
||||
|
||||
ssoRouter.get('/oidc/:provider/callback', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = String(req.params.provider);
|
||||
const code = String(req.query.code || '');
|
||||
const state = String(req.query.state || '');
|
||||
const oidcError = req.query.error ? String(req.query.error) : '';
|
||||
const error_description = req.query.error_description ? String(req.query.error_description) : '';
|
||||
|
||||
if (oidcError) {
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(error_description || oidcError)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.redirect('/?sso_error=Missing+authorization+code');
|
||||
return;
|
||||
}
|
||||
|
||||
const stateCookie = req.cookies?.sencho_sso_state;
|
||||
// Always clear the one-time state cookie, regardless of outcome.
|
||||
res.clearCookie('sencho_sso_state', { httpOnly: true, secure: isSecureRequest(req), sameSite: 'lax' });
|
||||
if (!stateCookie) {
|
||||
res.redirect('/?sso_error=SSO+session+expired.+Please+try+again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const cryptoSvc = CryptoService.getInstance();
|
||||
let statePayload: { state: string; codeVerifier: string; provider: string };
|
||||
try {
|
||||
statePayload = JSON.parse(cryptoSvc.decrypt(stateCookie));
|
||||
} catch (e) {
|
||||
console.error('[SSO] Failed to decrypt SSO state cookie:', getErrorMessage(e, 'unknown'));
|
||||
res.redirect('/?sso_error=Invalid+SSO+session');
|
||||
return;
|
||||
}
|
||||
|
||||
if (statePayload.provider !== provider) {
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(`Provider mismatch: expected ${statePayload.provider}, got ${provider}`)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = getSSOBaseUrl(req, res);
|
||||
if (!baseUrl) return;
|
||||
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
|
||||
|
||||
const result = await SSOService.getInstance().handleOIDCCallback(
|
||||
provider, callbackUrl,
|
||||
{ code, state },
|
||||
statePayload.state,
|
||||
statePayload.codeVerifier,
|
||||
);
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(result.error || 'Authentication failed')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = SSOService.getInstance().provisionUser({
|
||||
authProvider: provider as AuthProvider,
|
||||
providerId: result.user.providerId,
|
||||
preferredUsername: result.user.preferredUsername,
|
||||
email: result.user.email,
|
||||
role: result.user.role,
|
||||
});
|
||||
|
||||
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 (isDebugEnabled()) {
|
||||
console.log('[MFA:diag] login: path=oidc provider=', provider, 'user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
|
||||
}
|
||||
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}`);
|
||||
|
||||
res.redirect('/');
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error, 'SSO callback failed');
|
||||
console.error('[SSO] OIDC callback error:', msg);
|
||||
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user