Merge branch 'pr-169'

# Conflicts:
#	client/src/i18n/translations/ar.ts
#	client/src/i18n/translations/br.ts
#	client/src/i18n/translations/cs.ts
#	client/src/i18n/translations/de.ts
#	client/src/i18n/translations/en.ts
#	client/src/i18n/translations/es.ts
#	client/src/i18n/translations/fr.ts
#	client/src/i18n/translations/hu.ts
#	client/src/i18n/translations/it.ts
#	client/src/i18n/translations/nl.ts
#	client/src/i18n/translations/ru.ts
#	client/src/i18n/translations/zh.ts
#	client/src/pages/SettingsPage.tsx
This commit is contained in:
Maurice
2026-03-30 23:46:32 +02:00
20 changed files with 3031 additions and 2800 deletions

View File

@@ -394,6 +394,10 @@ function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
`);
},
() => {
// MFA backup/recovery codes
try { db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); } catch {}
},
// MCP long-lived API tokens
() => db.exec(`
CREATE TABLE IF NOT EXISTS mcp_tokens (

View File

@@ -17,6 +17,7 @@ function createTables(db: Database.Database): void {
last_login DATETIME,
mfa_enabled INTEGER DEFAULT 0,
mfa_secret TEXT,
mfa_backup_codes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { v4 as uuid } from 'uuid';
import fetch from 'node-fetch';
import { authenticator } from 'otplib';
@@ -21,6 +22,35 @@ authenticator.options = { window: 1 };
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
const MFA_BACKUP_CODE_COUNT = 10;
function normalizeBackupCode(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}
function hashBackupCode(input: string): string {
return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex');
}
function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
const codes: string[] = [];
while (codes.length < count) {
const raw = crypto.randomBytes(4).toString('hex').toUpperCase();
const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
if (!codes.includes(code)) codes.push(code);
}
return codes;
}
function parseBackupCodeHashes(raw: string | null | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : [];
} catch {
return [];
}
}
function getPendingMfaSecret(userId: number): string | null {
const row = mfaSetupPending.get(userId);
@@ -43,6 +73,7 @@ function stripUserForClient(user: User): Record<string, unknown> {
openweather_api_key: _o,
unsplash_api_key: _u,
mfa_secret: _mf,
mfa_backup_codes: _mbc,
...rest
} = user;
return {
@@ -664,10 +695,20 @@ router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => {
return res.status(401).json({ error: 'Invalid session' });
}
const secret = decryptMfaSecret(user.mfa_secret);
const tokenStr = String(code).replace(/\s/g, '');
const ok = authenticator.verify({ token: tokenStr, secret });
if (!ok) {
return res.status(401).json({ error: 'Invalid verification code' });
const tokenStr = String(code).trim();
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
const candidateHash = hashBackupCode(tokenStr);
const idx = hashes.findIndex(h => h === candidateHash);
if (idx === -1) {
return res.status(401).json({ error: 'Invalid verification code' });
}
hashes.splice(idx, 1);
db.prepare('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
JSON.stringify(hashes),
user.id
);
}
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
const sessionToken = generateToken(user);
@@ -721,14 +762,17 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => {
if (!ok) {
return res.status(401).json({ error: 'Invalid verification code' });
}
const backupCodes = generateBackupCodes();
const backupHashes = backupCodes.map(hashBackupCode);
const enc = encryptMfaSecret(pending);
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
enc,
JSON.stringify(backupHashes),
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: true });
res.json({ success: true, mfa_enabled: true, backup_codes: backupCodes });
});
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
@@ -757,7 +801,7 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
if (!ok) {
return res.status(401).json({ error: 'Invalid verification code' });
}
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);

View File

@@ -15,6 +15,7 @@ export interface User {
last_login?: string | null;
mfa_enabled?: number | boolean;
mfa_secret?: string | null;
mfa_backup_codes?: string | null;
created_at?: string;
updated_at?: string;
}