| 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<string, unknown> |
| 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<void> { |
| 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<unknown> = 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<DbSchema> { |
| 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<void> { |
| 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<void> { |
| 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<T>(fn: (db: DbSchema) => Promise<T> | T): Promise<T> { |
| 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<T> |
| } |
|
|
| 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 |
| }) |
| } |
|
|