Files
redlight/server/routes/auth.js
Michelle 7466f3513d
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
Enhance security and validation across multiple routes:
- Escape XML and HTML special characters to prevent injection attacks.
- Implement rate limiting for various endpoints to mitigate abuse.
- Add validation for email formats, password lengths, and field limits.
- Ensure proper access control for recordings and room management.
2026-02-28 19:49:29 +01:00

597 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
if (!process.env.JWT_SECRET) {
console.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,}$/;
const VALID_THEMES = new Set([
'light', 'dark', 'dracula', 'mocha', 'latte', 'nord', 'tokyo-night',
'gruvbox-dark', 'gruvbox-light', 'rose-pine', 'rose-pine-dawn',
'solarized-dark', 'solarized-light', 'one-dark', 'github-dark', 'scrunkly-cat',
]);
const VALID_LANGUAGES = new Set(['en', 'de']);
// 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:'),
});
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 } = req.body;
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 - (330 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 db = getDb();
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 = bcrypt.hashSync(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]
);
// Build verification URL
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
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);
} catch (mailErr) {
console.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]
);
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) {
console.error('Register error:', err);
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) {
console.error('Verify email error:', err);
res.status(500).json({ error: 'Verification failed' });
}
});
// POST /api/auth/resend-verification
router.post('/resend-verification', 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, 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 = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
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);
} catch (mailErr) {
console.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) {
console.error('Resend verification error:', err);
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, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
console.error('Login error:', err);
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) {
console.warn('Redis blacklist write failed:', redisErr.message);
}
}
}
res.json({ message: 'Logged out successfully' });
} catch (err) {
console.error('Logout error:', err);
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' });
}
// M2: theme and language allowlists
if (theme !== undefined && theme !== null && !VALID_THEMES.has(theme)) {
return res.status(400).json({ error: 'Invalid theme' });
}
if (language !== undefined && language !== null && !VALID_LANGUAGES.has(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 - (330 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) {
console.error('Profile update error:', err);
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 = bcrypt.hashSync(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) {
console.error('Password change error:', err);
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 content type
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' });
}
const ext = contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : contentType.includes('webp') ? '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) {
const oldPath = path.join(uploadsDir, current.avatar_image);
if (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) {
console.error('Avatar upload error:', err);
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) {
const oldPath = path.join(uploadsDir, current.avatar_image);
if (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) {
console.error('Avatar delete error:', err);
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="64" fill="${escapeXml(color)}"/>
<text x="64" y="64" dy=".35em" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="52" font-weight="bold">${initials}</text>
</svg>`;
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;