Files
sori.studio/server/utils/member-auth.js
zenn c43873ce5f v1.3.5: 관리자 로그인·대시보드 차트·통계 보관 정리
운영 HTTP에서 관리자 세션이 유지되지 않던 문제를 쿠키 공통화로 수정하고, 통계 클라이언트 분리·조회 오류·기간별 차트를 보강했다. 방문자 해시는 32일 초과분만 정리하고 일별 집계는 누적 보관한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 13:54:38 +09:00

152 lines
3.9 KiB
JavaScript

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
}