diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx
index 0d25f42..358be36 100644
--- a/client/src/pages/SettingsPage.tsx
+++ b/client/src/pages/SettingsPage.tsx
@@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
+import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast'
-import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, Terminal, Copy, Plus, Check } from 'lucide-react'
+import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Terminal, Copy, Plus, Check } from 'lucide-react'
import { authApi, adminApi, notificationsApi } from '../api/client'
import apiClient from '../api/client'
import { useAddonStore } from '../store/addonStore'
@@ -110,7 +110,8 @@ function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabl
}
export default function SettingsPage(): React.ReactElement {
- const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
+ const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
+ const [searchParams] = useSearchParams()
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const avatarInputRef = React.useRef(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
@@ -265,6 +266,10 @@ export default function SettingsPage(): React.ReactElement {
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
const [mfaDisableCode, setMfaDisableCode] = useState('')
const [mfaLoading, setMfaLoading] = useState(false)
+ const mfaRequiredByPolicy =
+ !demoMode &&
+ !user?.mfa_enabled &&
+ (searchParams.get('mfa') === 'required' || appRequireMfa)
useEffect(() => {
setMapTileUrl(settings.map_tile_url || '')
@@ -880,6 +885,19 @@ export default function SettingsPage(): React.ReactElement {
{t('settings.mfa.title')}
+ {mfaRequiredByPolicy && (
+
+
+
{t('settings.mfa.requiredByPolicy')}
+
+ )}
{t('settings.mfa.description')}
{demoMode ? (
{t('settings.mfa.demoBlocked')}
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts
index 8387c4e..b26eb14 100644
--- a/client/src/store/authStore.ts
+++ b/client/src/store/authStore.ts
@@ -24,6 +24,8 @@ interface AuthState {
demoMode: boolean
hasMapsKey: boolean
serverTimezone: string
+ /** Server policy: all users must enable MFA */
+ appRequireMfa: boolean
login: (email: string, password: string) => Promise
completeMfaLogin: (mfaToken: string, code: string) => Promise
@@ -38,6 +40,7 @@ interface AuthState {
setDemoMode: (val: boolean) => void
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
+ setAppRequireMfa: (val: boolean) => void
demoLogin: () => Promise
}
@@ -50,6 +53,7 @@ export const useAuthStore = create((set, get) => ({
demoMode: localStorage.getItem('demo_mode') === 'true',
hasMapsKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ appRequireMfa: false,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null })
@@ -205,6 +209,7 @@ export const useAuthStore = create((set, get) => ({
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
+ setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
demoLogin: async () => {
set({ isLoading: true, error: null })
diff --git a/client/src/types.ts b/client/src/types.ts
index ac232ee..b216b63 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -281,6 +281,8 @@ export interface AppConfig {
has_maps_key?: boolean
allowed_file_types?: string
timezone?: string
+ /** When true, users without MFA cannot use the app until they enable it */
+ require_mfa?: boolean
}
// Translation function type
diff --git a/server/src/index.ts b/server/src/index.ts
index 0d51334..db4bba7 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -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();
diff --git a/server/src/middleware/mfaPolicy.ts b/server/src/middleware/mfaPolicy.ts
new file mode 100644
index 0000000..2912faa
--- /dev/null
+++ b/server/src/middleware/mfaPolicy.ts
@@ -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',
+ });
+}
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
index dc75284..357dd64 100644
--- a/server/src/routes/auth.ts
+++ b/server/src/routes/auth.ts
@@ -145,6 +145,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,
@@ -153,6 +154,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,
@@ -518,7 +520,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', 'smtp_skip_tls_verify', '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', 'smtp_skip_tls_verify', 'notification_webhook_url', 'app_url'];
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
@@ -538,9 +540,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;
+
+ 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);
@@ -553,6 +569,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 });
@@ -719,6 +736,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' });
diff --git a/server/src/websocket.ts b/server/src/websocket.ts
index 1e28ddb..2f6e44c 100644
--- a/server/src/websocket.ts
+++ b/server/src/websocket.ts
@@ -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;