From 0836436fe7c486427ba908538996df9aab611f5a Mon Sep 17 00:00:00 2001 From: Michelle Date: Mon, 16 Mar 2026 13:28:43 +0100 Subject: [PATCH] feat: Implement Two-Factor Authentication (2FA) for enhanced user account security. --- package-lock.json | 230 +++++++++++++++++++++++++++ package.json | 2 + server/config/database.js | 10 +- server/middleware/auth.js | 2 +- server/routes/auth.js | 188 +++++++++++++++++++++- src/contexts/AuthContext.jsx | 12 +- src/i18n/de.json | 32 +++- src/i18n/en.json | 32 +++- src/pages/Login.jsx | 297 +++++++++++++++++++++++------------ src/pages/Settings.jsx | 212 ++++++++++++++++++++++++- 10 files changed, 909 insertions(+), 108 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78db726..eb51c1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,9 @@ "lucide-react": "^0.460.0", "multer": "^2.1.0", "nodemailer": "^8.0.1", + "otpauth": "^9.5.0", "pg": "^8.18.0", + "qrcode": "^1.5.4", "rate-limit-redis": "^4.3.1", "react": "^18.3.0", "react-dom": "^18.3.0", @@ -835,6 +837,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1725,6 +1739,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/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2026,6 +2049,15 @@ } } }, + "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", @@ -2103,6 +2135,12 @@ "dev": true, "license": "Apache-2.0" }, + "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/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -2470,6 +2508,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "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/flatpickr": { "version": "4.6.13", "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", @@ -3003,6 +3054,18 @@ "dev": true, "license": "MIT" }, + "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.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -3381,6 +3444,54 @@ "wrappy": "1" } }, + "node_modules/otpauth": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", + "integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=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", @@ -3390,6 +3501,15 @@ "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-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3532,6 +3652,15 @@ "node": ">= 6" } }, + "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/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3790,6 +3919,89 @@ "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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -4028,6 +4240,12 @@ "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": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4246,6 +4464,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", @@ -4913,6 +5137,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 281fcb0..17619de 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "lucide-react": "^0.460.0", "multer": "^2.1.0", "nodemailer": "^8.0.1", + "otpauth": "^9.5.0", "pg": "^8.18.0", + "qrcode": "^1.5.4", "rate-limit-redis": "^4.3.1", "react": "^18.3.0", "react-dom": "^18.3.0", diff --git a/server/config/database.js b/server/config/database.js index 5f806da..8ae31d0 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -1,4 +1,4 @@ -import bcrypt from 'bcryptjs'; +import bcrypt from 'bcryptjs'; import path from 'path'; import { fileURLToPath } from 'url'; import { log } from './logger.js'; @@ -802,6 +802,14 @@ export async function initDatabase() { `); } + // ── TOTP 2FA columns ────────────────────────────────────────────────────── + if (!(await db.columnExists('users', 'totp_secret'))) { + await db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT DEFAULT NULL'); + } + if (!(await db.columnExists('users', 'totp_enabled'))) { + await db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0'); + } + // ── Default admin (only on very first start) ──────────────────────────── const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); if (!adminAlreadySeeded) { diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 9bd5e36..ea6cef6 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -35,7 +35,7 @@ export async function authenticateToken(req, res, next) { } const db = getDb(); - const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified, oauth_provider FROM users WHERE id = ?', [decoded.userId]); + const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified, oauth_provider, totp_enabled FROM users WHERE id = ?', [decoded.userId]); if (!user) { return res.status(401).json({ error: 'User not found' }); } diff --git a/server/routes/auth.js b/server/routes/auth.js index 1076da4..c070506 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; @@ -7,6 +7,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { rateLimit } from 'express-rate-limit'; import { RedisStore } from 'rate-limit-redis'; +import * as OTPAuth from 'otpauth'; import { getDb } from '../config/database.js'; import redis from '../config/redis.js'; import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js'; @@ -99,6 +100,15 @@ const resendVerificationLimiter = rateLimit({ store: makeRedisStore('rl:resend:'), }); +const twoFaLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many 2FA attempts. Please try again later.' }, + store: makeRedisStore('rl:2fa:'), +}); + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars'); @@ -352,8 +362,14 @@ router.post('/login', loginLimiter, async (req, res) => { return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true }); } + // ── 2FA check ──────────────────────────────────────────────────────── + if (user.totp_enabled) { + const tempToken = jwt.sign({ userId: user.id, purpose: '2fa' }, JWT_SECRET, { expiresIn: '5m' }); + return res.json({ requires2FA: true, tempToken }); + } + const token = generateToken(user.id); - const { password_hash, verification_token, verification_token_expires, verification_resend_at, ...safeUser } = user; + const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user; res.json({ token, user: safeUser }); } catch (err) { @@ -362,6 +378,53 @@ router.post('/login', loginLimiter, async (req, res) => { } }); +// POST /api/auth/login/2fa - Verify TOTP code and complete login +router.post('/login/2fa', twoFaLimiter, async (req, res) => { + try { + const { tempToken, code } = req.body; + if (!tempToken || !code) { + return res.status(400).json({ error: 'Token and code are required' }); + } + + let decoded; + try { + decoded = jwt.verify(tempToken, JWT_SECRET); + } catch { + return res.status(401).json({ error: 'Invalid or expired token. Please log in again.' }); + } + + if (decoded.purpose !== '2fa') { + return res.status(401).json({ error: 'Invalid token' }); + } + + const db = getDb(); + const user = await db.get('SELECT * FROM users WHERE id = ?', [decoded.userId]); + if (!user || !user.totp_enabled || !user.totp_secret) { + return res.status(401).json({ error: 'Invalid token' }); + } + + const totp = new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(user.totp_secret), + algorithm: 'SHA1', + digits: 6, + period: 30, + }); + + const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 }); + if (delta === null) { + return res.status(401).json({ error: 'Invalid 2FA code' }); + } + + const token = generateToken(user.id); + const { password_hash, verification_token, verification_token_expires, verification_resend_at, totp_secret, ...safeUser } = user; + + res.json({ token, user: safeUser }); + } catch (err) { + log.auth.error(`2FA login error: ${err.message}`); + res.status(500).json({ error: '2FA verification failed' }); + } +}); + // POST /api/auth/logout - revoke JWT via DragonflyDB blacklist router.post('/logout', authenticateToken, async (req, res) => { try { @@ -670,4 +733,125 @@ router.get('/avatar/:filename', (req, res) => { fs.createReadStream(filepath).pipe(res); }); +// ── 2FA Management ────────────────────────────────────────────────────────── + +// GET /api/auth/2fa/status +router.get('/2fa/status', authenticateToken, async (req, res) => { + const db = getDb(); + const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]); + res.json({ enabled: !!user?.totp_enabled }); +}); + +// POST /api/auth/2fa/setup - Generate TOTP secret + provisioning URI +router.post('/2fa/setup', authenticateToken, twoFaLimiter, async (req, res) => { + try { + const db = getDb(); + const user = await db.get('SELECT totp_enabled FROM users WHERE id = ?', [req.user.id]); + if (user?.totp_enabled) { + return res.status(400).json({ error: '2FA is already enabled' }); + } + + const secret = new OTPAuth.Secret({ size: 20 }); + + // Load app name from branding settings + const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); + let issuer = 'Redlight'; + if (brandingSetting?.value) { + try { issuer = JSON.parse(brandingSetting.value).appName || issuer; } catch {} + } + + const totp = new OTPAuth.TOTP({ + issuer, + label: req.user.email, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + + // Store the secret (but don't enable yet — user must verify first) + await db.run('UPDATE users SET totp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [secret.base32, req.user.id]); + + res.json({ secret: secret.base32, uri: totp.toString() }); + } catch (err) { + log.auth.error(`2FA setup error: ${err.message}`); + res.status(500).json({ error: '2FA setup failed' }); + } +}); + +// POST /api/auth/2fa/enable - Verify code and activate 2FA +router.post('/2fa/enable', authenticateToken, twoFaLimiter, async (req, res) => { + try { + const { code } = req.body; + if (!code) { + return res.status(400).json({ error: 'Code is required' }); + } + + const db = getDb(); + const user = await db.get('SELECT totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]); + if (!user?.totp_secret) { + return res.status(400).json({ error: 'Please run 2FA setup first' }); + } + if (user.totp_enabled) { + return res.status(400).json({ error: '2FA is already enabled' }); + } + + const totp = new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(user.totp_secret), + algorithm: 'SHA1', + digits: 6, + period: 30, + }); + + const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 }); + if (delta === null) { + return res.status(401).json({ error: 'Invalid code. Please try again.' }); + } + + await db.run('UPDATE users SET totp_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]); + res.json({ enabled: true, message: '2FA has been enabled' }); + } catch (err) { + log.auth.error(`2FA enable error: ${err.message}`); + res.status(500).json({ error: '2FA could not be enabled' }); + } +}); + +// POST /api/auth/2fa/disable - Disable 2FA (requires password + TOTP code) +router.post('/2fa/disable', authenticateToken, twoFaLimiter, async (req, res) => { + try { + const { password, code } = req.body; + if (!password || !code) { + return res.status(400).json({ error: 'Password and code are required' }); + } + + const db = getDb(); + const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]); + if (!user?.totp_enabled) { + return res.status(400).json({ error: '2FA is not enabled' }); + } + + if (!bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: 'Invalid password' }); + } + + const totp = new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(user.totp_secret), + algorithm: 'SHA1', + digits: 6, + period: 30, + }); + + const delta = totp.validate({ token: code.replace(/\s/g, ''), window: 1 }); + if (delta === null) { + return res.status(401).json({ error: 'Invalid 2FA code' }); + } + + await db.run('UPDATE users SET totp_enabled = 0, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]); + res.json({ enabled: false, message: '2FA has been disabled' }); + } catch (err) { + log.auth.error(`2FA disable error: ${err.message}`); + res.status(500).json({ error: '2FA could not be disabled' }); + } +}); + export default router; diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index e95e325..682bf24 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -23,6 +23,16 @@ export function AuthProvider({ children }) { const login = useCallback(async (email, password) => { const res = await api.post('/auth/login', { email, password }); + if (res.data.requires2FA) { + return { requires2FA: true, tempToken: res.data.tempToken }; + } + localStorage.setItem('token', res.data.token); + setUser(res.data.user); + return res.data.user; + }, []); + + const verify2FA = useCallback(async (tempToken, code) => { + const res = await api.post('/auth/login/2fa', { tempToken, code }); localStorage.setItem('token', res.data.token); setUser(res.data.user); return res.data.user; @@ -75,7 +85,7 @@ export function AuthProvider({ children }) { }, []); return ( - + {children} ); diff --git a/src/i18n/de.json b/src/i18n/de.json index 957e944..4229a0a 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1,4 +1,4 @@ -{ +{ "common": { "appName": "Redlight", "loading": "Laden...", @@ -99,7 +99,15 @@ "oauthError": "Anmeldung fehlgeschlagen", "oauthNoToken": "Kein Authentifizierungstoken erhalten.", "oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.", - "oauthRedirecting": "Du wirst angemeldet..." + "oauthRedirecting": "Du wirst angemeldet...", + "2fa": { + "title": "Zwei-Faktor-Authentifizierung", + "prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.", + "codeLabel": "Bestätigungscode", + "verify": "Bestätigen", + "verifyFailed": "Überprüfung fehlgeschlagen", + "backToLogin": "← Zurück zum Login" + } }, "home": { "madeFor": "Made for BigBlueButton", @@ -327,6 +335,26 @@ "passwordChangeFailed": "Fehler beim Ändern", "passwordMismatch": "Passwörter stimmen nicht überein", "selectLanguage": "Sprache auswählen", + "security": { + "title": "Sicherheit", + "subtitle": "Schütze dein Konto mit Zwei-Faktor-Authentifizierung (2FA). Nach der Aktivierung benötigst du sowohl dein Passwort als auch einen Code aus deiner Authenticator-App zum Anmelden.", + "statusEnabled": "2FA ist aktiviert", + "statusEnabledDesc": "Dein Konto ist durch Zwei-Faktor-Authentifizierung geschützt.", + "statusDisabled": "2FA ist nicht aktiviert", + "statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.", + "enable": "2FA aktivieren", + "disable": "2FA deaktivieren", + "enabled": "Zwei-Faktor-Authentifizierung aktiviert!", + "disabled": "Zwei-Faktor-Authentifizierung deaktiviert.", + "enableFailed": "2FA konnte nicht aktiviert werden", + "disableFailed": "2FA konnte nicht deaktiviert werden", + "setupFailed": "2FA-Einrichtung konnte nicht gestartet werden", + "scanQR": "Scanne diesen QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, etc.).", + "manualKey": "Oder gib diesen Schlüssel manuell ein:", + "verifyCode": "Gib den Code aus deiner App zur Überprüfung ein", + "codeLabel": "6-stelliger Code", + "disableConfirm": "Gib dein Passwort und einen aktuellen 2FA-Code ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren." + }, "caldav": { "title": "CalDAV", "subtitle": "Verbinde deine Kalender-App (z. B. Apple Kalender, Thunderbird, DAVx⁵) über das CalDAV-Protokoll. Verwende deine E-Mail-Adresse und ein App-Token als Passwort.", diff --git a/src/i18n/en.json b/src/i18n/en.json index a4a0e18..06c9a15 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1,4 +1,4 @@ -{ +{ "common": { "appName": "Redlight", "loading": "Loading...", @@ -99,7 +99,15 @@ "oauthError": "Authentication failed", "oauthNoToken": "No authentication token received.", "oauthLoginFailed": "Could not complete sign in. Please try again.", - "oauthRedirecting": "Signing you in..." + "oauthRedirecting": "Signing you in...", + "2fa": { + "title": "Two-Factor Authentication", + "prompt": "Enter the 6-digit code from your authenticator app.", + "codeLabel": "Verification code", + "verify": "Verify", + "verifyFailed": "Verification failed", + "backToLogin": "← Back to login" + } }, "home": { "madeFor": "Made for BigBlueButton", @@ -327,6 +335,26 @@ "passwordChangeFailed": "Error changing password", "passwordMismatch": "Passwords do not match", "selectLanguage": "Select language", + "security": { + "title": "Security", + "subtitle": "Protect your account with two-factor authentication (2FA). After enabling, you will need both your password and a code from your authenticator app to sign in.", + "statusEnabled": "2FA is enabled", + "statusEnabledDesc": "Your account is protected with two-factor authentication.", + "statusDisabled": "2FA is not enabled", + "statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.", + "enable": "Enable 2FA", + "disable": "Disable 2FA", + "enabled": "Two-factor authentication enabled!", + "disabled": "Two-factor authentication disabled.", + "enableFailed": "Could not enable 2FA", + "disableFailed": "Could not disable 2FA", + "setupFailed": "Could not start 2FA setup", + "scanQR": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).", + "manualKey": "Or enter this key manually:", + "verifyCode": "Enter the code from your app to verify", + "codeLabel": "6-digit code", + "disableConfirm": "Enter your password and a current 2FA code to disable two-factor authentication." + }, "caldav": { "title": "CalDAV", "subtitle": "Connect your calendar app (e.g. Apple Calendar, Thunderbird, DAVx⁵) using the CalDAV protocol. Use your email address and an app token as password.", diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 4654daf..98927e0 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -1,9 +1,9 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; import { useBranding } from '../contexts/BrandingContext'; -import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn } from 'lucide-react'; +import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react'; import BrandLogo from '../components/BrandLogo'; import api from '../services/api'; import toast from 'react-hot-toast'; @@ -15,7 +15,14 @@ export default function Login() { const [needsVerification, setNeedsVerification] = useState(false); const [resendCooldown, setResendCooldown] = useState(0); const [resending, setResending] = useState(false); - const { login } = useAuth(); + // 2FA state + const [needs2FA, setNeeds2FA] = useState(false); + const [tempToken, setTempToken] = useState(''); + const [totpCode, setTotpCode] = useState(''); + const [verifying2FA, setVerifying2FA] = useState(false); + const totpInputRef = useRef(null); + + const { login, verify2FA } = useAuth(); const { t } = useLanguage(); const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding(); const navigate = useNavigate(); @@ -26,6 +33,13 @@ export default function Login() { return () => clearTimeout(timer); }, [resendCooldown]); + // Auto-focus TOTP input when 2FA screen appears + useEffect(() => { + if (needs2FA && totpInputRef.current) { + totpInputRef.current.focus(); + } + }, [needs2FA]); + const handleResend = async () => { if (resendCooldown > 0 || resending) return; setResending(true); @@ -48,7 +62,13 @@ export default function Login() { e.preventDefault(); setLoading(true); try { - await login(email, password); + const result = await login(email, password); + if (result?.requires2FA) { + setTempToken(result.tempToken); + setNeeds2FA(true); + setLoading(false); + return; + } toast.success(t('auth.loginSuccess')); navigate('/dashboard'); } catch (err) { @@ -62,6 +82,27 @@ export default function Login() { } }; + const handle2FASubmit = async (e) => { + e.preventDefault(); + setVerifying2FA(true); + try { + await verify2FA(tempToken, totpCode); + toast.success(t('auth.loginSuccess')); + navigate('/dashboard'); + } catch (err) { + toast.error(err.response?.data?.error || t('auth.2fa.verifyFailed')); + setTotpCode(''); + } finally { + setVerifying2FA(false); + } + }; + + const handleBack = () => { + setNeeds2FA(false); + setTempToken(''); + setTotpCode(''); + }; + return (
{/* Animated background */} @@ -81,111 +122,171 @@ export default function Login() {
-
-

{t('auth.welcomeBack')}

-

- {t('auth.loginSubtitle')} -

-
- -
-
- -
- - setEmail(e.target.value)} - className="input-field pl-11" - placeholder={t('auth.emailPlaceholder')} - required - /> + {needs2FA ? ( + <> + {/* 2FA verification step */} +
+
+ +
+

{t('auth.2fa.title')}

+

+ {t('auth.2fa.prompt')} +

-
-
- -
- - setPassword(e.target.value)} - className="input-field pl-11" - placeholder={t('auth.passwordPlaceholder')} - required - /> + +
+ +
+ + setTotpCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))} + className="input-field pl-11 text-center text-lg tracking-[0.3em] font-mono" + placeholder="000 000" + required + maxLength={7} + /> +
+
+ + + + + + + ) : ( + <> +
+

{t('auth.welcomeBack')}

+

+ {t('auth.loginSubtitle')} +

-
- + + + {oauthEnabled && ( <> - {t('auth.login')} - +
+
+
+
+
+ {t('auth.orContinueWith')} +
+
+ + + {t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')} + )} - - - {oauthEnabled && ( - <> -
-
-
+ {needsVerification && ( +
+
+ +

{t('auth.emailVerificationBanner')}

+
+
-
- {t('auth.orContinueWith')} -
-
- - - {t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')} - + )} + + {registrationMode !== 'invite' && ( +

+ {t('auth.noAccount')}{' '} + + {t('auth.signUpNow')} + +

+ )} + + + {t('auth.backToHome')} + )} - - {needsVerification && ( -
-
- -

{t('auth.emailVerificationBanner')}

-
- -
- )} - - {registrationMode !== 'invite' && ( -

- {t('auth.noAccount')}{' '} - - {t('auth.signUpNow')} - -

- )} - - - {t('auth.backToHome')} -
diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index 93410fc..44530ee 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff } from 'lucide-react'; +import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff, Shield, ShieldCheck, ShieldOff } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { useTheme } from '../contexts/ThemeContext'; import { useLanguage } from '../contexts/LanguageContext'; @@ -46,6 +46,17 @@ export default function Settings() { const [newlyCreatedToken, setNewlyCreatedToken] = useState(null); const [tokenVisible, setTokenVisible] = useState(false); + // 2FA state + const [twoFaEnabled, setTwoFaEnabled] = useState(!!user?.totp_enabled); + const [twoFaLoading, setTwoFaLoading] = useState(false); + const [twoFaSetupData, setTwoFaSetupData] = useState(null); // { secret, uri, qrDataUrl } + const [twoFaCode, setTwoFaCode] = useState(''); + const [twoFaEnabling, setTwoFaEnabling] = useState(false); + const [twoFaDisablePassword, setTwoFaDisablePassword] = useState(''); + const [twoFaDisableCode, setTwoFaDisableCode] = useState(''); + const [twoFaDisabling, setTwoFaDisabling] = useState(false); + const [showDisableForm, setShowDisableForm] = useState(false); + useEffect(() => { if (activeSection === 'caldav') { setCaldavLoading(true); @@ -54,6 +65,13 @@ export default function Settings() { .catch(() => {}) .finally(() => setCaldavLoading(false)); } + if (activeSection === 'security') { + setTwoFaLoading(true); + api.get('/auth/2fa/status') + .then(r => setTwoFaEnabled(r.data.enabled)) + .catch(() => {}) + .finally(() => setTwoFaLoading(false)); + } }, [activeSection]); const handleCreateToken = async (e) => { @@ -85,6 +103,56 @@ export default function Settings() { } }; + // 2FA handlers + const handleSetup2FA = async () => { + setTwoFaLoading(true); + try { + const res = await api.post('/auth/2fa/setup'); + // Generate QR code data URL client-side + const QRCode = (await import('qrcode')).default; + const qrDataUrl = await QRCode.toDataURL(res.data.uri, { width: 200, margin: 2, color: { dark: '#000000', light: '#ffffff' } }); + setTwoFaSetupData({ secret: res.data.secret, uri: res.data.uri, qrDataUrl }); + } catch (err) { + toast.error(err.response?.data?.error || t('settings.security.setupFailed')); + } finally { + setTwoFaLoading(false); + } + }; + + const handleEnable2FA = async (e) => { + e.preventDefault(); + setTwoFaEnabling(true); + try { + await api.post('/auth/2fa/enable', { code: twoFaCode }); + setTwoFaEnabled(true); + setTwoFaSetupData(null); + setTwoFaCode(''); + toast.success(t('settings.security.enabled')); + } catch (err) { + toast.error(err.response?.data?.error || t('settings.security.enableFailed')); + setTwoFaCode(''); + } finally { + setTwoFaEnabling(false); + } + }; + + const handleDisable2FA = async (e) => { + e.preventDefault(); + setTwoFaDisabling(true); + try { + await api.post('/auth/2fa/disable', { password: twoFaDisablePassword, code: twoFaDisableCode }); + setTwoFaEnabled(false); + setShowDisableForm(false); + setTwoFaDisablePassword(''); + setTwoFaDisableCode(''); + toast.success(t('settings.security.disabled')); + } catch (err) { + toast.error(err.response?.data?.error || t('settings.security.disableFailed')); + } finally { + setTwoFaDisabling(false); + } + }; + const groups = getThemeGroups(); const avatarColors = [ @@ -184,6 +252,7 @@ export default function Settings() { const sections = [ { id: 'profile', label: t('settings.profile'), icon: User }, { id: 'password', label: t('settings.password'), icon: Lock }, + { id: 'security', label: t('settings.security.title'), icon: Shield }, { id: 'language', label: t('settings.language'), icon: Globe }, { id: 'themes', label: t('settings.themes'), icon: Palette }, { id: 'caldav', label: t('settings.caldav.title'), icon: Calendar }, @@ -411,6 +480,147 @@ export default function Settings() {
)} + {/* Security / 2FA section */} + {activeSection === 'security' && ( +
+
+

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

+

{t('settings.security.subtitle')}

+ + {twoFaLoading ? ( +
+ +
+ ) : twoFaEnabled ? ( + /* 2FA is enabled */ +
+
+ +
+

{t('settings.security.statusEnabled')}

+

{t('settings.security.statusEnabledDesc')}

+
+
+ + {!showDisableForm ? ( + + ) : ( +
+

{t('settings.security.disableConfirm')}

+
+ +
+ + setTwoFaDisablePassword(e.target.value)} + className="input-field pl-11" + required + /> +
+
+
+ + setTwoFaDisableCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))} + className="input-field text-center text-lg tracking-[0.3em] font-mono" + placeholder="000 000" + required + maxLength={7} + /> +
+
+ + +
+
+ )} +
+ ) : twoFaSetupData ? ( + /* Setup flow: show QR code + verification */ +
+
+

{t('settings.security.scanQR')}

+
+ TOTP QR Code +
+
+
+

{t('settings.security.manualKey')}

+
+ + {twoFaSetupData.secret} + + +
+
+
+
+ + setTwoFaCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))} + className="input-field text-center text-lg tracking-[0.3em] font-mono" + placeholder="000 000" + required + maxLength={7} + /> +
+
+ + +
+
+
+ ) : ( + /* 2FA is disabled — show enable button */ +
+
+ +
+

{t('settings.security.statusDisabled')}

+

{t('settings.security.statusDisabledDesc')}

+
+
+ +
+ )} +
+
+ )} + {/* Language section */} {activeSection === 'language' && (