179 lines
7.2 KiB
JavaScript
179 lines
7.2 KiB
JavaScript
'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;
|