Files
ISDSS---Internal-Survailanc…/routes/auth.js
T
2026-09-13 20:12:28 +01:00

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: `Martinhal ISDSS (${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;