v_2.3
This commit is contained in:
+178
@@ -0,0 +1,178 @@
|
||||
'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;
|
||||
Reference in New Issue
Block a user