/** * Authentication routes * * POST /api/v1/auth/login — exchange username+password for a session cookie * POST /api/v1/auth/logout — destroy the current session * GET /api/v1/auth/me — return the currently authenticated user * POST /api/v1/auth/setup — one-time admin bootstrap (disabled after first user exists) */ import express from 'express'; import bcrypt from 'bcrypt'; import pool from '../db/pool.js'; const router = express.Router(); // --------------------------------------------------------------------------- // POST /login // --------------------------------------------------------------------------- router.post('/login', async (req, res, next) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } const result = await pool.query( 'SELECT * FROM users WHERE username = $1', [username.trim().toLowerCase()] ); if (result.rows.length === 0) { // Timing-safe: still run compare on a dummy hash so response time is constant await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000'); return res.status(401).json({ error: 'Invalid credentials' }); } const user = result.rows[0]; const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { return res.status(401).json({ error: 'Invalid credentials' }); } // Regenerate session ID to prevent fixation attacks req.session.regenerate((err) => { if (err) return next(err); req.session.userId = user.id; req.session.username = user.username; req.session.role = user.role; res.json({ id: user.id, username: user.username, display_name: user.display_name, role: user.role, }); }); } catch (err) { next(err); } }); // --------------------------------------------------------------------------- // POST /logout // --------------------------------------------------------------------------- router.post('/logout', (req, res, next) => { req.session.destroy((err) => { if (err) return next(err); res.clearCookie('connect.sid'); res.json({ message: 'Logged out' }); }); }); // --------------------------------------------------------------------------- // GET /me // --------------------------------------------------------------------------- router.get('/me', async (req, res) => { if (!req.session || !req.session.userId) { return res.status(401).json({ error: 'Not authenticated' }); } try { const result = await pool.query( 'SELECT id, username, display_name, role FROM users WHERE id = $1', [req.session.userId] ); if (result.rows.length === 0) { req.session.destroy(() => {}); return res.status(401).json({ error: 'User not found' }); } res.json(result.rows[0]); } catch (err) { // Fallback to session data if DB unreachable res.json({ id: req.session.userId, username: req.session.username, role: req.session.role, }); } }); // --------------------------------------------------------------------------- // POST /setup — one-time first-admin bootstrap // --------------------------------------------------------------------------- router.post('/setup', async (req, res, next) => { try { const { username, password, display_name } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } if (password.length < 8) { return res.status(400).json({ error: 'Password must be at least 8 characters' }); } // Block if any user already exists const count = await pool.query('SELECT COUNT(*) FROM users'); if (parseInt(count.rows[0].count, 10) > 0) { return res.status(403).json({ error: 'Setup is already complete. Use an existing admin account to add more users.', }); } const hash = await bcrypt.hash(password, 12); const result = await pool.query( `INSERT INTO users (username, password_hash, display_name, role) VALUES ($1, $2, $3, 'admin') RETURNING id, username, display_name, role`, [username.trim().toLowerCase(), hash, display_name || username] ); res.status(201).json(result.rows[0]); } catch (err) { next(err); } }); export default router;