import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import crypto from 'node:crypto' import { hashPassword } from './crypto.js' export type SessionKind = 'full' | 'mfa_pending' export type DbUser = { id: string email: string password_hash: string created_at: string login_failures?: number login_locked_until?: string | null } export type DbMfaSettings = { user_id: string totp_enabled: boolean totp_secret: string | null totp_secret_pending: string | null totp_verified_at: string | null backup_batch_id: string | null updated_at: string mfa_failures?: number mfa_locked_until?: string | null } export type DbBackupCode = { id: string user_id: string batch_id: string code_hash: string created_at: string used_at: string | null } export type DbRecoveryRequest = { id: string user_id: string method: 'backup_code' | 'password' status: 'pending' | 'completed' | 'cancelled' created_at: string resolved_at: string | null } export type DbAuditLog = { id: string user_id: string action: string result: 'success' | 'failure' ip: string | null user_agent: string | null meta: Record created_at: string } export type DbSession = { id: string user_id: string kind: SessionKind created_at: string expires_at: string } export type DbSchema = { users: DbUser[] mfa_settings: DbMfaSettings[] backup_codes: DbBackupCode[] recovery_requests: DbRecoveryRequest[] audit_logs: DbAuditLog[] sessions: DbSession[] } const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) let dataDir = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : path.resolve(__dirname, '../data') let dbPath = path.resolve(dataDir, 'db.json') function switchDataDir(nextDir: string) { dataDir = path.resolve(nextDir) dbPath = path.resolve(dataDir, 'db.json') } async function ensureDataDirWritable(): Promise { try { await fs.mkdir(dataDir, { recursive: true }) return } catch (e) { const err = e as NodeJS.ErrnoException if (err?.code === 'EACCES' || err?.code === 'EPERM' || err?.code === 'EROFS') { switchDataDir('/tmp/totp-2fa-closedloop') await fs.mkdir(dataDir, { recursive: true }) return } throw e } } let queue: Promise = Promise.resolve() export function nowIso() { return new Date().toISOString() } export function newId() { return crypto.randomUUID() } function createEmptyDb(): DbSchema { return { users: [], mfa_settings: [], backup_codes: [], recovery_requests: [], audit_logs: [], sessions: [], } } async function readDbFile(): Promise { await ensureDataDirWritable() try { const raw = await fs.readFile(dbPath, 'utf8') const parsed = JSON.parse(raw) as DbSchema const db = { ...createEmptyDb(), ...parsed, } await ensureDemoAccount(db) return db } catch (e) { const err = e as NodeJS.ErrnoException if (err?.code === 'ENOENT') { const empty = createEmptyDb() await ensureDemoAccount(empty) await fs.writeFile(dbPath, JSON.stringify(empty, null, 2), 'utf8') return empty } if (err?.code === 'EACCES' || err?.code === 'EPERM' || err?.code === 'EROFS') { switchDataDir('/tmp/totp-2fa-closedloop') await fs.mkdir(dataDir, { recursive: true }) const empty = createEmptyDb() await ensureDemoAccount(empty) await fs.writeFile(dbPath, JSON.stringify(empty, null, 2), 'utf8') return empty } throw e } } async function ensureDemoAccount(db: DbSchema): Promise { const email = 'demo@example.com' const password = 'Demo12345' let user = db.users.find((u) => u.email === email) if (!user) { const userId = newId() const passwordHash = await hashPassword(password) user = { id: userId, email, password_hash: passwordHash, created_at: nowIso(), login_failures: 0, login_locked_until: null, } db.users.push(user) } user.login_failures = 0 user.login_locked_until = null let mfa = db.mfa_settings.find((x) => x.user_id === user.id) if (!mfa) { db.mfa_settings.push({ user_id: user.id, totp_enabled: false, totp_secret: null, totp_secret_pending: null, totp_verified_at: null, backup_batch_id: null, updated_at: nowIso(), mfa_failures: 0, mfa_locked_until: null, }) mfa = db.mfa_settings.find((x) => x.user_id === user.id) ?? null } if (mfa) { mfa.mfa_failures = 0 mfa.mfa_locked_until = null } } async function writeDbFile(db: DbSchema): Promise { await ensureDataDirWritable() try { await fs.writeFile(dbPath, JSON.stringify(db, null, 2), 'utf8') } catch (e) { const err = e as NodeJS.ErrnoException if (err?.code === 'EACCES' || err?.code === 'EPERM' || err?.code === 'EROFS') { switchDataDir('/tmp/totp-2fa-closedloop') await fs.mkdir(dataDir, { recursive: true }) await fs.writeFile(dbPath, JSON.stringify(db, null, 2), 'utf8') return } throw e } } export async function withDb(fn: (db: DbSchema) => Promise | T): Promise { const task = async () => { const db = await readDbFile() const result = await fn(db) await writeDbFile(db) return result } queue = queue.then(task, task) return queue as Promise } export function pruneExpiredSessions(db: DbSchema) { const now = Date.now() db.sessions = db.sessions.filter((s) => { const exp = Date.parse(s.expires_at) return Number.isFinite(exp) && exp > now }) }