Files
PicHost/server/utils/auth.ts
T
O96u bc4cba7e52 release: PicHost v1.0.0
Multi-user image hosting with RBAC, upload preferences, and admin settings.
2026-08-22 14:36:57 +08:00

198 lines
4.7 KiB
TypeScript

import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
import type { H3Event } from 'h3'
import {
countUsers,
createSessionRow,
deleteSession,
findSessionById,
findUserById,
findUserByUsername,
insertUser,
type UserRole,
type UserRow
} from './db'
const SCRYPT_N = 16384
const SCRYPT_R = 8
const SCRYPT_P = 1
const SCRYPT_KEY_LEN = 64
const MIN_PASSWORD_LENGTH = 8
const USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
export const SESSION_COOKIE = 'pic_session'
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7 // 7 days
export interface AuthUser {
id: number
username: string
role: UserRole
}
function timingSafeEqualStr(a: string, b: string): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
export function isValidUsername(username: string): boolean {
return USERNAME_PATTERN.test(username)
}
export function isValidPassword(password: string): boolean {
return password.length >= MIN_PASSWORD_LENGTH
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex')
const derived = scryptSync(password, salt, SCRYPT_KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P
})
return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt}$${derived.toString('hex')}`
}
export function verifyPassword(password: string, stored: string): boolean {
const parts = stored.split('$')
if (parts.length !== 6 || parts[0] !== 'scrypt') return false
const N = Number(parts[1])
const r = Number(parts[2])
const p = Number(parts[3])
const salt = parts[4]
const hashHex = parts[5]
if (!salt || !hashHex || !Number.isFinite(N)) return false
try {
const derived = scryptSync(password, salt, SCRYPT_KEY_LEN, { N, r, p })
return timingSafeEqualStr(derived.toString('hex'), hashHex)
} catch {
return false
}
}
function hashSessionToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
function generateSessionToken(): string {
return randomBytes(32).toString('hex')
}
function sessionExpiresAt(): string {
return new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000).toISOString()
}
function cookieSecure(event: H3Event): boolean {
return getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).protocol === 'https:'
}
export function setSessionCookie(event: H3Event, sessionId: string): void {
setCookie(event, SESSION_COOKIE, sessionId, {
httpOnly: true,
secure: cookieSecure(event),
sameSite: 'lax',
path: '/',
maxAge: SESSION_MAX_AGE_SEC
})
}
export function clearSessionCookie(event: H3Event): void {
deleteCookie(event, SESSION_COOKIE, { path: '/' })
}
export async function createSession(
event: H3Event,
userId: number
): Promise<string> {
const sessionId = randomBytes(24).toString('hex')
const token = generateSessionToken()
const tokenHash = hashSessionToken(token)
const expiresAt = sessionExpiresAt()
const createdAt = new Date().toISOString()
createSessionRow({
id: sessionId,
userId,
tokenHash,
expiresAt,
createdAt
})
setSessionCookie(event, sessionId)
return sessionId
}
export async function destroySession(event: H3Event): Promise<void> {
const sessionId = getCookie(event, SESSION_COOKIE)
if (sessionId) {
deleteSession(sessionId)
}
clearSessionCookie(event)
}
export function rowToAuthUser(row: UserRow): AuthUser {
return {
id: row.id,
username: row.username,
role: row.role
}
}
export async function getCurrentUser(event: H3Event): Promise<AuthUser | null> {
const sessionId = getCookie(event, SESSION_COOKIE)
if (!sessionId) return null
const session = findSessionById(sessionId)
if (!session) return null
if (new Date(session.expires_at) <= new Date()) {
deleteSession(sessionId)
return null
}
const user = findUserById(session.user_id)
if (!user) return null
return rowToAuthUser(user)
}
export async function createUserAccount(input: {
username: string
password: string
role: UserRole
}): Promise<AuthUser> {
if (!isValidUsername(input.username)) {
throw new Error('INVALID_USERNAME')
}
if (!isValidPassword(input.password)) {
throw new Error('INVALID_PASSWORD')
}
const existing = findUserByUsername(input.username)
if (existing) {
throw new Error('USERNAME_TAKEN')
}
const passwordHash = hashPassword(input.password)
const createdAt = new Date().toISOString()
const id = insertUser({
username: input.username,
passwordHash,
role: input.role,
createdAt
})
return {
id,
username: input.username,
role: input.role
}
}
export function isInitialized(): boolean {
return countUsers() > 0
}