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