136 lines
4.4 KiB
JavaScript
136 lines
4.4 KiB
JavaScript
/**
|
|
* 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;
|