← back to AbramsOS

lib/auth.js

147 lines

const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { authenticator } = require('otplib');
const QRCode = require('qrcode');

const db = require('./db');
const { encrypt, decrypt } = require('./crypto');
const { id } = require('./ids');

const SESSION_COOKIE = 'aos.sid';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;       // 30 days
const STEP_UP_TTL_MS = 60 * 1000;                       // 60 seconds
const BCRYPT_ROUNDS = 12;

authenticator.options = { window: 1 }; // accept ±30s drift

// ─── password ────────────────────────────────────────────
async function hashPassword(plain) {
  if (!plain || plain.length < 12) throw new Error('password must be ≥12 chars');
  return bcrypt.hash(plain, BCRYPT_ROUNDS);
}
async function verifyPassword(plain, hash) {
  return bcrypt.compare(plain, hash);
}

// ─── TOTP ────────────────────────────────────────────────
function generateTotpSecret() {
  return authenticator.generateSecret();
}
function verifyTotp(token, secret) {
  if (!token) return false;
  return authenticator.verify({ token: String(token).replace(/\s/g, ''), secret });
}
function totpUri(secret, accountName, issuer = 'AbramsOS') {
  return authenticator.keyuri(accountName, issuer, secret);
}
async function totpQrDataUrl(uri) {
  return QRCode.toDataURL(uri);
}

// ─── single-user lockout ────────────────────────────────
async function userCount() {
  const r = await db.query('SELECT count(*)::int AS n FROM auth_credential');
  return r.rows[0].n;
}

// ─── sessions ────────────────────────────────────────────
function newSessionId() {
  return crypto.randomBytes(32).toString('base64url');
}

async function createSession(userId, { ip = null, userAgent = null } = {}) {
  const sid = newSessionId();
  const expires = new Date(Date.now() + SESSION_TTL_MS);
  await db.query(
    `INSERT INTO auth_session (id, user_id, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5)`,
    [sid, userId, expires, ip, userAgent]
  );
  return { sid, expiresAt: expires };
}

async function loadSession(sid) {
  if (!sid) return null;
  const r = await db.query(
    `SELECT id, user_id, step_up_at, expires_at, revoked_at FROM auth_session WHERE id = $1`,
    [sid]
  );
  if (!r.rows.length) return null;
  const s = r.rows[0];
  if (s.revoked_at) return null;
  if (new Date(s.expires_at).getTime() < Date.now()) return null;
  return s;
}

async function markStepUp(sid) {
  await db.query(`UPDATE auth_session SET step_up_at = now() WHERE id = $1`, [sid]);
}

function isStepUpValid(session) {
  if (!session?.step_up_at) return false;
  const age = Date.now() - new Date(session.step_up_at).getTime();
  return age < STEP_UP_TTL_MS;
}

async function revokeSession(sid) {
  await db.query(`UPDATE auth_session SET revoked_at = now() WHERE id = $1`, [sid]);
}

// ─── audit ──────────────────────────────────────────────
async function event({ userId = null, sessionId = null, eventType, ip = null, userAgent = null, metadata = {} }) {
  await db.query(
    `INSERT INTO auth_event (user_id, session_id, event_type, ip, user_agent, metadata_jsonb)
     VALUES ($1, $2, $3, $4, $5, $6)`,
    [userId, sessionId, eventType, ip, userAgent, metadata]
  );
}

// ─── credential ops ─────────────────────────────────────
async function setPassword(userId, plain) {
  const hash = await hashPassword(plain);
  await db.query(
    `INSERT INTO auth_credential (id, user_id, password_hash) VALUES ($1, $2, $3)
     ON CONFLICT (user_id) DO UPDATE SET password_hash = EXCLUDED.password_hash, rotated_at = now()`,
    [id('user'), userId, hash]
  );
}

async function getCredential(userId) {
  const r = await db.query(`SELECT password_hash FROM auth_credential WHERE user_id = $1`, [userId]);
  return r.rows[0] || null;
}

async function setTotpSecret(userId, secret) {
  const enc = encrypt(secret);
  await db.query(
    `INSERT INTO auth_totp (id, user_id, secret_encrypted, secret_iv, secret_tag) VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (user_id) DO UPDATE SET secret_encrypted = EXCLUDED.secret_encrypted, secret_iv = EXCLUDED.secret_iv, secret_tag = EXCLUDED.secret_tag, enrolled_at = NULL`,
    [id('user'), userId, enc.ciphertext, enc.iv, enc.tag]
  );
}

async function getTotpSecret(userId) {
  const r = await db.query(
    `SELECT secret_encrypted, secret_iv, secret_tag, enrolled_at FROM auth_totp WHERE user_id = $1`,
    [userId]
  );
  if (!r.rows.length) return null;
  const row = r.rows[0];
  const secret = decrypt(row.secret_encrypted, row.secret_iv, row.secret_tag);
  return { secret, enrolled: !!row.enrolled_at };
}

async function markTotpEnrolled(userId) {
  await db.query(`UPDATE auth_totp SET enrolled_at = COALESCE(enrolled_at, now()) WHERE user_id = $1`, [userId]);
}

module.exports = {
  SESSION_COOKIE, SESSION_TTL_MS, STEP_UP_TTL_MS,
  hashPassword, verifyPassword,
  generateTotpSecret, verifyTotp, totpUri, totpQrDataUrl,
  userCount,
  newSessionId, createSession, loadSession, markStepUp, isStepUpValid, revokeSession,
  event,
  setPassword, getCredential,
  setTotpSecret, getTotpSecret, markTotpEnrolled,
};