Keep Authentik on aigen, isolate library data per instance, and purge Comfy outputs from the desktop after they are saved on the server. Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'
|
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { promisify } from 'node:util'
|
|
import type { H3Event } from 'h3'
|
|
|
|
const scryptAsync = promisify(scryptCb)
|
|
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
|
|
|
|
interface StoredCredentials {
|
|
username: string
|
|
passwordHash: string
|
|
updatedAt: number
|
|
}
|
|
|
|
function libraryRoot() {
|
|
const config = useRuntimeConfig()
|
|
return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '')
|
|
}
|
|
|
|
function authPath() {
|
|
return join(libraryRoot(), 'auth.enc')
|
|
}
|
|
|
|
function instancePath() {
|
|
return join(libraryRoot(), 'instance.id')
|
|
}
|
|
|
|
function key() {
|
|
return createHash('sha256').update(String(useRuntimeConfig().sessionSecret || 'dev-only-change-me')).digest()
|
|
}
|
|
|
|
function encryptJson(data: StoredCredentials) {
|
|
const iv = randomBytes(12)
|
|
const cipher = createCipheriv('aes-256-gcm', key(), iv)
|
|
const payload = Buffer.from(JSON.stringify(data), 'utf8')
|
|
const enc = Buffer.concat([cipher.update(payload), cipher.final()])
|
|
const tag = cipher.getAuthTag()
|
|
return `v1:${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`
|
|
}
|
|
|
|
function decryptJson(raw: string): StoredCredentials | null {
|
|
const [version, ivHex, tagHex, dataHex] = raw.split(':')
|
|
if (version !== 'v1' || !ivHex || !tagHex || !dataHex) return null
|
|
try {
|
|
const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(ivHex, 'hex'))
|
|
decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
|
|
const json = Buffer.concat([decipher.update(Buffer.from(dataHex, 'hex')), decipher.final()]).toString('utf8')
|
|
return JSON.parse(json) as StoredCredentials
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function hashPassword(password: string) {
|
|
const salt = randomBytes(16)
|
|
const hash = await scryptAsync(password, salt, 64) as Buffer
|
|
return `scrypt:${salt.toString('hex')}:${hash.toString('hex')}`
|
|
}
|
|
|
|
async function verifyPassword(stored: string, password: string) {
|
|
const parts = stored.split(':')
|
|
if (parts.length !== 3 || parts[0] !== 'scrypt') return false
|
|
const salt = Buffer.from(parts[1], 'hex')
|
|
const expected = Buffer.from(parts[2], 'hex')
|
|
const actual = await scryptAsync(password, salt, 64) as Buffer
|
|
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
|
}
|
|
|
|
function safeEqual(a: string, b: string) {
|
|
const left = Buffer.from(a)
|
|
const right = Buffer.from(b)
|
|
if (left.length !== right.length) {
|
|
timingSafeEqual(left, createHash('sha256').update(left).digest().subarray(0, left.length))
|
|
return false
|
|
}
|
|
return timingSafeEqual(left, right)
|
|
}
|
|
|
|
export function passwordAuthEnabled() {
|
|
const config = useRuntimeConfig()
|
|
return (config.authMode || config.public.authMode) === 'password'
|
|
}
|
|
|
|
export function oidcAuthEnabled() {
|
|
if (passwordAuthEnabled()) return false
|
|
const config = useRuntimeConfig()
|
|
return Boolean(config.public.authEnabled && config.oidcClientId && config.oidcClientSecret)
|
|
}
|
|
|
|
export function privateInstanceId() {
|
|
mkdirSync(libraryRoot(), { recursive: true })
|
|
if (existsSync(instancePath())) return readFileSync(instancePath(), 'utf8').trim()
|
|
const id = createHash('sha256').update(randomBytes(32)).digest('hex').slice(0, 24)
|
|
writeFileSync(instancePath(), id)
|
|
return id
|
|
}
|
|
|
|
async function writeCredentials(creds: StoredCredentials) {
|
|
mkdirSync(libraryRoot(), { recursive: true })
|
|
const tmp = `${authPath()}.tmp`
|
|
writeFileSync(tmp, encryptJson(creds), 'utf8')
|
|
renameSync(tmp, authPath())
|
|
}
|
|
|
|
export async function loadCredentials(): Promise<StoredCredentials | null> {
|
|
if (existsSync(authPath())) {
|
|
const stored = decryptJson(readFileSync(authPath(), 'utf8').trim())
|
|
if (stored?.username && stored.passwordHash) return stored
|
|
}
|
|
const config = useRuntimeConfig()
|
|
const username = String(config.authUsername || '').trim()
|
|
const password = String(config.authPassword || '')
|
|
if (!username || !password) return null
|
|
const created: StoredCredentials = {
|
|
username,
|
|
passwordHash: await hashPassword(password),
|
|
updatedAt: Date.now()
|
|
}
|
|
await writeCredentials(created)
|
|
return created
|
|
}
|
|
|
|
export async function updateCredentials(username: string, password: string) {
|
|
const nextUser = username.trim().slice(0, 80)
|
|
if (nextUser.length < 2) throw createError({ statusCode: 400, statusMessage: 'Username must be at least 2 characters' })
|
|
if (password.length < 8) throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters' })
|
|
await writeCredentials({
|
|
username: nextUser,
|
|
passwordHash: await hashPassword(password),
|
|
updatedAt: Date.now()
|
|
})
|
|
return { username: nextUser }
|
|
}
|
|
|
|
function clientKey(event: H3Event) {
|
|
return getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() || getRequestIP(event) || 'unknown'
|
|
}
|
|
|
|
export function assertLoginAllowed(event: H3Event) {
|
|
const key = clientKey(event)
|
|
const now = Date.now()
|
|
const current = loginAttempts.get(key)
|
|
if (current && current.resetAt < now) loginAttempts.delete(key)
|
|
const next = loginAttempts.get(key)
|
|
if (next && next.count >= 8) {
|
|
throw createError({ statusCode: 429, statusMessage: 'Too many login attempts. Wait a few minutes.' })
|
|
}
|
|
}
|
|
|
|
export function recordLoginFailure(event: H3Event) {
|
|
const key = clientKey(event)
|
|
const now = Date.now()
|
|
const current = loginAttempts.get(key)
|
|
if (!current || current.resetAt < now) {
|
|
loginAttempts.set(key, { count: 1, resetAt: now + 15 * 60 * 1000 })
|
|
return
|
|
}
|
|
current.count += 1
|
|
}
|
|
|
|
export function clearLoginFailures(event: H3Event) {
|
|
loginAttempts.delete(clientKey(event))
|
|
}
|
|
|
|
export async function verifyLocalLogin(username: string, password: string) {
|
|
const creds = await loadCredentials()
|
|
if (!creds) throw createError({ statusCode: 503, statusMessage: 'Login is not configured yet' })
|
|
const userOk = safeEqual(creds.username, username.trim())
|
|
const passOk = await verifyPassword(creds.passwordHash, password)
|
|
if (!userOk || !passOk) return null
|
|
return creds.username
|
|
}
|
|
|
|
export function publicUsername() {
|
|
if (!existsSync(authPath())) return String(useRuntimeConfig().authUsername || '').trim()
|
|
const stored = decryptJson(readFileSync(authPath(), 'utf8').trim())
|
|
return stored?.username || ''
|
|
}
|