Merge pull request #166 from fgbona/feat/#155
feat(require-mfa): #155 enforce MFA via admin policy toggle across app access
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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -251,6 +251,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': 'إعداد المصادقة',
|
||||
@@ -413,6 +414,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',
|
||||
@@ -389,6 +390,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',
|
||||
|
||||
@@ -213,6 +213,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',
|
||||
@@ -390,6 +391,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íč',
|
||||
|
||||
@@ -246,6 +246,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',
|
||||
@@ -390,6 +391,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',
|
||||
|
||||
@@ -246,6 +246,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',
|
||||
@@ -390,6 +391,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',
|
||||
|
||||
@@ -236,6 +236,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',
|
||||
@@ -388,6 +389,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',
|
||||
|
||||
@@ -237,6 +237,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',
|
||||
@@ -389,6 +390,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',
|
||||
|
||||
@@ -212,6 +212,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',
|
||||
@@ -389,6 +390,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',
|
||||
|
||||
@@ -212,6 +212,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',
|
||||
@@ -389,6 +390,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',
|
||||
|
||||
@@ -237,6 +237,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',
|
||||
@@ -390,6 +391,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',
|
||||
|
||||
@@ -237,6 +237,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': 'Настроить аутентификатор',
|
||||
@@ -390,6 +391,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',
|
||||
|
||||
@@ -237,6 +237,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': '设置身份验证器',
|
||||
@@ -390,6 +391,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 密钥',
|
||||
|
||||
@@ -87,6 +87,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[]>([])
|
||||
@@ -121,7 +122,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()
|
||||
|
||||
@@ -157,6 +158,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
|
||||
@@ -203,6 +205,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] }))
|
||||
}
|
||||
@@ -708,6 +722,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, 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<boolean | 'blocked'>(false)
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(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 {
|
||||
<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',
|
||||
});
|
||||
}
|
||||
@@ -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<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);
|
||||
@@ -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' });
|
||||
|
||||
@@ -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