import { createHmac, timingSafeEqual } from 'node:crypto' import { createError, deleteCookie, getCookie, setCookie } from 'h3' import { getRuntimeEnvValue } from './runtime-env' import { shouldUseSecureSessionCookie } from './session-cookie' const memberSessionCookieName = 'sori_member_session' const sessionMaxAge = 60 * 60 * 24 * 14 /** * 회원 세션 서명 비밀값 조회 * @returns {string} 세션 서명 비밀값 */ const getSessionSecret = () => { const sessionSecret = getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret').trim() if (!sessionSecret) { throw createError({ statusCode: 500, message: '회원 세션 비밀값 환경 변수가 없습니다. MEMBER_SESSION_SECRET을 설정해 주세요.' }) } return sessionSecret } /** * 문자열 안전 비교 * @param {string} left - 비교 문자열 * @param {string} right - 비교 대상 문자열 * @returns {boolean} 일치 여부 */ 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 }} user - 회원 정보 * @returns {string} 세션 토큰 */ export const createMemberSessionToken = (user) => { const payload = Buffer.from(JSON.stringify({ userId: user.userId, email: user.email, expiresAt: Date.now() + sessionMaxAge * 1000 })).toString('base64url') return `${payload}.${signPayload(payload)}` } /** * 회원 세션 토큰 검증 * @param {string | undefined} token - 세션 토큰 * @returns {{ userId: string, email: string } | null} 세션 정보 */ export const verifyMemberSessionToken = (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.expiresAt || session.expiresAt < Date.now()) { return null } return { userId: session.userId, email: session.email } } /** * 회원 세션 쿠키 설정 * @param {import('h3').H3Event} event - 요청 이벤트 * @param {{ userId: string, email: string }} user - 회원 정보 * @returns {void} */ export const setMemberSession = (event, user) => { setCookie(event, memberSessionCookieName, createMemberSessionToken(user), { httpOnly: true, sameSite: 'lax', secure: shouldUseSecureSessionCookie(event), path: '/', maxAge: sessionMaxAge }) } /** * 회원 세션 쿠키 삭제 * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {void} */ export const clearMemberSession = (event) => { deleteCookie(event, memberSessionCookieName, { path: '/' }) } /** * 회원 세션 조회 * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {{ userId: string, email: string } | null} 세션 정보 */ export const getMemberSession = (event) => verifyMemberSessionToken(getCookie(event, memberSessionCookieName)) /** * 회원 세션 필수 확인 * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {{ userId: string, email: string }} 세션 정보 */ export const requireMemberSession = (event) => { const session = getMemberSession(event) if (!session) { throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) } return session }