3v324v23's picture
demo: 默认账号可直接登录并支持重置
a993770
Raw
History Blame Contribute Delete
5.71 kB
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
})
}