fix(mfa-backup-codes): persist backup codes panel after enable and refresh

Keep MFA backup codes visible after enabling MFA by avoiding protected-route unmount during user reload (`loadUser({ silent: true })`) and restoring pending backup codes from sessionStorage until the user explicitly dismisses them.
This commit is contained in:
fgbona
2026-03-30 18:22:45 -03:00
parent 8412f303dd
commit de444bf770
20 changed files with 251 additions and 20 deletions

View File

@@ -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),

View File

@@ -226,6 +226,13 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'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': 'إعداد المصادقة',

View File

@@ -221,6 +221,13 @@ 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.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',

View File

@@ -188,6 +188,13 @@ 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.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',

View File

@@ -221,6 +221,13 @@ 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.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',

View File

@@ -221,6 +221,13 @@ 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.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',

View File

@@ -211,6 +211,13 @@ 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.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',

View File

@@ -212,6 +212,13 @@ 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.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',

View File

@@ -187,6 +187,13 @@ 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.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<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -187,6 +187,13 @@ 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.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',

View File

@@ -212,6 +212,13 @@ 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.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',

View File

@@ -212,6 +212,13 @@ const ru: Record<string, string> = {
'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': 'Настроить аутентификатор',

View File

@@ -212,6 +212,13 @@ const zh: Record<string, string> = {
'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': '设置身份验证器',

View File

@@ -544,11 +544,11 @@ export default function LoginPage(): React.ReactElement {
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="text"
inputMode="numeric"
inputMode="text"
autoComplete="one-time-code"
value={mfaCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="000000"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
placeholder="000000 or XXXX-XXXX"
required
style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}

View File

@@ -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<string[] | null>(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<void> => {
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 = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
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 {
</button>
</div>
)}
{backupCodes && backupCodes.length > 0 && (
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p>
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre>
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<Copy size={13} /> {t('settings.mfa.backupCopy')}
</button>
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<Download size={13} /> {t('settings.mfa.backupDownload')}
</button>
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<Printer size={13} /> {t('settings.mfa.backupPrint')}
</button>
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.ok')}
</button>
</div>
</div>
)}
</>
)}
</div>

View File

@@ -29,7 +29,8 @@ interface AuthState {
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
register: (username: string, email: string, password: string) => Promise<AuthResponse>
logout: () => void
loadUser: () => Promise<void>
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
loadUser: (opts?: { silent?: boolean }) => Promise<void>
updateMapsKey: (key: string | null) => Promise<void>
updateApiKeys: (keys: Record<string, string | null>) => Promise<void>
updateProfile: (profileData: Partial<User>) => Promise<void>
@@ -129,13 +130,14 @@ export const useAuthStore = create<AuthState>((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({

View File

@@ -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) {

View File

@@ -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
);

View File

@@ -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<number, { secret: string; exp: number }>();
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<string, unknown> {
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);

View File

@@ -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;
}