2026-05-27 14:21:32 -04:00
|
|
|
import express from 'express';
|
|
|
|
|
import pool from '../db/pool.js';
|
2026-05-27 14:42:53 -04:00
|
|
|
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
|
2026-05-27 14:28:18 -04:00
|
|
|
import { hashPassword, comparePassword } from '../auth/passwords.js';
|
2026-05-27 14:21:32 -04:00
|
|
|
|
2026-05-27 14:35:59 -04:00
|
|
|
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
|
|
|
|
|
|
2026-05-27 14:21:32 -04:00
|
|
|
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); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 14:24:56 -04:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 14:28:18 -04:00
|
|
|
// 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) {
|
2026-05-27 14:35:59 -04:00
|
|
|
// Pre-computed bcrypt hash of a value that no real password input will match.
|
|
|
|
|
// Used to keep the user-not-found response time uniform with the wrong-password
|
|
|
|
|
// path (~180ms at cost 12) so user enumeration via timing isn't possible.
|
|
|
|
|
await comparePassword(password, DUMMY_PASSWORD_HASH);
|
2026-05-27 14:28:18 -04:00
|
|
|
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()));
|
|
|
|
|
|
2026-05-27 14:35:59 -04:00
|
|
|
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
|
|
|
|
|
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
2026-05-27 14:28:18 -04:00
|
|
|
|
|
|
|
|
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 14:38:05 -04:00
|
|
|
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
|
|
|
|
|
router.post('/logout', (req, res) => {
|
|
|
|
|
if (!req.session) return res.status(204).end();
|
|
|
|
|
req.session.destroy(err => {
|
|
|
|
|
if (err) console.error('[auth] session destroy failed:', err.message);
|
|
|
|
|
res.clearCookie('dragonflight.sid', { path: '/' });
|
|
|
|
|
res.status(204).end();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 14:42:53 -04:00
|
|
|
// GET /api/v1/auth/me
|
|
|
|
|
router.get('/me', requireAuth, (req, res) => {
|
|
|
|
|
res.json({ id: req.user.id, username: req.user.username, display_name: req.user.display_name });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/v1/auth/password { current_password, new_password }
|
|
|
|
|
router.post('/password', requireAuth, async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const { current_password, new_password } = req.body || {};
|
|
|
|
|
if (!current_password || !new_password) return badRequest(res, 'current_password and new_password required');
|
|
|
|
|
if (new_password.length < MIN_PASSWORD_LEN) return badRequest(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' characters');
|
|
|
|
|
|
|
|
|
|
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
|
|
|
|
|
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
|
|
|
|
|
if (!(await comparePassword(current_password, rows[0].password_hash))) {
|
|
|
|
|
return badRequest(res, 'current password is incorrect');
|
|
|
|
|
}
|
|
|
|
|
const newHash = await hashPassword(new_password);
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
|
|
|
|
|
[newHash, req.user.id]
|
|
|
|
|
);
|
|
|
|
|
res.status(204).end();
|
|
|
|
|
} catch (err) { next(err); }
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-27 14:21:32 -04:00
|
|
|
export default router;
|
|
|
|
|
export { realUserCount };
|