feat/mfa: Added multifactor authentication.

This commit is contained in:
fgbona
2026-03-27 23:29:37 -03:00
parent 1a992b7b4e
commit b6d927a3d6
17 changed files with 1036 additions and 42 deletions

View File

@@ -41,6 +41,10 @@ apiClient.interceptors.response.use(
export const authApi = {
register: (data: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).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<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),

View File

@@ -164,6 +164,22 @@ const de: Record<string, string> = {
'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.',
@@ -207,6 +223,13 @@ const de: Record<string, string> = {
'login.demoFailed': 'Demo-Login fehlgeschlagen',
'login.oidcSignIn': 'Anmelden mit {name}',
'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',

View File

@@ -164,6 +164,22 @@ const en: Record<string, string> = {
'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.',
@@ -207,6 +223,13 @@ const en: Record<string, string> = {
'login.demoFailed': 'Demo login failed',
'login.oidcSignIn': 'Sign in with {name}',
'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',

View File

@@ -4,7 +4,7 @@ import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { 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
@@ -25,7 +25,7 @@ export default function LoginPage(): React.ReactElement {
const [error, setError] = useState<string>('')
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const { login, register, demoLogin } = useAuthStore()
const { login, register, demoLogin, completeMfaLogin } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
@@ -83,18 +83,39 @@ export default function LoginPage(): React.ReactElement {
}
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
const [mfaStep, setMfaStep] = useState(false)
const [mfaToken, setMfaToken] = useState('')
const [mfaCode, setMfaCode] = useState('')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
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)
} 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)
@@ -435,10 +456,18 @@ export default function LoginPage(): React.ReactElement {
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
{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')}
</h2>
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
{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')}
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -448,6 +477,35 @@ export default function LoginPage(): React.ReactElement {
</div>
)}
{mode === 'login' && mfaStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
<div style={{ position: 'relative' }}>
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={mfaCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="000000"
required
style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
<p style={{ fontSize: 12, color: '#9ca3af', marginTop: 8 }}>{t('login.mfaHint')}</p>
<button
type="button"
onClick={() => { setMfaStep(false); setMfaToken(''); setMfaCode(''); setError('') }}
style={{ marginTop: 8, background: 'none', border: 'none', color: '#6b7280', fontSize: 13, cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}
>
{t('login.mfaBack')}
</button>
</div>
)}
{/* Username (register only) */}
{mode === 'register' && (
<div>
@@ -465,6 +523,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Email */}
{!(mode === 'login' && mfaStep) && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
<div style={{ position: 'relative' }}>
@@ -477,8 +536,10 @@ export default function LoginPage(): React.ReactElement {
/>
</div>
</div>
)}
{/* Password */}
{!(mode === 'login' && mfaStep) && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
<div style={{ position: 'relative' }}>
@@ -497,6 +558,7 @@ export default function LoginPage(): React.ReactElement {
</button>
</div>
</div>
)}
<button type="submit" disabled={isLoading} style={{
marginTop: 4, width: '100%', padding: '12px', background: '#111827', color: 'white',
@@ -508,8 +570,8 @@ export default function LoginPage(): React.ReactElement {
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
>
{isLoading
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : t('login.signIn')}</>
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
}
</button>
</form>
@@ -518,7 +580,7 @@ export default function LoginPage(): React.ReactElement {
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError('') }}
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
style={{ background: 'none', border: 'none', color: '#111827', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13 }}>
{mode === 'login' ? t('login.register') : t('login.signIn')}
</button>

View File

@@ -6,7 +6,7 @@ import { 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<boolean | 'blocked'>(false)
const avatarInputRef = React.useRef<HTMLInputElement>(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
@@ -72,6 +72,13 @@ export default function SettingsPage(): React.ReactElement {
const [newPassword, setNewPassword] = useState<string>('')
const [confirmPassword, setConfirmPassword] = useState<string>('')
const [mfaQr, setMfaQr] = useState<string | null>(null)
const [mfaSecret, setMfaSecret] = useState<string | null>(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)
@@ -447,6 +454,145 @@ export default function SettingsPage(): React.ReactElement {
</div>
</div>
{/* MFA */}
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
<div className="flex items-center gap-2 mb-3">
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
</div>
<div className="space-y-3">
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
{demoMode ? (
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
</p>
{!user?.mfa_enabled && !mfaQr && (
<button
type="button"
disabled={mfaLoading}
onClick={async () => {
setMfaLoading(true)
try {
const data = await authApi.mfaSetup() as { qr_data_url: string; secret: string }
setMfaQr(data.qr_data_url)
setMfaSecret(data.secret)
setMfaSetupCode('')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
>
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
{t('settings.mfa.setup')}
</button>
)}
{!user?.mfa_enabled && mfaQr && (
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
<img src={mfaQr} alt="" className="rounded-lg border mx-auto block" style={{ maxWidth: 200, borderColor: 'var(--border-primary)' }} />
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
</div>
<input
type="text"
inputMode="numeric"
value={mfaSetupCode}
onChange={(e) => 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"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={mfaLoading || mfaSetupCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
await authApi.mfaEnable({ code: mfaSetupCode })
toast.success(t('settings.mfa.toastEnabled'))
setMfaQr(null)
setMfaSecret(null)
setMfaSetupCode('')
await loadUser()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
>
{t('settings.mfa.enable')}
</button>
<button
type="button"
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
className="px-4 py-2 rounded-lg text-sm border"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
>
{t('settings.mfa.cancelSetup')}
</button>
</div>
</div>
)}
{user?.mfa_enabled && (
<div className="space-y-3">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
<input
type="password"
value={mfaDisablePwd}
onChange={(e) => setMfaDisablePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<input
type="text"
inputMode="numeric"
value={mfaDisableCode}
onChange={(e) => 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"
/>
<button
type="button"
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
toast.success(t('settings.mfa.toastDisabled'))
setMfaDisablePwd('')
setMfaDisableCode('')
await loadUser()
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
>
{t('settings.mfa.disable')}
</button>
</div>
)}
</>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div style={{ position: 'relative', flexShrink: 0 }}>
{user?.avatar_url ? (

View File

@@ -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<AuthResponse>
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
register: (username: string, email: string, password: string) => Promise<AuthResponse>
logout: () => void
loadUser: () => Promise<void>
@@ -48,7 +51,11 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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<AuthState>((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) => {
set({ isLoading: true, error: null })
try {

View File

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

134
scripts/install-server-deps.sh Executable file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Install server/node_modules on Linux (e.g. RHEL 8) when:
# - `node` points to NSolid → node-gyp downloads wrong headers (403)
# - GLIBC < 2.29 → better-sqlite3 prebuild fails → must compile from source
# - Python 3.6 (default on EL8) → node-gyp 12+ requires Python 3.8+ for its gyp scripts
# - GCC 8 (default g++ on EL8) → better-sqlite3 needs -std=c++20 (GCC 10+); use gcc-toolset
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT/server"
# node-gyp: pick Python 3.8+ (required; 3.6 throws SyntaxError on walrus operator in gyp)
pick_python38() {
local py
if [[ -n "${PYTHON:-}" && -x "${PYTHON}" ]]; then
if "${PYTHON}" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' 2>/dev/null; then
echo "$PYTHON"
return 0
fi
fi
for py in \
/usr/bin/python3.12 /usr/bin/python3.11 /usr/bin/python3.10 /usr/bin/python3.9 /usr/bin/python3.8 \
/usr/local/bin/python3.11 /usr/local/bin/python3.10 /usr/local/bin/python3.9 \
"$(command -v python3 2>/dev/null || true)"; do
[[ -z "$py" || ! -x "$py" ]] && continue
if "$py" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' 2>/dev/null; then
echo "$py"
return 0
fi
done
return 1
}
if ! PY="$(pick_python38)"; then
echo "ERROR: No Python 3.8+ found. node-gyp (used by better-sqlite3) needs Python ≥ 3.8."
echo "On RHEL 8 / AlmaLinux 8:"
echo " sudo dnf install -y python39 gcc-c++ make"
echo " export PYTHON=/usr/bin/python3.9"
echo " $0"
exit 1
fi
export PYTHON="$PY"
export npm_config_python="$PY"
echo "Using PYTHON=$PYTHON ($("$PYTHON" -c 'import sys; print(sys.version.split()[0])' 2>/dev/null || echo unknown))"
# Optional: force a real Node.js binary (bypasses NSolid on PATH), e.g.
# NODE_BINARY=/opt/nodejs/bin/node ../scripts/install-server-deps.sh
# nvm stores versions as v22.x.x — do NOT use .../versions/node/22/bin/node (wrong).
if [[ -n "${NODE_BINARY:-}" ]]; then
if [[ ! -x "$NODE_BINARY" ]]; then
echo "ERROR: NODE_BINARY is not executable: $NODE_BINARY"
echo "Hint: nvm path looks like: \$HOME/.nvm/versions/node/v22.21.1/bin/node"
exit 1
fi
export PATH="$(dirname "$NODE_BINARY"):$PATH"
hash -r 2>/dev/null || true
fi
# Load nvm if present so Node from nvm wins over /usr/bin/nsolid (must run before `node` check)
if [[ -z "${NODE_BINARY:-}" ]]; then
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [[ -s "$NVM_DIR/nvm.sh" ]]; then
# shellcheck source=/dev/null
. "$NVM_DIR/nvm.sh"
if [[ -f "$ROOT/server/.nvmrc" ]]; then
nvm install
nvm use
else
nvm install 22
nvm use 22
fi
hash -r 2>/dev/null || true
fi
fi
NODE_BIN="$(command -v node 2>/dev/null || true)"
if [[ -z "$NODE_BIN" ]]; then
echo "ERROR: \`node\` not found. Install Node.js (nvm recommended) or set NODE_BINARY=/path/to/node"
exit 1
fi
BASE="$(basename "$(readlink -f "$NODE_BIN" 2>/dev/null || echo "$NODE_BIN")")"
if [[ "$BASE" == "nsolid" ]] || [[ "$NODE_BIN" == *nsolid* ]] || [[ "$(readlink -f "$NODE_BIN" 2>/dev/null || true)" == *nsolid* ]]; then
echo "ERROR: \`node\` still resolves to NSolid: $NODE_BIN"
echo " node-gyp then requests NSolid header tarballs and often gets HTTP 403."
echo ""
echo "Try one of:"
echo " 1) source ~/.nvm/nvm.sh && cd $ROOT/server && nvm use && $0"
echo " 2) NODE_BINARY=\$(ls -d \"\$HOME/.nvm/versions/node/v22\"*\"/bin/node\" 2>/dev/null | head -1) $0"
exit 1
fi
echo "Using node: $NODE_BIN ($("$NODE_BIN" -p 'process.version'))"
# better-sqlite3 native build uses -std=c++20; GCC 8 only knows -std=c++2a — need gcc-toolset-12+ on RHEL 8
gpp_supports_cxx20() {
local tmp
tmp="$(mktemp)"
if echo 'int main(){}' | g++ -std=c++20 -x c++ - -o "$tmp" 2>/dev/null; then
rm -f "$tmp"
return 0
fi
rm -f "$tmp"
return 1
}
if ! gpp_supports_cxx20; then
for ts in gcc-toolset-13 gcc-toolset-12 gcc-toolset-11; do
en="/opt/rh/${ts}/enable"
if [[ -f "$en" ]]; then
# shellcheck source=/dev/null
. "$en"
if gpp_supports_cxx20; then
echo "Using newer g++ for C++20: $(command -v g++) ($($(command -v g++) -dumpversion))"
break
fi
fi
done
fi
if ! gpp_supports_cxx20; then
echo "ERROR: \`g++\` does not support -std=c++20 (better-sqlite3 needs GCC 10+)."
echo "On RHEL 8 / AlmaLinux 8 install a toolchain and re-run this script:"
echo " sudo dnf install -y gcc-toolset-12-gcc-c++ make"
echo " source /opt/rh/gcc-toolset-12/enable"
echo " $0"
exit 1
fi
echo "Also required on EL8: sudo dnf install -y gcc-c++ make (if not already)"
echo ""
# Prefer compiling native addons from source when prebuilds do not load (older GLIBC)
npm install --build-from-source "$@"

1
server/.npmrc Normal file
View File

@@ -0,0 +1 @@
# node-gyp reads PYTHON from the environment; do not set deprecated keys here (npm 10+ warns on unknown keys).

1
server/.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

421
server/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "nomad-server",
"version": "2.6.0",
"version": "2.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-server",
"version": "2.6.0",
"version": "2.6.1",
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
@@ -19,6 +19,8 @@
"multer": "^1.4.5-lts.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",
@@ -592,6 +645,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",
@@ -677,6 +740,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",
@@ -959,9 +1046,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": {
@@ -1078,6 +1165,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",
@@ -1109,6 +1205,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",
@@ -1262,6 +1387,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",
@@ -1314,6 +1448,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",
@@ -1394,6 +1534,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",
@@ -1605,6 +1751,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",
@@ -1672,6 +1831,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",
@@ -1767,9 +1935,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"
@@ -1962,6 +2130,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",
@@ -2094,6 +2271,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",
@@ -2458,6 +2647,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",
@@ -2467,16 +2703,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": {
@@ -2486,6 +2731,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",
@@ -2549,6 +2803,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",
@@ -2633,9 +2904,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"
@@ -2666,6 +2937,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",
@@ -2758,6 +3044,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",
@@ -2931,6 +3223,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",
@@ -3011,6 +3329,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",
@@ -3210,6 +3536,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",
@@ -3246,6 +3592,47 @@
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zip-stream": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz",

View File

@@ -17,6 +17,8 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.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",

View File

@@ -193,6 +193,10 @@ 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 {}
},
];
if (currentVersion < migrations.length) {

View File

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

View File

@@ -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<number, { secret: string; exp: number }>();
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<string, unknown> {
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');
@@ -110,7 +142,7 @@ router.post('/demo-login', (_req: Request, res: Response) => {
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@nomad.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<string, unknown>;
res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } });
});
@@ -157,7 +189,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);
res.status(201).json({ token, user: { ...user, avatar_url: null } });
@@ -183,24 +215,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<string, unknown>;
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<string, unknown>;
res.json({ user: { ...base, avatar_url: avatarUrl(user) } });
});
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
@@ -267,10 +309,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<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar'> | 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<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | 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) => {
@@ -314,10 +357,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<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar'> | 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<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | 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) => {
@@ -497,4 +541,107 @@ router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
});
});
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 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<string, unknown>;
res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } });
} catch {
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;

View File

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

View File

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