import { Router } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { rateLimit } from 'express-rate-limit'; import { RedisStore } from 'rate-limit-redis'; import { getDb } from '../config/database.js'; import redis from '../config/redis.js'; import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; import { log } from '../config/logger.js'; if (!process.env.JWT_SECRET) { log.auth.error('FATAL: JWT_SECRET environment variable is not set.'); process.exit(1); } const JWT_SECRET = process.env.JWT_SECRET; // ── Rate Limiting ──────────────────────────────────────────────────────────── function makeRedisStore(prefix) { try { return new RedisStore({ sendCommand: (...args) => redis.call(...args), prefix, }); } catch { return undefined; // falls back to in-memory if Redis unavailable } } // ── Validation helpers ───────────────────────────────────────────────────── const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/; // Simple format check for theme/language IDs (actual validation happens on the frontend) const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/; // Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/; const MIN_PASSWORD_LENGTH = 8; // ── Rate Limiters ──────────────────────────────────────────────────────────── const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 20, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many login attempts. Please try again in 15 minutes.' }, store: makeRedisStore('rl:login:'), }); const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many registration attempts. Please try again later.' }, store: makeRedisStore('rl:register:'), }); const profileLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many profile update attempts. Please try again later.' }, store: makeRedisStore('rl:profile:'), }); const passwordLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many password change attempts. Please try again later.' }, store: makeRedisStore('rl:password:'), }); const avatarLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 20, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many avatar upload attempts. Please try again later.' }, store: makeRedisStore('rl:avatar:'), }); // S1: rate limit resend-verification to prevent SMTP abuse const resendVerificationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests. Please try again later.' }, store: makeRedisStore('rl:resend:'), }); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars'); // Ensure uploads directory exists if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } const router = Router(); // POST /api/auth/register router.post('/register', registerLimiter, async (req, res) => { try { const { username, display_name, email, password, invite_token } = req.body; // Check registration mode const db = getDb(); const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'"); const registrationMode = regModeSetting?.value || 'open'; let validatedInvite = null; if (registrationMode === 'invite') { if (!invite_token) { return res.status(403).json({ error: 'Registration is currently invite-only. You need an invitation link to register.' }); } // Validate the invite token validatedInvite = await db.get( 'SELECT * FROM user_invites WHERE token = ? AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP', [invite_token] ); if (!validatedInvite) { return res.status(403).json({ error: 'Invalid or expired invitation link.' }); } } if (!username || !display_name || !email || !password) { return res.status(400).json({ error: 'All fields are required' }); } // L3: display_name length limit (consistent with profile update) if (display_name.length > 100) { return res.status(400).json({ error: 'Display name must not exceed 100 characters' }); } const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; if (!usernameRegex.test(username)) { return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' }); } // M1: email format if (!EMAIL_RE.test(email)) { return res.status(400).json({ error: 'Invalid email address' }); } // M4: minimum password length if (password.length < MIN_PASSWORD_LENGTH) { return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }); } const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]); if (existing) { return res.status(409).json({ error: 'Email is already in use' }); } const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [username]); if (existingUsername) { return res.status(409).json({ error: 'Username is already taken' }); } const hash = await bcrypt.hash(password, 12); // If SMTP is configured, require email verification if (isMailerConfigured()) { const verificationToken = uuidv4(); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); await db.run( 'INSERT INTO users (name, display_name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, ?, 0, ?, ?)', [username, display_name, email.toLowerCase(), hash, verificationToken, expires] ); // Mark invite as used if applicable if (validatedInvite) { const newUser = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]); if (newUser) { await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [newUser.id, validatedInvite.id]); } } // Build verification URL const baseUrl = getBaseUrl(req); const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; // Load app name from branding settings const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); let appName = 'Redlight'; if (brandingSetting?.value) { try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {} } try { await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en'); } catch (mailErr) { log.auth.error(`Verification mail failed: ${mailErr.message}`); // Account is created but email failed — user can resend from login page return res.status(201).json({ needsVerification: true, emailFailed: true, message: 'Account created but verification email could not be sent. Please try resending.' }); } return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' }); } // No SMTP configured - register and login immediately (legacy behaviour) const result = await db.run( 'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)', [username, display_name, email.toLowerCase(), hash] ); // Mark invite as used if applicable if (validatedInvite) { await db.run('UPDATE user_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', [result.lastInsertRowid, validatedInvite.id]); } const token = generateToken(result.lastInsertRowid); const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]); res.status(201).json({ token, user }); } catch (err) { log.auth.error(`Register error: ${err.message}`); res.status(500).json({ error: 'Registration failed' }); } }); // GET /api/auth/verify-email?token=... router.get('/verify-email', async (req, res) => { try { const { token } = req.query; if (!token) { return res.status(400).json({ error: 'Token is missing' }); } const db = getDb(); const user = await db.get( 'SELECT id, verification_token_expires FROM users WHERE verification_token = ? AND email_verified = 0', [token] ); if (!user) { return res.status(400).json({ error: 'Invalid or already used token' }); } if (new Date(user.verification_token_expires) < new Date()) { return res.status(400).json({ error: 'Token has expired. Please register again.' }); } await db.run( 'UPDATE users SET email_verified = 1, verification_token = NULL, verification_token_expires = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [user.id] ); res.json({ verified: true, message: 'Email verified successfully' }); } catch (err) { log.auth.error(`Verify email error: ${err.message}`); res.status(500).json({ error: 'Verification failed' }); } }); // POST /api/auth/resend-verification router.post('/resend-verification', resendVerificationLimiter, async (req, res) => { try { const { email } = req.body; if (!email) { return res.status(400).json({ error: 'Email is required' }); } if (!isMailerConfigured()) { return res.status(400).json({ error: 'SMTP is not configured' }); } const db = getDb(); const user = await db.get('SELECT id, name, display_name, language, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]); if (!user || user.email_verified) { // Don't reveal whether account exists return res.json({ message: 'If an account exists, a new email has been sent.' }); } // Server-side 60s rate limit if (user.verification_resend_at) { const secondsAgo = (Date.now() - new Date(user.verification_resend_at).getTime()) / 1000; if (secondsAgo < 60) { const waitSeconds = Math.ceil(60 - secondsAgo); return res.status(429).json({ error: `Please wait ${waitSeconds} seconds before requesting another email.`, waitSeconds }); } } const verificationToken = uuidv4(); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); const now = new Date().toISOString(); await db.run( 'UPDATE users SET verification_token = ?, verification_token_expires = ?, verification_resend_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [verificationToken, expires, now, user.id] ); const baseUrl = getBaseUrl(req); const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); let appName = 'Redlight'; if (brandingSetting?.value) { try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {} } try { await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en'); } catch (mailErr) { log.auth.error(`Resend verification mail failed: ${mailErr.message}`); return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' }); } res.json({ message: 'If an account exists, a new email has been sent.' }); } catch (err) { log.auth.error(`Resend verification error: ${err.message}`); res.status(500).json({ error: 'Internal server error' }); } }); // POST /api/auth/login router.post('/login', loginLimiter, async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password are required' }); } // M1: basic email format check - invalid format can never match a real account if (!EMAIL_RE.test(email)) { return res.status(401).json({ error: 'Invalid credentials' }); } const db = getDb(); const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]); if (!user || !bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ error: 'Invalid credentials' }); } if (!user.email_verified && isMailerConfigured()) { return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true }); } const token = generateToken(user.id); const { password_hash, verification_token, verification_token_expires, verification_resend_at, ...safeUser } = user; res.json({ token, user: safeUser }); } catch (err) { log.auth.error(`Login error: ${err.message}`); res.status(500).json({ error: 'Login failed' }); } }); // POST /api/auth/logout - revoke JWT via DragonflyDB blacklist router.post('/logout', authenticateToken, async (req, res) => { try { const authHeader = req.headers.authorization; const token = authHeader && authHeader.split(' ')[1]; const decoded = jwt.decode(token); if (decoded?.jti && decoded?.exp) { const ttl = decoded.exp - Math.floor(Date.now() / 1000); if (ttl > 0) { try { await redis.setex(`blacklist:${decoded.jti}`, ttl, '1'); } catch (redisErr) { log.auth.warn(`Redis blacklist write failed: ${redisErr.message}`); } } } res.json({ message: 'Logged out successfully' }); } catch (err) { log.auth.error(`Logout error: ${err.message}`); res.status(500).json({ error: 'Logout failed' }); } }); // GET /api/auth/me router.get('/me', authenticateToken, (req, res) => { res.json({ user: req.user }); }); // PUT /api/auth/profile router.put('/profile', authenticateToken, profileLimiter, async (req, res) => { try { const { name, display_name, email, theme, language, avatar_color } = req.body; const db = getDb(); // M1: validate new email format if (email && !EMAIL_RE.test(email)) { return res.status(400).json({ error: 'Invalid email address' }); } if (email && email !== req.user.email) { const existing = await db.get('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]); if (existing) { return res.status(409).json({ error: 'Email is already in use' }); } } // M2: display_name length limit if (display_name !== undefined && display_name !== null && display_name.length > 100) { return res.status(400).json({ error: 'Display name must not exceed 100 characters' }); } // Theme and language: basic format validation (frontend handles actual ID matching) if (theme !== undefined && theme !== null && (typeof theme !== 'string' || !SAFE_ID_RE.test(theme))) { return res.status(400).json({ error: 'Invalid theme' }); } if (language !== undefined && language !== null && (typeof language !== 'string' || !SAFE_ID_RE.test(language))) { return res.status(400).json({ error: 'Invalid language' }); } // L5: validate avatar_color format/length if (avatar_color !== undefined && avatar_color !== null) { if (typeof avatar_color !== 'string' || !SAFE_COLOR_RE.test(avatar_color)) { return res.status(400).json({ error: 'Invalid avatar color' }); } } if (name && name !== req.user.name) { const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; if (!usernameRegex.test(name)) { return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' }); } const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]); if (existingUsername) { return res.status(409).json({ error: 'Username is already taken' }); } } await db.run(` UPDATE users SET name = COALESCE(?, name), display_name = COALESCE(?, display_name), email = COALESCE(?, email), theme = COALESCE(?, theme), language = COALESCE(?, language), avatar_color = COALESCE(?, avatar_color), updated_at = CURRENT_TIMESTAMP WHERE id = ? `, [name, display_name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]); const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); res.json({ user: updated }); } catch (err) { log.auth.error(`Profile update error: ${err.message}`); res.status(500).json({ error: 'Profile could not be updated' }); } }); // PUT /api/auth/password router.put('/password', authenticateToken, passwordLimiter, async (req, res) => { try { const { currentPassword, newPassword } = req.body; // M6: guard against missing/non-string body values if (!currentPassword || !newPassword) { return res.status(400).json({ error: 'currentPassword and newPassword are required' }); } if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') { return res.status(400).json({ error: 'Invalid input' }); } const db = getDb(); const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.user.id]); if (!bcrypt.compareSync(currentPassword, user.password_hash)) { return res.status(401).json({ error: 'Current password is incorrect' }); } // M4: minimum password length if (newPassword.length < MIN_PASSWORD_LENGTH) { return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` }); } const hash = await bcrypt.hash(newPassword, 12); await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]); res.json({ message: 'Password changed successfully' }); } catch (err) { log.auth.error(`Password change error: ${err.message}`); res.status(500).json({ error: 'Password could not be changed' }); } }); // POST /api/auth/avatar - Upload avatar image router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => { try { // Validate file content by checking magic bytes (file signatures) const contentType = req.headers['content-type']; if (!contentType || !contentType.startsWith('image/')) { return res.status(400).json({ error: 'Only image files are allowed' }); } // M15: stream-level size limit - abort as soon as 2 MB is exceeded const MAX_AVATAR_SIZE = 2 * 1024 * 1024; const buffer = await new Promise((resolve, reject) => { const chunks = []; let totalSize = 0; req.on('data', chunk => { totalSize += chunk.length; if (totalSize > MAX_AVATAR_SIZE) { req.destroy(); return reject(new Error('LIMIT_EXCEEDED')); } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }).catch(err => { if (err.message === 'LIMIT_EXCEEDED') return null; throw err; }); if (!buffer) { return res.status(400).json({ error: 'Image must not exceed 2MB' }); } // Validate magic bytes to prevent Content-Type spoofing const magicBytes = buffer.slice(0, 8); const isJPEG = magicBytes[0] === 0xFF && magicBytes[1] === 0xD8 && magicBytes[2] === 0xFF; const isPNG = magicBytes[0] === 0x89 && magicBytes[1] === 0x50 && magicBytes[2] === 0x4E && magicBytes[3] === 0x47; const isGIF = magicBytes[0] === 0x47 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46; const isWEBP = magicBytes[0] === 0x52 && magicBytes[1] === 0x49 && magicBytes[2] === 0x46 && magicBytes[3] === 0x46 && buffer.length > 11 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50; if (!isJPEG && !isPNG && !isGIF && !isWEBP) { return res.status(400).json({ error: 'File content does not match a supported image format (JPEG, PNG, GIF, WebP)' }); } const ext = isPNG ? 'png' : isGIF ? 'gif' : isWEBP ? 'webp' : 'jpg'; const filename = `${req.user.id}_${Date.now()}.${ext}`; const filepath = path.join(uploadsDir, filename); // Remove old avatar if exists const db = getDb(); const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]); if (current?.avatar_image) { // S8: defense-in-depth path traversal check on DB-stored filename const oldPath = path.resolve(uploadsDir, current.avatar_image); if (oldPath.startsWith(uploadsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } fs.writeFileSync(filepath, buffer); await db.run('UPDATE users SET avatar_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [filename, req.user.id]); const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); res.json({ user: updated }); } catch (err) { log.auth.error(`Avatar upload error: ${err.message}`); res.status(500).json({ error: 'Avatar could not be uploaded' }); } }); // DELETE /api/auth/avatar - Remove avatar image router.delete('/avatar', authenticateToken, avatarLimiter, async (req, res) => { try { const db = getDb(); const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]); if (current?.avatar_image) { // S8: defense-in-depth path traversal check on DB-stored filename const oldPath = path.resolve(uploadsDir, current.avatar_image); if (oldPath.startsWith(uploadsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]); const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); res.json({ user: updated }); } catch (err) { log.auth.error(`Avatar delete error: ${err.message}`); res.status(500).json({ error: 'Avatar could not be removed' }); } }); // Escape XML special characters to prevent XSS in SVG text/attribute contexts function escapeXml(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // GET /api/auth/avatar/initials/:name - Generate SVG avatar from initials (public, BBB fetches this) router.get('/avatar/initials/:name', (req, res) => { const name = decodeURIComponent(req.params.name).trim(); // C1 fix: validate color against a strict allowlist before embedding in SVG attribute const rawColor = req.query.color || ''; const color = SAFE_COLOR_RE.test(rawColor) ? rawColor : generateColorFromName(name); // C2 fix: XML-escape initials before embedding in SVG text node const rawInitials = name .split(' ') .map(n => n[0]) .join('') .toUpperCase() .slice(0, 2) || '?'; const initials = escapeXml(rawInitials); const svg = ` ${initials} `; res.setHeader('Content-Type', 'image/svg+xml'); res.setHeader('Cache-Control', 'public, max-age=3600'); res.send(svg); }); function generateColorFromName(name) { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } const hue = Math.abs(hash) % 360; return `hsl(${hue}, 55%, 45%)`; } // GET /api/auth/avatar/:filename - Serve avatar image router.get('/avatar/:filename', (req, res) => { // H1 fix: resolve the path and ensure it stays inside uploadsDir (prevent path traversal) const filepath = path.resolve(uploadsDir, req.params.filename); if (!filepath.startsWith(uploadsDir + path.sep)) { return res.status(400).json({ error: 'Invalid filename' }); } if (!fs.existsSync(filepath)) { return res.status(404).json({ error: 'Avatar not found' }); } const ext = path.extname(filepath).slice(1); const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' }; res.setHeader('Content-Type', mimeMap[ext] || 'image/jpeg'); res.setHeader('Cache-Control', 'public, max-age=86400'); fs.createReadStream(filepath).pipe(res); }); export default router;