import { createHmac, timingSafeEqual } from 'node:crypto' import { createError, deleteCookie, getCookie, setCookie } from 'h3' import { getRuntimeEnvValue } from './runtime-env' const adminSessionCookieName = 'sori_admin_session' const sessionMaxAge = 60 * 60 * 12 /** * 세션 서명 비밀값 조회 * @returns {string} 세션 서명 비밀값 */ const getSessionSecret = () => { const adminPassword = getRuntimeEnvValue('ADMIN_PASSWORD', 'adminPassword') if (!adminPassword) { throw createError({ statusCode: 500, message: '관리자 비밀번호 환경 변수가 없습니다.' }) } return adminPassword } /** * 문자열 안전 비교 * @param {string} left - 비교 문자열 * @param {string} right - 비교 대상 문자열 * @returns {boolean} 일치 여부 */ export const safeCompare = (left, right) => { const leftBuffer = Buffer.from(left) const rightBuffer = Buffer.from(right) if (leftBuffer.length !== rightBuffer.length) { return false } return timingSafeEqual(leftBuffer, rightBuffer) } /** * 세션 페이로드 서명 * @param {string} payload - 인코딩된 세션 페이로드 * @returns {string} 세션 서명 */ const signPayload = (payload) => createHmac('sha256', getSessionSecret()) .update(payload) .digest('base64url') /** * 관리자 세션 토큰 생성 * @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보 * @returns {string} 세션 토큰 */ export const createAdminSessionToken = (adminUser) => { const payload = Buffer.from(JSON.stringify({ userId: adminUser.userId, email: adminUser.email, role: 'admin', expiresAt: Date.now() + sessionMaxAge * 1000 })).toString('base64url') return `${payload}.${signPayload(payload)}` } /** * 관리자 세션 토큰 검증 * @param {string | undefined} token - 세션 토큰 * @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보 */ export const verifyAdminSessionToken = (token) => { if (!token) { return null } const [payload, signature] = token.split('.') if (!payload || !signature || !safeCompare(signature, signPayload(payload))) { return null } let session = null try { session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) } catch { return null } if (!session.userId || !session.email || session.role !== 'admin' || !session.expiresAt || session.expiresAt < Date.now()) { return null } return { userId: session.userId, email: session.email, role: 'admin' } } /** * 관리자 세션 쿠키 설정 * @param {import('h3').H3Event} event - 요청 이벤트 * @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보 * @returns {void} */ export const setAdminSession = (event, adminUser) => { setCookie(event, adminSessionCookieName, createAdminSessionToken(adminUser), { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/admin', maxAge: sessionMaxAge }) } /** * 관리자 세션 쿠키 삭제 * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {void} */ export const clearAdminSession = (event) => { deleteCookie(event, adminSessionCookieName, { path: '/admin' }) } /** * 관리자 세션 조회 * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보 */ export const getAdminSession = (event) => verifyAdminSessionToken(getCookie(event, adminSessionCookieName)) /** * 관리자 세션 필수 확인 * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {{ userId: string, email: string, role: 'admin' }} 세션 정보 */ export const requireAdminSession = (event) => { const session = getAdminSession(event) if (!session) { throw createError({ statusCode: 401, message: '관리자 로그인이 필요합니다.' }) } return session }