dragonflight/services/mam-api/src/routes/auth.js

98 lines
3.8 KiB
JavaScript
Raw Normal View History

import express from 'express';
import pool from '../db/pool.js';
import { DEV_USER_ID } from '../middleware/auth.js';
import { hashPassword, comparePassword } from '../auth/passwords.js';
const router = express.Router();
// Real users = anyone except the seeded dev row.
async function realUserCount() {
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
return rows[0].n;
}
// GET /api/v1/auth/setup-required
// Cheap, no auth. Used by AuthGate to decide between Login and Setup screens.
router.get('/setup-required', async (_req, res, next) => {
try {
res.json({ required: (await realUserCount()) === 0 });
} catch (err) { next(err); }
});
const MIN_PASSWORD_LEN = 12;
function badRequest(res, msg) { return res.status(400).json({ error: msg }); }
// POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists.
router.post('/setup', async (req, res, next) => {
try {
const { username, password } = req.body || {};
if (!username || typeof username !== 'string') return badRequest(res, 'username required');
if (!password || typeof password !== 'string') return badRequest(res, 'password required');
if (password.length < MIN_PASSWORD_LEN) return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters');
if ((await realUserCount()) > 0) {
return res.status(409).json({ error: 'setup already complete' });
}
const hash = await hashPassword(password);
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role)
VALUES ($1, $2, $1, 'admin')
RETURNING id, username, display_name`,
[username.trim(), hash]
);
const user = rows[0];
// Immediately log them in.
req.session.user_id = user.id;
req.session.first_seen_at = Date.now();
req.session.last_seen_at = Date.now();
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
res.json({ user });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
next(err);
}
});
// POST /api/v1/auth/login — authenticate an existing user by username + password.
router.post('/login', async (req, res, next) => {
try {
const { username, password } = req.body || {};
if (!username || !password) return res.status(401).json({ error: 'invalid credentials' });
const { rows } = await pool.query(
`SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
[username.trim(), DEV_USER_ID]
);
if (rows.length === 0) {
// Still hash the supplied password against a dummy to keep response time uniform.
await comparePassword(password, '$2b$12$dummyhashthatwillalwaysfailtocomparexxxxxxxxxxxxxxxxxxxx');
return res.status(401).json({ error: 'invalid credentials' });
}
const user = rows[0];
if (!(await comparePassword(password, user.password_hash))) {
return res.status(401).json({ error: 'invalid credentials' });
}
req.session.user_id = user.id;
req.session.first_seen_at = Date.now();
req.session.last_seen_at = Date.now();
// The critical line — wait for the row to land in `sessions` before responding.
// Without this, the SPA's next request races the store write, hits 401, and
// the prior bounce-to-login logic produced an infinite loop.
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]).catch(() => {});
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
} catch (err) { next(err); }
});
export default router;
export { realUserCount };