'use strict'; const express = require('express'); const bcrypt = require('bcryptjs'); const speakeasy = require('speakeasy'); const qrcode = require('qrcode'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const { db } = require('../db'); const { log } = require('../lib/audit'); const { requireAuth } = require('../middleware/auth'); const router = express.Router(); const AVATAR_DIR = process.env.AVATAR_DIR || path.join(__dirname, '..', 'avatars'); fs.mkdirSync(AVATAR_DIR, { recursive: true }); const ALLOWED_AVATAR = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }; const avatarUpload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, AVATAR_DIR), filename: (req, file, cb) => cb(null, `u${req.session.user.id}_${crypto.randomBytes(6).toString('hex')}${ALLOWED_AVATAR[file.mimetype] || ''}`), }), fileFilter: (req, file, cb) => cb(null, !!ALLOWED_AVATAR[file.mimetype]), limits: { fileSize: 4 * 1024 * 1024 }, // 4 MB }); function publicUser(u) { return { id: u.id, username: u.username, email: u.email, role: u.role, mfa_enabled: !!u.mfa_enabled, avatar: u.avatar ? `/api/auth/avatar/${u.id}?v=${encodeURIComponent(u.avatar)}` : null, }; } // Step 1: username + password. If MFA on, respond with mfa_required. router.post('/login', (req, res) => { const { username, password, token } = req.body || {}; const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username); if (!user || !bcrypt.compareSync(password || '', user.password_hash)) { log(req, 'LOGIN_FAILED', 'Login', `Failed login for "${username}"`); return res.status(401).json({ error: 'Invalid username or password.' }); } if (user.mfa_enabled) { if (!token) { return res.json({ mfa_required: true }); } const ok = speakeasy.totp.verify({ secret: user.mfa_secret, encoding: 'base32', token: String(token).replace(/\s/g, ''), window: 1, }); if (!ok) { log(req, 'MFA_FAILED', 'Login', `Bad MFA code for "${user.username}"`); return res.status(401).json({ error: 'Invalid authentication code.' }); } } req.session.user = publicUser(user); log(req, 'LOGIN', 'Login', `${user.username} signed in`); res.json({ ok: true, user: req.session.user }); }); router.post('/logout', requireAuth, (req, res) => { log(req, 'LOGOUT', 'Login', `${req.session.user.username} signed out`); req.session.destroy(() => res.json({ ok: true })); }); router.get('/me', requireAuth, (req, res) => { const fresh = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.user.id); req.session.user = publicUser(fresh); res.json({ user: req.session.user }); }); // --- MFA --------------------------------------------------------------- // Generate a secret + QR to scan. Secret is held on session until verified. router.post('/mfa/setup', requireAuth, async (req, res) => { const secret = speakeasy.generateSecret({ name: `Infosec (${req.session.user.username})`, }); req.session.pending_mfa = secret.base32; const qr = await qrcode.toDataURL(secret.otpauth_url); res.json({ otpauth_url: secret.otpauth_url, qr, base32: secret.base32 }); }); // Verify the first code and turn MFA on. router.post('/mfa/enable', requireAuth, (req, res) => { const { token } = req.body || {}; const pending = req.session.pending_mfa; if (!pending) return res.status(400).json({ error: 'Start MFA setup first.' }); const ok = speakeasy.totp.verify({ secret: pending, encoding: 'base32', token: String(token || '').replace(/\s/g, ''), window: 1, }); if (!ok) return res.status(400).json({ error: 'Code did not match. Try again.' }); db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?') .run(pending, req.session.user.id); delete req.session.pending_mfa; req.session.user.mfa_enabled = true; log(req, 'MFA_ENABLED', 'Account', `${req.session.user.username} enabled MFA`); res.json({ ok: true }); }); router.post('/mfa/disable', requireAuth, (req, res) => { const { password } = req.body || {}; const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.user.id); if (!bcrypt.compareSync(password || '', user.password_hash)) { return res.status(401).json({ error: 'Password incorrect.' }); } db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL WHERE id = ?') .run(user.id); req.session.user.mfa_enabled = false; log(req, 'MFA_DISABLED', 'Account', `${user.username} disabled MFA`); res.json({ ok: true }); }); // ---- Profile picture --------------------------------------------------- // Upload (or replace) the signed-in user's picture. function avatarUploadSafe(req, res, next) { avatarUpload.single('avatar')(req, res, (err) => { if (err) { const msg = err.code === 'LIMIT_FILE_SIZE' ? 'That image is larger than the 4 MB limit.' : 'Please choose a PNG, JPEG, WebP or GIF image (max 4 MB).'; return res.status(400).json({ error: msg }); } next(); }); } router.post('/avatar', requireAuth, avatarUploadSafe, (req, res) => { if (!req.file) { return res.status(400).json({ error: 'Please choose a PNG, JPEG, WebP or GIF image (max 4 MB).' }); } const id = req.session.user.id; const prev = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id); db.prepare('UPDATE users SET avatar = ?, avatar_mime = ? WHERE id = ?') .run(req.file.filename, req.file.mimetype, id); // Remove the old file so avatars do not accumulate on disk. if (prev && prev.avatar && prev.avatar !== req.file.filename) { try { fs.unlinkSync(path.join(AVATAR_DIR, prev.avatar)); } catch (_) { /* already gone */ } } req.session.user = publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(id)); log(req, 'AVATAR_UPDATED', 'My Account', `${req.session.user.username} updated their profile picture`); res.json({ ok: true, avatar: req.session.user.avatar }); }); // Remove the signed-in user's picture. router.delete('/avatar', requireAuth, (req, res) => { const id = req.session.user.id; const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id); if (row && row.avatar) { try { fs.unlinkSync(path.join(AVATAR_DIR, row.avatar)); } catch (_) { /* already gone */ } } db.prepare('UPDATE users SET avatar = NULL, avatar_mime = NULL WHERE id = ?').run(id); req.session.user = publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(id)); log(req, 'AVATAR_REMOVED', 'My Account', `${req.session.user.username} removed their profile picture`); res.json({ ok: true }); }); // Serve a user's picture. Any signed-in user may view another's (e.g. admins // looking at the user list), but never without a session. router.get('/avatar/:id', requireAuth, (req, res) => { const row = db.prepare('SELECT avatar, avatar_mime FROM users WHERE id = ?').get(Number(req.params.id)); if (!row || !row.avatar) return res.status(404).send('No picture.'); const file = path.join(AVATAR_DIR, row.avatar); if (!fs.existsSync(file)) return res.status(404).send('No picture.'); if (row.avatar_mime) res.type(row.avatar_mime); res.setHeader('Cache-Control', 'private, max-age=300'); res.sendFile(file); }); module.exports = router;