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:
@@ -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 <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (
|
||||
appRequireMfa &&
|
||||
user &&
|
||||
!user.mfa_enabled &&
|
||||
location.pathname !== '/settings'
|
||||
) {
|
||||
return <Navigate to="/settings?mfa=required" replace />
|
||||
}
|
||||
|
||||
if (adminRequired && user && user.role !== 'admin') {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -226,6 +226,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
|
||||
'admin.allowRegistration': 'السماح بالتسجيل',
|
||||
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
|
||||
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
|
||||
'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
|
||||
'admin.apiKeys': 'مفاتيح API',
|
||||
'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
|
||||
'admin.mapsKey': 'مفتاح Google Maps API',
|
||||
|
||||
@@ -221,6 +221,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -188,6 +188,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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íč',
|
||||
|
||||
@@ -221,6 +221,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -221,6 +221,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -211,6 +211,7 @@ const es: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -212,6 +212,7 @@ const fr: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -187,6 +187,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -187,6 +187,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -212,6 +212,7 @@ const nl: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -212,6 +212,7 @@ const ru: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'admin.tabs.settings': 'Настройки',
|
||||
'admin.allowRegistration': 'Разрешить регистрацию',
|
||||
'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
|
||||
'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)',
|
||||
'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.',
|
||||
'admin.apiKeys': 'API-ключи',
|
||||
'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.',
|
||||
'admin.mapsKey': 'API-ключ Google Maps',
|
||||
|
||||
@@ -212,6 +212,7 @@ const zh: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'admin.tabs.settings': '设置',
|
||||
'admin.allowRegistration': '允许注册',
|
||||
'admin.allowRegistrationHint': '新用户可以自行注册',
|
||||
'admin.requireMfa': '要求双因素身份验证(2FA)',
|
||||
'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。',
|
||||
'admin.apiKeys': 'API 密钥',
|
||||
'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。',
|
||||
'admin.mapsKey': 'Google Maps API 密钥',
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
|
||||
// Registration toggle
|
||||
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
||||
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
||||
|
||||
// Invite links
|
||||
const [invites, setInvites] = useState<any[]>([])
|
||||
@@ -119,7 +120,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const [updating, setUpdating] = useState<boolean>(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 {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Require 2FA for all users */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.requireMfa')}</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.requireMfa')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.requireMfaHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRequireMfa(!requireMfa)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
requireMfa ? 'bg-slate-900' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
requireMfa ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed File Types */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
|
||||
@@ -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<boolean | 'blocked'>(false)
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(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 {
|
||||
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
<div
|
||||
className="flex gap-3 p-3 rounded-lg border text-sm"
|
||||
style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
|
||||
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||
{demoMode ? (
|
||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||
|
||||
@@ -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<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
@@ -38,6 +40,7 @@ interface AuthState {
|
||||
setDemoMode: (val: boolean) => void
|
||||
setHasMapsKey: (val: boolean) => void
|
||||
setServerTimezone: (tz: string) => void
|
||||
setAppRequireMfa: (val: boolean) => void
|
||||
demoLogin: () => Promise<AuthResponse>
|
||||
}
|
||||
|
||||
@@ -50,6 +53,7 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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