← 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;