diff --git a/client/src/api/client.ts b/client/src/api/client.ts index fbabe99..e129b8d 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -44,7 +44,7 @@ export const authApi = { login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data), verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data), mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data), - mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data), + mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }), mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data), me: () => apiClient.get('/auth/me').then(r => r.data), updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data), diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 08820dd..b9d1fd9 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -226,6 +226,13 @@ const ar: Record = { 'settings.avatarError': 'فشل الرفع', 'settings.mfa.title': 'المصادقة الثنائية (2FA)', 'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).', + 'settings.mfa.backupTitle': 'رموز النسخ الاحتياطي', + 'settings.mfa.backupDescription': 'استخدم هذه الرموز لمرة واحدة إذا فقدت الوصول إلى تطبيق المصادقة.', + 'settings.mfa.backupWarning': 'احفظ هذه الرموز الآن. كل رمز يمكن استخدامه مرة واحدة فقط.', + 'settings.mfa.backupCopy': 'نسخ الرموز', + 'settings.mfa.backupDownload': 'تنزيل TXT', + 'settings.mfa.backupPrint': 'طباعة / PDF', + 'settings.mfa.backupCopied': 'تم نسخ رموز النسخ الاحتياطي', 'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.', 'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.', 'settings.mfa.setup': 'إعداد المصادقة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index dab6913..fd40533 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -221,6 +221,13 @@ 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.backupTitle': 'Códigos de backup', + 'settings.mfa.backupDescription': 'Use estes códigos únicos se perder acesso ao app autenticador.', + 'settings.mfa.backupWarning': 'Salve estes códigos agora. Cada código pode ser usado apenas uma vez.', + 'settings.mfa.backupCopy': 'Copiar códigos', + 'settings.mfa.backupDownload': 'Baixar TXT', + 'settings.mfa.backupPrint': 'Imprimir / PDF', + 'settings.mfa.backupCopied': 'Códigos de backup copiados', 'settings.mfa.enabled': 'O 2FA está ativado na sua conta.', 'settings.mfa.disabled': 'O 2FA não está ativado.', 'settings.mfa.setup': 'Configurar autenticador', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index daefce0..a265867 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -188,6 +188,13 @@ 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.backupTitle': 'Záložní kódy', + 'settings.mfa.backupDescription': 'Použijte tyto jednorázové kódy, pokud ztratíte přístup k autentizační aplikaci.', + 'settings.mfa.backupWarning': 'Uložte si je hned. Každý kód lze použít pouze jednou.', + 'settings.mfa.backupCopy': 'Kopírovat kódy', + 'settings.mfa.backupDownload': 'Stáhnout TXT', + 'settings.mfa.backupPrint': 'Tisk / PDF', + 'settings.mfa.backupCopied': 'Záložní kódy zkopírovány', 'settings.mfa.enabled': '2FA je pro váš účet aktivní.', 'settings.mfa.disabled': '2FA není aktivní.', 'settings.mfa.setup': 'Nastavit autentizační aplikaci', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1e5422d..3bb447b 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -221,6 +221,13 @@ 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.backupTitle': 'Backup-Codes', + 'settings.mfa.backupDescription': 'Verwende diese Einmal-Codes, wenn du keinen Zugriff mehr auf deine Authenticator-App hast.', + 'settings.mfa.backupWarning': 'Jetzt speichern. Jeder Code kann nur einmal verwendet werden.', + 'settings.mfa.backupCopy': 'Codes kopieren', + 'settings.mfa.backupDownload': 'TXT herunterladen', + 'settings.mfa.backupPrint': 'Drucken / PDF', + 'settings.mfa.backupCopied': 'Backup-Codes kopiert', 'settings.mfa.enabled': '2FA ist für dein Konto aktiv.', 'settings.mfa.disabled': '2FA ist nicht aktiviert.', 'settings.mfa.setup': 'Authenticator einrichten', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index b4f5a05..077c183 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -221,6 +221,13 @@ 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.backupTitle': 'Backup codes', + 'settings.mfa.backupDescription': 'Use these one-time backup codes if you lose access to your authenticator app.', + 'settings.mfa.backupWarning': 'Save these codes now. Each code can only be used once.', + 'settings.mfa.backupCopy': 'Copy codes', + 'settings.mfa.backupDownload': 'Download TXT', + 'settings.mfa.backupPrint': 'Print / PDF', + 'settings.mfa.backupCopied': 'Backup codes copied', 'settings.mfa.enabled': '2FA is enabled on your account.', 'settings.mfa.disabled': '2FA is not enabled.', 'settings.mfa.setup': 'Set up authenticator', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 778a98c..1c5afd1 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -211,6 +211,13 @@ 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.backupTitle': 'Códigos de respaldo', + 'settings.mfa.backupDescription': 'Usa estos códigos de un solo uso si pierdes acceso a tu app autenticadora.', + 'settings.mfa.backupWarning': 'Guárdalos ahora. Cada código solo se puede usar una vez.', + 'settings.mfa.backupCopy': 'Copiar códigos', + 'settings.mfa.backupDownload': 'Descargar TXT', + 'settings.mfa.backupPrint': 'Imprimir / PDF', + 'settings.mfa.backupCopied': 'Códigos de respaldo copiados', 'settings.mfa.enabled': '2FA está activado en tu cuenta.', 'settings.mfa.disabled': '2FA no está activado.', 'settings.mfa.setup': 'Configurar autenticador', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 079f0c1..842d3e3 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -212,6 +212,13 @@ 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.backupTitle': 'Codes de secours', + 'settings.mfa.backupDescription': 'Utilisez ces codes à usage unique si vous perdez l\'accès à votre application d\'authentification.', + 'settings.mfa.backupWarning': 'Enregistrez ces codes maintenant. Chaque code n\'est utilisable qu\'une seule fois.', + 'settings.mfa.backupCopy': 'Copier les codes', + 'settings.mfa.backupDownload': 'Télécharger TXT', + 'settings.mfa.backupPrint': 'Imprimer / PDF', + 'settings.mfa.backupCopied': 'Codes de secours copiés', 'settings.mfa.enabled': '2FA est activé sur votre compte.', 'settings.mfa.disabled': '2FA n\'est pas activé.', 'settings.mfa.setup': 'Configurer l\'authentificateur', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 212287c..2ed6062 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -187,6 +187,13 @@ 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.backupTitle': 'Tartalék kódok', + 'settings.mfa.backupDescription': 'Használd ezeket az egyszer használatos kódokat, ha elveszíted a hozzáférést a hitelesítő alkalmazásodhoz.', + 'settings.mfa.backupWarning': 'Mentsd el ezeket most. Minden kód csak egyszer használható.', + 'settings.mfa.backupCopy': 'Kódok másolása', + 'settings.mfa.backupDownload': 'TXT letöltése', + 'settings.mfa.backupPrint': 'Nyomtatás / PDF', + 'settings.mfa.backupCopied': 'Tartalék kódok másolva', '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', @@ -608,7 +615,6 @@ const hu: Record = { 'atlas.statsTab': 'Statisztikák', 'atlas.bucketTab': 'Bakancslista', 'atlas.addBucket': 'Hozzáadás a bakancslistához', - 'atlas.bucketNamePlaceholder': 'Hely vagy úti cél...', 'atlas.bucketNotesPlaceholder': 'Jegyzetek (opcionális)', 'atlas.bucketEmpty': 'A bakancslistád üres', 'atlas.bucketEmptyHint': 'Adj hozzá helyeket, ahová álmodsz eljutni', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 8727080..d917a6c 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -187,6 +187,13 @@ 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.backupTitle': 'Codici di backup', + 'settings.mfa.backupDescription': 'Usa questi codici monouso se perdi l\'accesso alla tua app authenticator.', + 'settings.mfa.backupWarning': 'Salvali adesso. Ogni codice può essere usato una sola volta.', + 'settings.mfa.backupCopy': 'Copia codici', + 'settings.mfa.backupDownload': 'Scarica TXT', + 'settings.mfa.backupPrint': 'Stampa / PDF', + 'settings.mfa.backupCopied': 'Codici di backup copiati', 'settings.mfa.enabled': 'La 2FA è abilitata sul tuo account.', 'settings.mfa.disabled': 'La 2FA non è abilitata.', 'settings.mfa.setup': 'Configura authenticator', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index cfe0255..f9e3965 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -212,6 +212,13 @@ 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.backupTitle': 'Back-upcodes', + 'settings.mfa.backupDescription': 'Gebruik deze eenmalige codes als je geen toegang meer hebt tot je authenticator-app.', + 'settings.mfa.backupWarning': 'Sla deze codes nu op. Elke code kan maar een keer worden gebruikt.', + 'settings.mfa.backupCopy': 'Codes kopiëren', + 'settings.mfa.backupDownload': 'TXT downloaden', + 'settings.mfa.backupPrint': 'Afdrukken / PDF', + 'settings.mfa.backupCopied': 'Back-upcodes gekopieerd', 'settings.mfa.enabled': '2FA is ingeschakeld op je account.', 'settings.mfa.disabled': '2FA is niet ingeschakeld.', 'settings.mfa.setup': 'Authenticator instellen', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 5698ebe..85dcc2a 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -212,6 +212,13 @@ const ru: Record = { 'settings.saveProfile': 'Сохранить профиль', 'settings.mfa.title': 'Двухфакторная аутентификация (2FA)', 'settings.mfa.description': 'Добавляет второй шаг при входе. Используйте приложение-аутентификатор (Google Authenticator, Authy и др.).', + 'settings.mfa.backupTitle': 'Резервные коды', + 'settings.mfa.backupDescription': 'Используйте эти одноразовые коды, если потеряете доступ к приложению-аутентификатору.', + 'settings.mfa.backupWarning': 'Сохраните их сейчас. Каждый код можно использовать только один раз.', + 'settings.mfa.backupCopy': 'Скопировать коды', + 'settings.mfa.backupDownload': 'Скачать TXT', + 'settings.mfa.backupPrint': 'Печать / PDF', + 'settings.mfa.backupCopied': 'Резервные коды скопированы', 'settings.mfa.enabled': '2FA включена для вашего аккаунта.', 'settings.mfa.disabled': '2FA не включена.', 'settings.mfa.setup': 'Настроить аутентификатор', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 02cd7d1..b99869e 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -212,6 +212,13 @@ const zh: Record = { 'settings.saveProfile': '保存资料', 'settings.mfa.title': '双因素认证 (2FA)', 'settings.mfa.description': '登录时添加第二步验证。使用身份验证器应用(Google Authenticator、Authy 等)。', + 'settings.mfa.backupTitle': '备用代码', + 'settings.mfa.backupDescription': '如果你无法使用身份验证器应用,可使用这些一次性备用代码登录。', + 'settings.mfa.backupWarning': '请立即保存这些代码。每个代码只能使用一次。', + 'settings.mfa.backupCopy': '复制代码', + 'settings.mfa.backupDownload': '下载 TXT', + 'settings.mfa.backupPrint': '打印 / PDF', + 'settings.mfa.backupCopied': '备用代码已复制', 'settings.mfa.enabled': '您的账户已启用 2FA。', 'settings.mfa.disabled': '2FA 未启用。', 'settings.mfa.setup': '设置身份验证器', diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index d083339..7121dfd 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -544,11 +544,11 @@ export default function LoginPage(): React.ReactElement { ) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))} - placeholder="000000" + onChange={(e: React.ChangeEvent) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))} + placeholder="000000 or XXXX-XXXX" required style={inputBase} onFocus={(e: React.FocusEvent) => e.target.style.borderColor = '#111827'} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 7ae6bd2..f045117 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -6,7 +6,7 @@ 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, Copy, Download, Printer } from 'lucide-react' import { authApi, adminApi, notificationsApi } from '../api/client' import apiClient from '../api/client' import type { LucideIcon } from 'lucide-react' @@ -18,6 +18,8 @@ interface MapPreset { url: string } +const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' + const MAP_PRESETS: MapPreset[] = [ { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, @@ -193,6 +195,66 @@ export default function SettingsPage(): React.ReactElement { const [mfaDisablePwd, setMfaDisablePwd] = useState('') const [mfaDisableCode, setMfaDisableCode] = useState('') const [mfaLoading, setMfaLoading] = useState(false) + const [backupCodes, setBackupCodes] = useState(null) + + const backupCodesText = backupCodes?.join('\n') || '' + + // Restore backup codes panel after refresh (loadUser silent fix + sessionStorage) + useEffect(() => { + if (!user?.mfa_enabled || backupCodes) return + try { + const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY) + if (!raw) return + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((x) => typeof x === 'string')) { + setBackupCodes(parsed) + } + } catch { + sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY) + } + }, [user?.mfa_enabled, backupCodes]) + + const dismissBackupCodes = (): void => { + sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY) + setBackupCodes(null) + } + + const copyBackupCodes = async (): Promise => { + if (!backupCodesText) return + try { + await navigator.clipboard.writeText(backupCodesText) + toast.success(t('settings.mfa.backupCopied')) + } catch { + toast.error(t('common.error')) + } + } + + const downloadBackupCodes = (): void => { + if (!backupCodesText) return + const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'trek-mfa-backup-codes.txt' + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + } + + const printBackupCodes = (): void => { + if (!backupCodesText) return + const html = `TREK MFA Backup Codes + +

TREK MFA Backup Codes

${new Date().toLocaleString()}

${backupCodesText}
` + const w = window.open('', '_blank', 'width=900,height=700') + if (!w) return + w.document.open() + w.document.write(html) + w.document.close() + w.focus() + w.print() + } useEffect(() => { setMapTileUrl(settings.map_tile_url || '') @@ -709,12 +771,21 @@ export default function SettingsPage(): React.ReactElement { onClick={async () => { setMfaLoading(true) try { - await authApi.mfaEnable({ code: mfaSetupCode }) + const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] } toast.success(t('settings.mfa.toastEnabled')) setMfaQr(null) setMfaSecret(null) setMfaSetupCode('') - await loadUser() + const codes = resp.backup_codes || null + if (codes?.length) { + try { + sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes)) + } catch { + /* ignore quota / private mode */ + } + } + setBackupCodes(codes) + await loadUser({ silent: true }) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { @@ -766,7 +837,9 @@ export default function SettingsPage(): React.ReactElement { toast.success(t('settings.mfa.toastDisabled')) setMfaDisablePwd('') setMfaDisableCode('') - await loadUser() + sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY) + setBackupCodes(null) + await loadUser({ silent: true }) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('common.error'))) } finally { @@ -779,6 +852,29 @@ export default function SettingsPage(): React.ReactElement { )} + + {backupCodes && backupCodes.length > 0 && ( +
+

{t('settings.mfa.backupTitle')}

+

{t('settings.mfa.backupDescription')}

+
{backupCodesText}
+

{t('settings.mfa.backupWarning')}

+
+ + + + +
+
+ )} )} diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 8387c4e..086832a 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -29,7 +29,8 @@ interface AuthState { completeMfaLogin: (mfaToken: string, code: string) => Promise register: (username: string, email: string, password: string) => Promise logout: () => void - loadUser: () => Promise + /** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */ + loadUser: (opts?: { silent?: boolean }) => Promise updateMapsKey: (key: string | null) => Promise updateApiKeys: (keys: Record) => Promise updateProfile: (profileData: Partial) => Promise @@ -129,13 +130,14 @@ export const useAuthStore = create((set, get) => ({ }) }, - loadUser: async () => { + loadUser: async (opts?: { silent?: boolean }) => { + const silent = !!opts?.silent const token = get().token if (!token) { - set({ isLoading: false }) + if (!silent) set({ isLoading: false }) return } - set({ isLoading: true }) + if (!silent) set({ isLoading: true }) try { const data = await authApi.me() set({ diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 963cff2..5d2a52c 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -394,6 +394,10 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); `); }, + () => { + // MFA backup/recovery codes + try { db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index c3ebe23..c6a5d23 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -17,6 +17,7 @@ function createTables(db: Database.Database): void { last_login DATETIME, mfa_enabled INTEGER DEFAULT 0, mfa_secret TEXT, + mfa_backup_codes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 5229b08..de2d9de 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; +import crypto from 'crypto'; import { v4 as uuid } from 'uuid'; import fetch from 'node-fetch'; import { authenticator } from 'otplib'; @@ -19,6 +20,35 @@ authenticator.options = { window: 1 }; const MFA_SETUP_TTL_MS = 15 * 60 * 1000; const mfaSetupPending = new Map(); +const MFA_BACKUP_CODE_COUNT = 10; + +function normalizeBackupCode(input: string): string { + return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); +} + +function hashBackupCode(input: string): string { + return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex'); +} + +function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] { + const codes: string[] = []; + while (codes.length < count) { + const raw = crypto.randomBytes(4).toString('hex').toUpperCase(); + const code = `${raw.slice(0, 4)}-${raw.slice(4)}`; + if (!codes.includes(code)) codes.push(code); + } + return codes; +} + +function parseBackupCodeHashes(raw: string | null | undefined): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : []; + } catch { + return []; + } +} function getPendingMfaSecret(userId: number): string | null { const row = mfaSetupPending.get(userId); @@ -41,6 +71,7 @@ function stripUserForClient(user: User): Record { openweather_api_key: _o, unsplash_api_key: _u, mfa_secret: _mf, + mfa_backup_codes: _mbc, ...rest } = user; return { @@ -645,10 +676,20 @@ router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => { return res.status(401).json({ error: 'Invalid session' }); } const secret = decryptMfaSecret(user.mfa_secret); - const tokenStr = String(code).replace(/\s/g, ''); - const ok = authenticator.verify({ token: tokenStr, secret }); - if (!ok) { - return res.status(401).json({ error: 'Invalid verification code' }); + const tokenStr = String(code).trim(); + const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret }); + if (!okTotp) { + const hashes = parseBackupCodeHashes(user.mfa_backup_codes); + const candidateHash = hashBackupCode(tokenStr); + const idx = hashes.findIndex(h => h === candidateHash); + if (idx === -1) { + return res.status(401).json({ error: 'Invalid verification code' }); + } + hashes.splice(idx, 1); + db.prepare('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + JSON.stringify(hashes), + user.id + ); } db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); const sessionToken = generateToken(user); @@ -702,14 +743,17 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => { if (!ok) { return res.status(401).json({ error: 'Invalid verification code' }); } + const backupCodes = generateBackupCodes(); + const backupHashes = backupCodes.map(hashBackupCode); const enc = encryptMfaSecret(pending); - db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( enc, + JSON.stringify(backupHashes), authReq.user.id ); mfaSetupPending.delete(authReq.user.id); writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); - res.json({ success: true, mfa_enabled: true }); + res.json({ success: true, mfa_enabled: true, backup_codes: backupCodes }); }); router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { @@ -734,7 +778,7 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re if (!ok) { return res.status(401).json({ error: 'Invalid verification code' }); } - db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( authReq.user.id ); mfaSetupPending.delete(authReq.user.id); diff --git a/server/src/types.ts b/server/src/types.ts index 495b8c4..7db6a71 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -15,6 +15,7 @@ export interface User { last_login?: string | null; mfa_enabled?: number | boolean; mfa_secret?: string | null; + mfa_backup_codes?: string | null; created_at?: string; updated_at?: string; }