diff --git a/client/src/api/client.ts b/client/src/api/client.ts index a55b7e0..279ef67 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -42,6 +42,10 @@ export const authApi = { register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data), validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data), 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), + 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), updateApiKeys: (data: Record) => apiClient.put('/auth/me/api-keys', data).then(r => r.data), diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 13fdd35..506122d 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -173,6 +173,22 @@ const de: Record = { 'settings.avatarUploaded': 'Profilbild aktualisiert', 'settings.avatarRemoved': 'Profilbild entfernt', '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.enabled': '2FA ist für dein Konto aktiv.', + 'settings.mfa.disabled': '2FA ist nicht aktiviert.', + 'settings.mfa.setup': 'Authenticator einrichten', + 'settings.mfa.scanQr': 'QR-Code mit der App scannen oder den Schlüssel manuell eingeben.', + 'settings.mfa.secretLabel': 'Geheimer Schlüssel (manuell)', + 'settings.mfa.codePlaceholder': '6-stelliger Code', + 'settings.mfa.enable': '2FA aktivieren', + 'settings.mfa.cancelSetup': 'Abbrechen', + 'settings.mfa.disableTitle': '2FA deaktivieren', + 'settings.mfa.disableHint': 'Passwort und einen aktuellen Code aus der Authenticator-App eingeben.', + 'settings.mfa.disable': '2FA deaktivieren', + 'settings.mfa.toastEnabled': 'Zwei-Faktor-Authentifizierung aktiviert', + 'settings.mfa.toastDisabled': 'Zwei-Faktor-Authentifizierung deaktiviert', + 'settings.mfa.demoBlocked': 'In der Demo nicht verfügbar', // Login 'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.', @@ -217,6 +233,13 @@ const de: Record = { 'login.oidcSignIn': 'Anmelden mit {name}', 'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.', 'login.demoHint': 'Demo ausprobieren — ohne Registrierung', + 'login.mfaTitle': 'Zwei-Faktor-Authentifizierung', + 'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.', + 'login.mfaCodeLabel': 'Bestätigungscode', + 'login.mfaCodeRequired': 'Bitte den Code aus der Authenticator-App eingeben.', + 'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.', + 'login.mfaBack': '← Zurück zur Anmeldung', + 'login.mfaVerify': 'Bestätigen', // Register 'register.passwordMismatch': 'Passwörter stimmen nicht überein', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index a82f2ac..c6a1300 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -173,6 +173,22 @@ const en: Record = { 'settings.avatarUploaded': 'Profile picture updated', 'settings.avatarRemoved': 'Profile picture removed', '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.enabled': '2FA is enabled on your account.', + 'settings.mfa.disabled': '2FA is not enabled.', + 'settings.mfa.setup': 'Set up authenticator', + 'settings.mfa.scanQr': 'Scan this QR code with your app, or enter the secret manually.', + 'settings.mfa.secretLabel': 'Secret key (manual entry)', + 'settings.mfa.codePlaceholder': '6-digit code', + 'settings.mfa.enable': 'Enable 2FA', + 'settings.mfa.cancelSetup': 'Cancel', + 'settings.mfa.disableTitle': 'Disable 2FA', + 'settings.mfa.disableHint': 'Enter your account password and a current code from your authenticator.', + 'settings.mfa.disable': 'Disable 2FA', + 'settings.mfa.toastEnabled': 'Two-factor authentication enabled', + 'settings.mfa.toastDisabled': 'Two-factor authentication disabled', + 'settings.mfa.demoBlocked': 'Not available in demo mode', // Login 'login.error': 'Login failed. Please check your credentials.', @@ -217,6 +233,13 @@ const en: Record = { 'login.oidcSignIn': 'Sign in with {name}', 'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.', 'login.demoHint': 'Try the demo — no registration needed', + 'login.mfaTitle': 'Two-factor authentication', + 'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.', + 'login.mfaCodeLabel': 'Verification code', + 'login.mfaCodeRequired': 'Enter the code from your authenticator app.', + 'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.', + 'login.mfaBack': '← Back to sign in', + 'login.mfaVerify': 'Verify', // Register 'register.passwordMismatch': 'Passwords do not match', diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index ecc494d..4bba457 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -4,7 +4,7 @@ import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' import { authApi } from '../api/client' -import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react' +import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound } from 'lucide-react' interface AppConfig { has_users: boolean @@ -28,7 +28,7 @@ export default function LoginPage(): React.ReactElement { const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) - const { login, register, demoLogin } = useAuthStore() + const { login, register, demoLogin, completeMfaLogin } = useAuthStore() const { setLanguageLocal } = useSettingsStore() const navigate = useNavigate() @@ -101,18 +101,39 @@ export default function LoginPage(): React.ReactElement { } const [showTakeoff, setShowTakeoff] = useState(false) + const [mfaStep, setMfaStep] = useState(false) + const [mfaToken, setMfaToken] = useState('') + const [mfaCode, setMfaCode] = useState('') const handleSubmit = async (e: React.FormEvent): Promise => { e.preventDefault() setError('') setIsLoading(true) try { + if (mode === 'login' && mfaStep) { + if (!mfaCode.trim()) { + setError(t('login.mfaCodeRequired')) + setIsLoading(false) + return + } + await completeMfaLogin(mfaToken, mfaCode) + setShowTakeoff(true) + setTimeout(() => navigate('/dashboard'), 2600) + return + } if (mode === 'register') { if (!username.trim()) { setError('Username is required'); setIsLoading(false); return } if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return } await register(username, email, password, inviteToken || undefined) } else { - await login(email, password) + const result = await login(email, password) + if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) { + setMfaToken(result.mfa_token) + setMfaStep(true) + setMfaCode('') + setIsLoading(false) + return + } } setShowTakeoff(true) setTimeout(() => navigate('/dashboard'), 2600) @@ -489,10 +510,18 @@ export default function LoginPage(): React.ReactElement { ) : ( <>

- {mode === 'register' ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) : t('login.title')} + {mode === 'login' && mfaStep + ? t('login.mfaTitle') + : mode === 'register' + ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) + : t('login.title')}

