From 66f5ea50c57d78ea862cde53eea52ad69279df9b Mon Sep 17 00:00:00 2001 From: fgbona Date: Mon, 30 Mar 2026 17:42:40 -0300 Subject: [PATCH] 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. --- client/src/App.tsx | 17 +++++- client/src/api/client.ts | 7 +++ client/src/i18n/translations/ar.ts | 3 + client/src/i18n/translations/br.ts | 3 + client/src/i18n/translations/cs.ts | 3 + client/src/i18n/translations/de.ts | 3 + client/src/i18n/translations/en.ts | 3 + client/src/i18n/translations/es.ts | 3 + client/src/i18n/translations/fr.ts | 3 + client/src/i18n/translations/hu.ts | 3 + client/src/i18n/translations/it.ts | 3 + client/src/i18n/translations/nl.ts | 3 + client/src/i18n/translations/ru.ts | 3 + client/src/i18n/translations/zh.ts | 3 + client/src/pages/AdminPage.tsx | 44 +++++++++++++- client/src/pages/SettingsPage.tsx | 24 +++++++- client/src/store/authStore.ts | 5 ++ client/src/types.ts | 2 + server/src/index.ts | 4 ++ server/src/middleware/mfaPolicy.ts | 98 ++++++++++++++++++++++++++++++ server/src/routes/auth.ts | 25 +++++++- server/src/websocket.ts | 8 ++- 22 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 server/src/middleware/mfaPolicy.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 80e8338..14ae098 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -23,8 +23,9 @@ interface ProtectedRouteProps { } function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) { - const { isAuthenticated, user, isLoading } = useAuthStore() + const { isAuthenticated, user, isLoading, appRequireMfa } = useAuthStore() const { t } = useTranslation() + const location = useLocation() if (isLoading) { return ( @@ -41,6 +42,15 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps return } + if ( + appRequireMfa && + user && + !user.mfa_enabled && + location.pathname !== '/settings' + ) { + return + } + if (adminRequired && user && user.role !== 'admin') { return } @@ -63,17 +73,18 @@ function RootRedirect() { } export default function App() { - const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore() + const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { if (token) { loadUser() } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => { + authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean }) => { if (config?.demo_mode) setDemoMode(true) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) if (config?.timezone) setServerTimezone(config.timezone) + if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.version) { const storedVersion = localStorage.getItem('trek_app_version') diff --git a/client/src/api/client.ts b/client/src/api/client.ts index fbabe99..98b97ef 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -34,6 +34,13 @@ apiClient.interceptors.response.use( window.location.href = '/login' } } + if ( + error.response?.status === 403 && + (error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' && + !window.location.pathname.startsWith('/settings') + ) { + window.location.href = '/settings?mfa=required' + } return Promise.reject(error) } ) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 08820dd..ebeb8bf 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -226,6 +226,7 @@ const ar: Record = { 'settings.avatarError': 'فشل الرفع', 'settings.mfa.title': 'المصادقة الثنائية (2FA)', 'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).', + 'settings.mfa.requiredByPolicy': 'المسؤول يتطلب المصادقة الثنائية. اضبط تطبيق المصادقة أدناه قبل المتابعة.', 'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.', 'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.', 'settings.mfa.setup': 'إعداد المصادقة', @@ -374,6 +375,8 @@ const ar: Record = { 'admin.invite.deleteError': 'فشل حذف رابط الدعوة', 'admin.allowRegistration': 'السماح بالتسجيل', 'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم', + 'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)', + 'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.', 'admin.apiKeys': 'مفاتيح API', 'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.', 'admin.mapsKey': 'مفتاح Google Maps API', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index dab6913..746b8e8 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -221,6 +221,7 @@ const br: Record = { 'settings.avatarError': 'Falha no envio', 'settings.mfa.title': 'Autenticação em duas etapas (2FA)', 'settings.mfa.description': 'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).', + 'settings.mfa.requiredByPolicy': 'O administrador exige autenticação em dois fatores. Configure um app autenticador abaixo antes de continuar.', 'settings.mfa.enabled': 'O 2FA está ativado na sua conta.', 'settings.mfa.disabled': 'O 2FA não está ativado.', 'settings.mfa.setup': 'Configurar autenticador', @@ -364,6 +365,8 @@ const br: Record = { 'admin.tabs.settings': 'Configurações', 'admin.allowRegistration': 'Permitir cadastro', 'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos', + 'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)', + 'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.', 'admin.apiKeys': 'Chaves de API', 'admin.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.', 'admin.mapsKey': 'Chave da API Google Maps', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index daefce0..db955ac 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -188,6 +188,7 @@ const cs: Record = { 'settings.avatarError': 'Nahrávání se nezdařilo', 'settings.mfa.title': 'Dvoufaktorové ověření (2FA)', 'settings.mfa.description': 'Přidá druhý stupeň zabezpečení při přihlašování e-mailem a heslem. Použijte aplikaci (Google Authenticator, Authy apod.).', + 'settings.mfa.requiredByPolicy': 'Správce vyžaduje dvoufázové ověření. Nejdřív níže nastavte aplikaci autentikátoru.', 'settings.mfa.enabled': '2FA je pro váš účet aktivní.', 'settings.mfa.disabled': '2FA není aktivní.', 'settings.mfa.setup': 'Nastavit autentizační aplikaci', @@ -365,6 +366,8 @@ const cs: Record = { 'admin.tabs.settings': 'Nastavení', 'admin.allowRegistration': 'Povolit registraci', 'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat', + 'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)', + 'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.', 'admin.apiKeys': 'API klíče', 'admin.apiKeysHint': 'Volitelné. Povoluje rozšířená data o místech (fotky, počasí).', 'admin.mapsKey': 'Google Maps API klíč', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1e5422d..929eefa 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -221,6 +221,7 @@ const de: Record = { 'settings.avatarError': 'Fehler beim Hochladen', 'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)', 'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).', + 'settings.mfa.requiredByPolicy': 'Dein Administrator verlangt Zwei-Faktor-Authentifizierung. Richte unten eine Authenticator-App ein, bevor du fortfährst.', 'settings.mfa.enabled': '2FA ist für dein Konto aktiv.', 'settings.mfa.disabled': '2FA ist nicht aktiviert.', 'settings.mfa.setup': 'Authenticator einrichten', @@ -365,6 +366,8 @@ const de: Record = { 'admin.tabs.settings': 'Einstellungen', 'admin.allowRegistration': 'Registrierung erlauben', 'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren', + 'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen', + 'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.', 'admin.apiKeys': 'API-Schlüssel', 'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.', 'admin.mapsKey': 'Google Maps API Key', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index b4f5a05..09b90fe 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -221,6 +221,7 @@ const en: Record = { 'settings.avatarError': 'Upload failed', 'settings.mfa.title': 'Two-factor authentication (2FA)', 'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).', + 'settings.mfa.requiredByPolicy': 'Your administrator requires two-factor authentication. Set up an authenticator app below before continuing.', 'settings.mfa.enabled': '2FA is enabled on your account.', 'settings.mfa.disabled': '2FA is not enabled.', 'settings.mfa.setup': 'Set up authenticator', @@ -365,6 +366,8 @@ const en: Record = { 'admin.tabs.settings': 'Settings', 'admin.allowRegistration': 'Allow Registration', 'admin.allowRegistrationHint': 'New users can register themselves', + 'admin.requireMfa': 'Require two-factor authentication (2FA)', + 'admin.requireMfaHint': 'Users without 2FA must complete setup in Settings before using the app.', 'admin.apiKeys': 'API Keys', 'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.', 'admin.mapsKey': 'Google Maps API Key', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 778a98c..9001789 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -211,6 +211,7 @@ const es: Record = { 'settings.saveProfile': 'Guardar perfil', 'settings.mfa.title': 'Autenticación de dos factores (2FA)', 'settings.mfa.description': 'Añade un segundo paso al iniciar sesión. Usa una app de autenticación (Google Authenticator, Authy, etc.).', + 'settings.mfa.requiredByPolicy': 'Tu administrador exige autenticación en dos factores. Configura una app de autenticación abajo antes de continuar.', 'settings.mfa.enabled': '2FA está activado en tu cuenta.', 'settings.mfa.disabled': '2FA no está activado.', 'settings.mfa.setup': 'Configurar autenticador', @@ -363,6 +364,8 @@ const es: Record = { 'admin.tabs.settings': 'Ajustes', 'admin.allowRegistration': 'Permitir el registro', 'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos', + 'admin.requireMfa': 'Exigir autenticación en dos factores (2FA)', + 'admin.requireMfaHint': 'Los usuarios sin 2FA deben completar la configuración en Ajustes antes de usar la aplicación.', 'admin.apiKeys': 'Claves API', 'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.', 'admin.mapsKey': 'Clave API de Google Maps', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 079f0c1..84130d6 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -212,6 +212,7 @@ const fr: Record = { 'settings.saveProfile': 'Enregistrer le profil', 'settings.mfa.title': 'Authentification à deux facteurs (2FA)', 'settings.mfa.description': 'Ajoute une étape supplémentaire lors de la connexion. Utilisez une application d\'authentification (Google Authenticator, Authy, etc.).', + 'settings.mfa.requiredByPolicy': 'Votre administrateur exige l\'authentification à deux facteurs. Configurez une application d\'authentification ci-dessous avant de continuer.', 'settings.mfa.enabled': '2FA est activé sur votre compte.', 'settings.mfa.disabled': '2FA n\'est pas activé.', 'settings.mfa.setup': 'Configurer l\'authentificateur', @@ -364,6 +365,8 @@ const fr: Record = { 'admin.tabs.settings': 'Paramètres', 'admin.allowRegistration': 'Autoriser les inscriptions', 'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes', + 'admin.requireMfa': 'Exiger l\'authentification à deux facteurs (2FA)', + 'admin.requireMfaHint': 'Les utilisateurs sans 2FA doivent terminer la configuration dans Paramètres avant d\'utiliser l\'application.', 'admin.apiKeys': 'Clés API', 'admin.apiKeysHint': 'Facultatif. Active les données de lieu étendues comme les photos et la météo.', 'admin.mapsKey': 'Clé API Google Maps', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 212287c..f46532b 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -187,6 +187,7 @@ const hu: Record = { 'settings.avatarError': 'Feltöltés sikertelen', 'settings.mfa.title': 'Kétfaktoros hitelesítés (2FA)', 'settings.mfa.description': 'Egy második lépést ad a bejelentkezéshez e-mail és jelszó használatakor. Használj hitelesítő alkalmazást (Google Authenticator, Authy stb.).', + 'settings.mfa.requiredByPolicy': 'A rendszergazda kétlépcsős hitelesítést ír elő. Állíts be hitelesítő alkalmazást lent, mielőtt továbblépnél.', 'settings.mfa.enabled': '2FA engedélyezve van a fiókodban.', 'settings.mfa.disabled': '2FA nincs engedélyezve.', 'settings.mfa.setup': 'Hitelesítő beállítása', @@ -364,6 +365,8 @@ const hu: Record = { 'admin.tabs.settings': 'Beállítások', 'admin.allowRegistration': 'Regisztráció engedélyezése', 'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat', + 'admin.requireMfa': 'Kétlépcsős hitelesítés (2FA) kötelezővé tétele', + 'admin.requireMfaHint': 'A 2FA nélküli felhasználóknak a Beállításokban kell befejezniük a beállítást az alkalmazás használata előtt.', 'admin.apiKeys': 'API kulcsok', 'admin.apiKeysHint': 'Opcionális. Bővített helyadatokat tesz lehetővé, például fotókat és időjárást.', 'admin.mapsKey': 'Google Maps API kulcs', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 8727080..6508727 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -187,6 +187,7 @@ const it: Record = { 'settings.avatarError': 'Impossibile caricare', 'settings.mfa.title': 'Autenticazione a due fattori (2FA)', 'settings.mfa.description': 'Aggiunge un secondo passaggio quando accedi con email e password. Usa un\'app authenticator (Google Authenticator, Authy, ecc.).', + 'settings.mfa.requiredByPolicy': 'L\'amministratore richiede l\'autenticazione a due fattori. Configura un\'app authenticator qui sotto prima di continuare.', 'settings.mfa.enabled': 'La 2FA è abilitata sul tuo account.', 'settings.mfa.disabled': 'La 2FA non è abilitata.', 'settings.mfa.setup': 'Configura authenticator', @@ -364,6 +365,8 @@ const it: Record = { 'admin.tabs.settings': 'Impostazioni', 'admin.allowRegistration': 'Consenti Registrazione', 'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente', + 'admin.requireMfa': 'Richiedi autenticazione a due fattori (2FA)', + 'admin.requireMfaHint': 'Gli utenti senza 2FA devono completare la configurazione in Impostazioni prima di usare l\'app.', 'admin.apiKeys': 'Chiavi API', 'admin.apiKeysHint': 'Opzionale. Abilita dati estesi per i luoghi come foto e meteo.', 'admin.mapsKey': 'Chiave API Google Maps', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index cfe0255..78fc8d8 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -212,6 +212,7 @@ const nl: Record = { 'settings.saveProfile': 'Profiel opslaan', 'settings.mfa.title': 'Tweefactorauthenticatie (2FA)', 'settings.mfa.description': 'Voegt een tweede stap toe bij het inloggen. Gebruik een authenticator-app (Google Authenticator, Authy, etc.).', + 'settings.mfa.requiredByPolicy': 'Je beheerder vereist tweestapsverificatie. Stel hieronder een authenticator-app in voordat je verdergaat.', 'settings.mfa.enabled': '2FA is ingeschakeld op je account.', 'settings.mfa.disabled': '2FA is niet ingeschakeld.', 'settings.mfa.setup': 'Authenticator instellen', @@ -365,6 +366,8 @@ const nl: Record = { 'admin.tabs.settings': 'Instellingen', 'admin.allowRegistration': 'Registratie toestaan', 'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren', + 'admin.requireMfa': 'Tweestapsverificatie (2FA) verplicht stellen', + 'admin.requireMfaHint': 'Gebruikers zonder 2FA moeten de installatie in Instellingen voltooien voordat ze de app kunnen gebruiken.', 'admin.apiKeys': 'API-sleutels', 'admin.apiKeysHint': 'Optioneel. Schakelt uitgebreide plaatsgegevens in zoals foto\'s en weer.', 'admin.mapsKey': 'Google Maps API-sleutel', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 5698ebe..a59c99d 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -212,6 +212,7 @@ const ru: Record = { 'settings.saveProfile': 'Сохранить профиль', 'settings.mfa.title': 'Двухфакторная аутентификация (2FA)', 'settings.mfa.description': 'Добавляет второй шаг при входе. Используйте приложение-аутентификатор (Google Authenticator, Authy и др.).', + 'settings.mfa.requiredByPolicy': 'Администратор требует двухфакторную аутентификацию. Настройте приложение-аутентификатор ниже, прежде чем продолжить.', 'settings.mfa.enabled': '2FA включена для вашего аккаунта.', 'settings.mfa.disabled': '2FA не включена.', 'settings.mfa.setup': 'Настроить аутентификатор', @@ -365,6 +366,8 @@ const ru: Record = { 'admin.tabs.settings': 'Настройки', 'admin.allowRegistration': 'Разрешить регистрацию', 'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно', + 'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)', + 'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.', 'admin.apiKeys': 'API-ключи', 'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.', 'admin.mapsKey': 'API-ключ Google Maps', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 02cd7d1..7bfc2ff 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -212,6 +212,7 @@ const zh: Record = { 'settings.saveProfile': '保存资料', 'settings.mfa.title': '双因素认证 (2FA)', 'settings.mfa.description': '登录时添加第二步验证。使用身份验证器应用(Google Authenticator、Authy 等)。', + 'settings.mfa.requiredByPolicy': '管理员要求双因素身份验证。请先完成下方的身份验证器设置后再继续。', 'settings.mfa.enabled': '您的账户已启用 2FA。', 'settings.mfa.disabled': '2FA 未启用。', 'settings.mfa.setup': '设置身份验证器', @@ -365,6 +366,8 @@ const zh: Record = { 'admin.tabs.settings': '设置', 'admin.allowRegistration': '允许注册', 'admin.allowRegistrationHint': '新用户可以自行注册', + 'admin.requireMfa': '要求双因素身份验证(2FA)', + 'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。', 'admin.apiKeys': 'API 密钥', 'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。', 'admin.mapsKey': 'Google Maps API 密钥', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 2fe5c2d..38fd59b 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -85,6 +85,7 @@ export default function AdminPage(): React.ReactElement { // Registration toggle const [allowRegistration, setAllowRegistration] = useState(true) + const [requireMfa, setRequireMfa] = useState(false) // Invite links const [invites, setInvites] = useState([]) @@ -119,7 +120,7 @@ export default function AdminPage(): React.ReactElement { const [updating, setUpdating] = useState(false) const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null) - const { user: currentUser, updateApiKeys } = useAuthStore() + const { user: currentUser, updateApiKeys, setAppRequireMfa } = useAuthStore() const navigate = useNavigate() const toast = useToast() @@ -155,6 +156,7 @@ export default function AdminPage(): React.ReactElement { try { const config = await authApi.getAppConfig() setAllowRegistration(config.allow_registration) + if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa) if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) } catch (err: unknown) { // ignore @@ -201,6 +203,18 @@ export default function AdminPage(): React.ReactElement { } } + const handleToggleRequireMfa = async (value: boolean) => { + setRequireMfa(value) + try { + await authApi.updateAppSettings({ require_mfa: value }) + setAppRequireMfa(value) + toast.success(t('common.saved')) + } catch (err: unknown) { + setRequireMfa(!value) + toast.error(getApiErrorMessage(err, t('common.error'))) + } + } + const toggleKey = (key) => { setShowKeys(prev => ({ ...prev, [key]: !prev[key] })) } @@ -706,6 +720,34 @@ export default function AdminPage(): React.ReactElement { + {/* Require 2FA for all users */} +
+
+

{t('admin.requireMfa')}

+
+
+
+
+

{t('admin.requireMfa')}

+

{t('admin.requireMfaHint')}

+
+ +
+
+
+ {/* Allowed File Types */}
diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 7ae6bd2..98a44cc 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 } from 'lucide-react' +import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle } from 'lucide-react' import { authApi, adminApi, notificationsApi } from '../api/client' import apiClient from '../api/client' import type { LucideIcon } from 'lucide-react' @@ -101,7 +101,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() @@ -193,6 +194,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 || '') @@ -652,6 +657,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 4cdbe4e..a8b1b82 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 5229b08..683f42d 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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; + + 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' }); 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;