feat(require-mfa): #155 enforce MFA via admin policy toggle across app access
Add an admin-controlled `require_mfa` policy in App Settings and expose it via `/auth/app-config` so the client can enforce it globally. Users without MFA are redirected to Settings after login and blocked from protected API/WebSocket access until setup is completed, while preserving MFA setup endpoints and admin recovery paths. Also prevent enabling the policy unless the acting admin already has MFA enabled, and block MFA disable while the policy is active. Includes UI toggle in Admin > Settings, required-policy notice in Settings, client-side 403 `MFA_REQUIRED` handling, and i18n updates for all supported locales.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import 'dotenv/config';
|
||||
import './config';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import path from 'path';
|
||||
@@ -80,6 +82,8 @@ if (shouldForceHttps) {
|
||||
app.use(express.json({ limit: '100kb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use(enforceGlobalMfaPolicy);
|
||||
|
||||
if (DEBUG) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
98
server/src/middleware/mfaPolicy.ts
Normal file
98
server/src/middleware/mfaPolicy.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
|
||||
/** Paths that never require MFA (public or pre-auth). */
|
||||
function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
||||
if (method === 'GET' && pathNoQuery === '/api/health') return true;
|
||||
if (method === 'GET' && pathNoQuery === '/api/auth/app-config') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/login') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/register') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
|
||||
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true;
|
||||
if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Authenticated paths allowed while MFA is not yet enabled (setup + lockout recovery). */
|
||||
function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
|
||||
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
||||
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
||||
if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* When app_settings.require_mfa is true, block API access for users without MFA enabled,
|
||||
* except for public routes and MFA setup endpoints.
|
||||
*/
|
||||
export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFunction): void {
|
||||
const pathNoQuery = (req.originalUrl || req.url || '').split('?')[0];
|
||||
|
||||
if (!pathNoQuery.startsWith('/api')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPublicApiPath(req.method, pathNoQuery)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
if (!token) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
|
||||
userId = decoded.id;
|
||||
} catch {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||
if (requireRow?.value !== 'true') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.DEMO_MODE === 'true') {
|
||||
const demo = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
|
||||
if (demo?.email === 'demo@trek.app' || demo?.email === 'demo@nomad.app') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT mfa_enabled, role FROM users WHERE id = ?').get(userId) as
|
||||
| { mfa_enabled: number | boolean; role: string }
|
||||
| undefined;
|
||||
if (!row) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
|
||||
if (mfaOk) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMfaSetupExemptPath(req.method, pathNoQuery)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(403).json({
|
||||
error: 'Two-factor authentication is required. Complete setup in Settings.',
|
||||
code: 'MFA_REQUIRED',
|
||||
});
|
||||
}
|
||||
@@ -143,6 +143,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
);
|
||||
const oidcOnlySetting = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
|
||||
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
|
||||
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||
res.json({
|
||||
allow_registration: isDemo ? false : allowRegistration,
|
||||
has_users: userCount > 0,
|
||||
@@ -151,6 +152,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
oidc_configured: oidcConfigured,
|
||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||
oidc_only_mode: oidcOnlyMode,
|
||||
require_mfa: requireMfaRow?.value === 'true',
|
||||
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
|
||||
demo_mode: isDemo,
|
||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||
@@ -516,7 +518,7 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'notification_webhook_url', 'app_url'];
|
||||
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'notification_webhook_url', 'app_url'];
|
||||
|
||||
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
@@ -536,9 +538,23 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const { allow_registration, allowed_file_types, require_mfa } = req.body as Record<string, unknown>;
|
||||
|
||||
if (require_mfa === true || require_mfa === 'true') {
|
||||
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined;
|
||||
if (!(adminMfa?.mfa_enabled === 1)) {
|
||||
return res.status(400).json({
|
||||
error: 'Enable two-factor authentication on your own account before requiring it for all users.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
if (req.body[key] !== undefined) {
|
||||
const val = String(req.body[key]);
|
||||
let val = String(req.body[key]);
|
||||
if (key === 'require_mfa') {
|
||||
val = req.body[key] === true || val === 'true' ? 'true' : 'false';
|
||||
}
|
||||
// Don't save masked password
|
||||
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||
@@ -551,6 +567,7 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||
details: {
|
||||
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
|
||||
allowed_file_types_changed: allowed_file_types !== undefined,
|
||||
require_mfa: require_mfa !== undefined ? (require_mfa === true || require_mfa === 'true') : undefined,
|
||||
},
|
||||
});
|
||||
res.json({ success: true });
|
||||
@@ -717,6 +734,10 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
|
||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
|
||||
return res.status(403).json({ error: 'MFA cannot be changed in demo mode.' });
|
||||
}
|
||||
const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||
if (policy?.value === 'true') {
|
||||
return res.status(403).json({ error: 'Two-factor authentication cannot be disabled while it is required for all users.' });
|
||||
}
|
||||
const { password, code } = req.body as { password?: string; code?: string };
|
||||
if (!password || !code) {
|
||||
return res.status(400).json({ error: 'Password and authenticator code are required' });
|
||||
|
||||
@@ -55,12 +55,18 @@ function setupWebSocket(server: http.Server): void {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
|
||||
user = db.prepare(
|
||||
'SELECT id, username, email, role FROM users WHERE id = ?'
|
||||
'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(decoded.id) as User | undefined;
|
||||
if (!user) {
|
||||
nws.close(4001, 'User not found');
|
||||
return;
|
||||
}
|
||||
const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
|
||||
const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
|
||||
if (requireMfa && !mfaOk) {
|
||||
nws.close(4403, 'MFA required');
|
||||
return;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
nws.close(4001, 'Invalid or expired token');
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user