- {mode === 'register' ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) : t('login.subtitle')} + {mode === 'login' && mfaStep + ? t('login.mfaSubtitle') + : mode === 'register' + ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) + : t('login.subtitle')}

@@ -502,6 +531,35 @@ export default function LoginPage(): React.ReactElement { )} + {mode === 'login' && mfaStep && ( +
+ +
+ + ) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder="000000" + required + style={inputBase} + onFocus={(e: React.FocusEvent) => e.target.style.borderColor = '#111827'} + onBlur={(e: React.FocusEvent) => e.target.style.borderColor = '#e5e7eb'} + /> +
+

{t('login.mfaHint')}

+ +
+ )} + {/* Username (register only) */} {mode === 'register' && (
@@ -519,6 +577,7 @@ export default function LoginPage(): React.ReactElement { )} {/* Email */} + {!(mode === 'login' && mfaStep) && (
@@ -531,8 +590,10 @@ export default function LoginPage(): React.ReactElement { />
+ )} {/* Password */} + {!(mode === 'login' && mfaStep) && (
@@ -551,6 +612,7 @@ export default function LoginPage(): React.ReactElement {
+ )} @@ -572,7 +634,7 @@ export default function LoginPage(): React.ReactElement { {showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (

{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '} - diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 4c6035d..f4c7dee 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 } from 'lucide-react' +import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react' import { authApi, adminApi } from '../api/client' import type { LucideIcon } from 'lucide-react' import type { UserWithOidc } from '../types' @@ -46,7 +46,7 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem } export default function SettingsPage(): React.ReactElement { - const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore() + const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore() const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const avatarInputRef = React.useRef(null) const { settings, updateSetting, updateSettings } = useSettingsStore() @@ -79,6 +79,13 @@ export default function SettingsPage(): React.ReactElement { }).catch(() => {}) }, []) + const [mfaQr, setMfaQr] = useState(null) + const [mfaSecret, setMfaSecret] = useState(null) + const [mfaSetupCode, setMfaSetupCode] = useState('') + const [mfaDisablePwd, setMfaDisablePwd] = useState('') + const [mfaDisableCode, setMfaDisableCode] = useState('') + const [mfaLoading, setMfaLoading] = useState(false) + useEffect(() => { setMapTileUrl(settings.map_tile_url || '') setDefaultLat(settings.default_lat || 48.8566) @@ -453,6 +460,145 @@ export default function SettingsPage(): React.ReactElement {

)} + {/* MFA */} +
+
+ +

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

+
+
+

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

+ {demoMode ? ( +

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

+ ) : ( + <> +

+ {user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')} +

+ + {!user?.mfa_enabled && !mfaQr && ( + + )} + + {!user?.mfa_enabled && mfaQr && ( +
+

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

+ +
+ + {mfaSecret} +
+ setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder={t('settings.mfa.codePlaceholder')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" + /> +
+ + +
+
+ )} + + {user?.mfa_enabled && ( +
+

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

+

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

+ setMfaDisablePwd(e.target.value)} + placeholder={t('settings.currentPassword')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" + /> + setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder={t('settings.mfa.codePlaceholder')} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" + /> + +
+ )} + + )} +
+
+
{user?.avatar_url ? ( diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 2b037bc..1ce5211 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -9,6 +9,8 @@ interface AuthResponse { token: string } +export type LoginResult = AuthResponse | { mfa_required: true; mfa_token: string } + interface AvatarResponse { avatar_url: string } @@ -22,7 +24,8 @@ interface AuthState { demoMode: boolean hasMapsKey: boolean - login: (email: string, password: string) => Promise + login: (email: string, password: string) => Promise + completeMfaLogin: (mfaToken: string, code: string) => Promise register: (username: string, email: string, password: string) => Promise logout: () => void loadUser: () => Promise @@ -48,7 +51,11 @@ export const useAuthStore = create((set, get) => ({ login: async (email: string, password: string) => { set({ isLoading: true, error: null }) try { - const data = await authApi.login({ email, password }) + const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string } + if (data.mfa_required && data.mfa_token) { + set({ isLoading: false, error: null }) + return { mfa_required: true as const, mfa_token: data.mfa_token } + } localStorage.setItem('auth_token', data.token) set({ user: data.user, @@ -58,7 +65,7 @@ export const useAuthStore = create((set, get) => ({ error: null, }) connect(data.token) - return data + return data as AuthResponse } catch (err: unknown) { const error = getApiErrorMessage(err, 'Login failed') set({ isLoading: false, error }) @@ -66,6 +73,27 @@ export const useAuthStore = create((set, get) => ({ } }, + completeMfaLogin: async (mfaToken: string, code: string) => { + set({ isLoading: true, error: null }) + try { + const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') }) + localStorage.setItem('auth_token', data.token) + set({ + user: data.user, + token: data.token, + isAuthenticated: true, + isLoading: false, + error: null, + }) + connect(data.token) + return data as AuthResponse + } catch (err: unknown) { + const error = getApiErrorMessage(err, 'Verification failed') + set({ isLoading: false, error }) + throw new Error(error) + } + }, + register: async (username: string, email: string, password: string, invite_token?: string) => { set({ isLoading: true, error: null }) try { diff --git a/client/src/types.ts b/client/src/types.ts index b0c7a32..7bc49dd 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -8,6 +8,8 @@ export interface User { avatar_url: string | null maps_api_key: string | null created_at: string + /** Present after load; true when TOTP MFA is enabled for password login */ + mfa_enabled?: boolean } export interface Trip { diff --git a/server/package-lock.json b/server/package-lock.json index 7b89c96..d8e2f28 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -19,6 +19,8 @@ "multer": "^2.1.1", "node-cron": "^4.2.1", "node-fetch": "^2.7.0", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "tsx": "^4.21.0", "typescript": "^6.0.2", "unzipper": "^0.12.3", @@ -35,6 +37,7 @@ "@types/multer": "^2.1.0", "@types/node": "^25.5.0", "@types/node-cron": "^3.0.11", + "@types/qrcode": "^1.5.5", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", @@ -457,6 +460,56 @@ "node": ">=18" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@types/archiver": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", @@ -600,6 +653,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -697,6 +760,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -979,9 +1066,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1098,6 +1185,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1129,6 +1225,35 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/compress-commons": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz", @@ -1252,6 +1377,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1304,6 +1438,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1384,6 +1524,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1595,6 +1741,19 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1662,6 +1821,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1757,9 +1925,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1952,6 +2120,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2084,6 +2261,18 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", @@ -2436,6 +2625,53 @@ "wrappy": "1" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2445,16 +2681,25 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -2464,6 +2709,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2527,6 +2781,23 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -2611,9 +2882,9 @@ "license": "MIT" }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -2644,6 +2915,21 @@ "node": ">=8.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2736,6 +3022,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2909,6 +3201,32 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -2989,6 +3307,14 @@ "b4a": "^1.6.4" } }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3188,6 +3514,26 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/server/package.json b/server/package.json index 42cb242..fdd7b37 100644 --- a/server/package.json +++ b/server/package.json @@ -17,6 +17,8 @@ "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", "node-cron": "^4.2.1", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "node-fetch": "^2.7.0", "tsx": "^4.21.0", "typescript": "^6.0.2", @@ -34,6 +36,7 @@ "@types/multer": "^2.1.0", "@types/node": "^25.5.0", "@types/node-cron": "^3.0.11", + "@types/qrcode": "^1.5.5", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index a30a96d..b62b495 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -194,6 +194,8 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {} }, () => { + try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {} + try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {} try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch {} }, () => { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 9670ecb..425a914 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -15,6 +15,8 @@ function createTables(db: Database.Database): void { oidc_sub TEXT, oidc_issuer TEXT, last_login DATETIME, + mfa_enabled INTEGER DEFAULT 0, + mfa_secret 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 a986e0f..1a5fae1 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -6,11 +6,43 @@ import path from 'path'; import fs from 'fs'; import { v4 as uuid } from 'uuid'; import fetch from 'node-fetch'; +import { authenticator } from 'otplib'; +import QRCode from 'qrcode'; import { db } from '../db/database'; import { authenticate, demoUploadBlock } from '../middleware/auth'; import { JWT_SECRET } from '../config'; +import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto'; import { AuthRequest, User } from '../types'; +authenticator.options = { window: 1 }; + +const MFA_SETUP_TTL_MS = 15 * 60 * 1000; +const mfaSetupPending = new Map(); + +function getPendingMfaSecret(userId: number): string | null { + const row = mfaSetupPending.get(userId); + if (!row || Date.now() > row.exp) { + mfaSetupPending.delete(userId); + return null; + } + return row.secret; +} + +function stripUserForClient(user: User): Record { + const { + password_hash: _p, + maps_api_key: _m, + openweather_api_key: _o, + unsplash_api_key: _u, + mfa_secret: _mf, + ...rest + } = user; + return { + ...rest, + mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true), + }; +} + const router = express.Router(); const avatarDir = path.join(__dirname, '../../uploads/avatars'); @@ -124,7 +156,7 @@ router.post('/demo-login', (_req: Request, res: Response) => { const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined; if (!user) return res.status(500).json({ error: 'Demo user not found' }); const token = generateToken(user); - const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user; + const safe = stripUserForClient(user) as Record; res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } }); }); @@ -193,7 +225,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { 'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)' ).run(username, email, password_hash, role); - const user = { id: result.lastInsertRowid, username, email, role, avatar: null }; + const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false }; const token = generateToken(user); // Atomically increment invite token usage (prevents race condition) @@ -234,24 +266,34 @@ router.post('/login', authLimiter, (req: Request, res: Response) => { return res.status(401).json({ error: 'Invalid email or password' }); } + if (user.mfa_enabled === 1 || user.mfa_enabled === true) { + const mfa_token = jwt.sign( + { id: Number(user.id), purpose: 'mfa_login' }, + JWT_SECRET, + { expiresIn: '5m' } + ); + return res.json({ mfa_required: true, mfa_token }); + } + db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); const token = generateToken(user); - const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user; + const userSafe = stripUserForClient(user) as Record; - res.json({ token, user: { ...userWithoutSensitive, avatar_url: avatarUrl(user) } }); + res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } }); }); router.get('/me', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const user = db.prepare( - 'SELECT id, username, email, role, avatar, oidc_issuer, created_at FROM users WHERE id = ?' + 'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled FROM users WHERE id = ?' ).get(authReq.user.id) as User | undefined; if (!user) { return res.status(404).json({ error: 'User not found' }); } - res.json({ user: { ...user, avatar_url: avatarUrl(user) } }); + const base = stripUserForClient(user as User) as Record; + res.json({ user: { ...base, avatar_url: avatarUrl(user) } }); }); router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { @@ -321,10 +363,11 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => { ); const updated = db.prepare( - 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?' - ).get(authReq.user.id) as Pick | undefined; + 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?' + ).get(authReq.user.id) as Pick | undefined; - res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); + const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; + res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); }); router.put('/me/settings', authenticate, (req: Request, res: Response) => { @@ -368,10 +411,11 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => { } const updated = db.prepare( - 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?' - ).get(authReq.user.id) as Pick | undefined; + 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?' + ).get(authReq.user.id) as Pick | undefined; - res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); + const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; + res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); }); router.get('/me/settings', authenticate, (req: Request, res: Response) => { @@ -551,30 +595,107 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => { }); }); -// GitHub releases proxy (cached, avoids client-side rate limits) -let releasesCache: { data: unknown[]; fetchedAt: number } | null = null; -const RELEASES_CACHE_TTL = 30 * 60 * 1000; - -router.get('/github-releases', authenticate, async (req: Request, res: Response) => { - const page = parseInt(req.query.page as string) || 1; - const perPage = Math.min(parseInt(req.query.per_page as string) || 10, 30); - - if (page === 1 && releasesCache && Date.now() - releasesCache.fetchedAt < RELEASES_CACHE_TTL) { - return res.json(releasesCache.data.slice(0, perPage)); +router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => { + const { mfa_token, code } = req.body as { mfa_token?: string; code?: string }; + if (!mfa_token || !code) { + return res.status(400).json({ error: 'Verification token and code are required' }); } - try { - const resp = await fetch( - `https://api.github.com/repos/mauriceboe/NOMAD/releases?per_page=${perPage}&page=${page}`, - { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } - ); - if (!resp.ok) return res.json([]); - const data = await resp.json(); - if (page === 1) releasesCache = { data, fetchedAt: Date.now() }; - res.json(data); + const decoded = jwt.verify(mfa_token, JWT_SECRET) as { id: number; purpose?: string }; + if (decoded.purpose !== 'mfa_login') { + return res.status(401).json({ error: 'Invalid verification token' }); + } + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id) as User | undefined; + if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) { + 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' }); + } + db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); + const sessionToken = generateToken(user); + const userSafe = stripUserForClient(user) as Record; + res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } }); } catch { - res.status(500).json({ error: 'Failed to fetch releases' }); + return res.status(401).json({ error: 'Invalid or expired verification token' }); } }); +router.post('/mfa/setup', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') { + return res.status(403).json({ error: 'MFA is not available in demo mode.' }); + } + const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined; + if (row?.mfa_enabled) { + return res.status(400).json({ error: 'MFA is already enabled' }); + } + const secret = authenticator.generateSecret(); + mfaSetupPending.set(authReq.user.id, { secret, exp: Date.now() + MFA_SETUP_TTL_MS }); + const otpauth_url = authenticator.keyuri(authReq.user.email, 'NOMAD', secret); + QRCode.toDataURL(otpauth_url) + .then((qr_data_url: string) => { + res.json({ secret, otpauth_url, qr_data_url }); + }) + .catch(() => { + res.status(500).json({ error: 'Could not generate QR code' }); + }); +}); + +router.post('/mfa/enable', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { code } = req.body as { code?: string }; + if (!code) { + return res.status(400).json({ error: 'Verification code is required' }); + } + const pending = getPendingMfaSecret(authReq.user.id); + if (!pending) { + return res.status(400).json({ error: 'No MFA setup in progress. Start the setup again.' }); + } + const tokenStr = String(code).replace(/\s/g, ''); + const ok = authenticator.verify({ token: tokenStr, secret: pending }); + if (!ok) { + return res.status(401).json({ error: 'Invalid verification code' }); + } + const enc = encryptMfaSecret(pending); + db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + enc, + authReq.user.id + ); + mfaSetupPending.delete(authReq.user.id); + res.json({ success: true, mfa_enabled: true }); +}); + +router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { + const authReq = req as AuthRequest; + 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 { password, code } = req.body as { password?: string; code?: string }; + if (!password || !code) { + return res.status(400).json({ error: 'Password and authenticator code are required' }); + } + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authReq.user.id) as User | undefined; + if (!user?.mfa_enabled || !user.mfa_secret) { + return res.status(400).json({ error: 'MFA is not enabled' }); + } + if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: 'Incorrect password' }); + } + 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' }); + } + db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + authReq.user.id + ); + mfaSetupPending.delete(authReq.user.id); + res.json({ success: true, mfa_enabled: false }); +}); + export default router; diff --git a/server/src/services/mfaCrypto.ts b/server/src/services/mfaCrypto.ts new file mode 100644 index 0000000..748f9bd --- /dev/null +++ b/server/src/services/mfaCrypto.ts @@ -0,0 +1,25 @@ +import crypto from 'crypto'; +import { JWT_SECRET } from '../config'; + +function getKey(): Buffer { + return crypto.createHash('sha256').update(`${JWT_SECRET}:mfa:v1`).digest(); +} + +/** Encrypt TOTP secret for storage in SQLite. */ +export function encryptMfaSecret(plain: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString('base64'); +} + +export function decryptMfaSecret(blob: string): string { + const buf = Buffer.from(blob, 'base64'); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const enc = buf.subarray(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8'); +} diff --git a/server/src/types.ts b/server/src/types.ts index b9f2142..495b8c4 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -13,6 +13,8 @@ export interface User { oidc_sub?: string | null; oidc_issuer?: string | null; last_login?: string | null; + mfa_enabled?: number | boolean; + mfa_secret?: string | null; created_at?: string; updated_at?: string; }