feat: Implement Two-Factor Authentication (2FA) for enhanced user account security.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user