← back to AbramsOS
routes/auth-app.js
171 lines
const express = require('express');
const auth = require('../lib/auth');
const db = require('../lib/db');
const router = express.Router();
const DEV_USER_ID = 'user_steve';
function clientMeta(req) {
return {
ip: (req.headers['x-forwarded-for']?.split(',')[0].trim() || req.ip || null),
userAgent: req.headers['user-agent'] || null,
};
}
// ─── signup (single-user, locked after first) ───────────
router.get('/signup', async (_req, res) => {
const n = await auth.userCount();
if (n > 0) return res.redirect('/signin');
res.render('signup', { error: null });
});
router.post('/signup', async (req, res) => {
const n = await auth.userCount();
if (n > 0) return res.redirect('/signin');
const { email, password } = req.body || {};
if (!email || !password) return res.render('signup', { error: 'email + password required' });
if (password.length < 12) return res.render('signup', { error: 'password must be at least 12 characters' });
try {
// Upsert by id so the seeded row gets the user-submitted email
await db.query(
`INSERT INTO user_account (id, email, display_name) VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name`,
[DEV_USER_ID, email, email.split('@')[0]]
);
await auth.setPassword(DEV_USER_ID, password);
// Generate the TOTP secret immediately and force enrollment on next page
const secret = auth.generateTotpSecret();
await auth.setTotpSecret(DEV_USER_ID, secret);
const meta = clientMeta(req);
await auth.event({ userId: DEV_USER_ID, eventType: 'signup', ...meta, metadata: { email } });
// Sign them into a temporary session so they can complete TOTP enrollment
const { sid } = await auth.createSession(DEV_USER_ID, meta);
res.cookie(auth.SESSION_COOKIE, sid, { httpOnly: true, sameSite: 'lax', maxAge: auth.SESSION_TTL_MS });
res.redirect('/enroll-totp');
} catch (err) {
console.error('[signup]', err);
res.render('signup', { error: err.message });
}
});
// ─── TOTP enrollment ────────────────────────────────────
router.get('/enroll-totp', async (req, res) => {
if (!req.userId) return res.redirect('/signin');
const totp = await auth.getTotpSecret(req.userId);
if (!totp) return res.redirect('/signup');
if (totp.enrolled) return res.redirect('/');
const userR = await db.query(`SELECT email FROM user_account WHERE id = $1`, [req.userId]);
const email = userR.rows[0]?.email || 'user';
const uri = auth.totpUri(totp.secret, email);
const qr = await auth.totpQrDataUrl(uri);
res.render('enroll-totp', { qr, secret: totp.secret, error: null });
});
router.post('/enroll-totp', async (req, res) => {
if (!req.userId) return res.redirect('/signin');
const totp = await auth.getTotpSecret(req.userId);
if (!totp) return res.redirect('/signup');
const { token } = req.body || {};
if (!auth.verifyTotp(token, totp.secret)) {
const userR = await db.query(`SELECT email FROM user_account WHERE id = $1`, [req.userId]);
const email = userR.rows[0]?.email || 'user';
const uri = auth.totpUri(totp.secret, email);
const qr = await auth.totpQrDataUrl(uri);
return res.render('enroll-totp', { qr, secret: totp.secret, error: 'code did not match — try again' });
}
await auth.markTotpEnrolled(req.userId);
await auth.markStepUp(req.session.id);
await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'totp_enroll', ...clientMeta(req) });
res.redirect('/');
});
// ─── signin (password → TOTP → session) ─────────────────
router.get('/signin', async (req, res) => {
if (req.userId) return res.redirect('/');
const n = await auth.userCount();
if (n === 0) return res.redirect('/signup');
res.render('signin', { stage: 'password', error: null, next: req.query.next || '/' });
});
router.post('/signin', async (req, res) => {
const meta = clientMeta(req);
const next = req.body?.next || '/';
// Stage 1: password
if (req.body?.stage === 'password' || !req.body?.stage) {
const { email, password } = req.body || {};
const userR = await db.query(`SELECT id FROM user_account WHERE email = $1`, [email || '']);
const cred = userR.rows[0] ? await auth.getCredential(userR.rows[0].id) : null;
const ok = cred ? await auth.verifyPassword(password || '', cred.password_hash) : false;
if (!ok) {
await auth.event({ userId: userR.rows[0]?.id || null, eventType: 'signin_pwd_fail', ...meta, metadata: { email } });
// constant-time-ish failure
return res.render('signin', { stage: 'password', error: 'invalid email or password', next });
}
const userId = userR.rows[0].id;
await auth.event({ userId, eventType: 'signin_pwd_ok', ...meta });
// Begin a session marked as NOT step-upped yet
const { sid } = await auth.createSession(userId, meta);
res.cookie(auth.SESSION_COOKIE, sid, { httpOnly: true, sameSite: 'lax', maxAge: auth.SESSION_TTL_MS });
return res.render('signin', { stage: 'totp', error: null, next });
}
// Stage 2: TOTP
if (req.body?.stage === 'totp') {
if (!req.userId) return res.redirect('/signin');
const totp = await auth.getTotpSecret(req.userId);
if (!totp || !totp.enrolled) return res.redirect('/enroll-totp');
const { token } = req.body || {};
if (!auth.verifyTotp(token, totp.secret)) {
await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'totp_fail', ...meta });
return res.render('signin', { stage: 'totp', error: 'invalid code', next });
}
await auth.markStepUp(req.session.id);
await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'totp_ok', ...meta });
return res.redirect(next);
}
res.redirect('/signin');
});
router.post('/signout', async (req, res) => {
if (req.session?.id) {
await auth.revokeSession(req.session.id);
await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'signout', ...clientMeta(req) });
}
res.clearCookie(auth.SESSION_COOKIE);
res.redirect('/signin');
});
// ─── step-up re-verify (for sensitive actions) ──────────
router.get('/step-up', (req, res) => {
if (!req.userId) return res.redirect('/signin');
res.render('step-up', { error: null, next: req.query.next || '/' });
});
router.post('/step-up', async (req, res) => {
if (!req.userId) return res.redirect('/signin');
const totp = await auth.getTotpSecret(req.userId);
const next = req.body?.next || '/';
if (!totp || !auth.verifyTotp(req.body?.token, totp.secret)) {
await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'step_up_fail', ...clientMeta(req) });
return res.render('step-up', { error: 'invalid code', next });
}
await auth.markStepUp(req.session.id);
await auth.event({ userId: req.userId, sessionId: req.session.id, eventType: 'step_up_ok', ...clientMeta(req) });
res.redirect(next);
});
module.exports = router;