diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js new file mode 100644 index 0000000..8eb9d0b --- /dev/null +++ b/services/mam-api/src/routes/auth.js @@ -0,0 +1,136 @@ +/** + * 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;