관리자 기능과 태그 표시 설정 추가
This commit is contained in:
147
server/utils/admin-auth.js
Normal file
147
server/utils/admin-auth.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
|
||||
|
||||
const adminSessionCookieName = 'sori_admin_session'
|
||||
const sessionMaxAge = 60 * 60 * 12
|
||||
|
||||
/**
|
||||
* 세션 서명 비밀값 조회
|
||||
* @returns {string} 세션 서명 비밀값
|
||||
*/
|
||||
const getSessionSecret = () => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (!config.adminPassword) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '관리자 비밀번호 환경 변수가 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return config.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 {string} email - 관리자 이메일
|
||||
* @returns {string} 세션 토큰
|
||||
*/
|
||||
export const createAdminSessionToken = (email) => {
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
email,
|
||||
expiresAt: Date.now() + sessionMaxAge * 1000
|
||||
})).toString('base64url')
|
||||
|
||||
return `${payload}.${signPayload(payload)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 세션 토큰 검증
|
||||
* @param {string | undefined} token - 세션 토큰
|
||||
* @returns {{ email: string } | 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.email || !session.expiresAt || session.expiresAt < Date.now()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
email: session.email
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 세션 쿠키 설정
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {string} email - 관리자 이메일
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setAdminSession = (event, email) => {
|
||||
setCookie(event, adminSessionCookieName, createAdminSessionToken(email), {
|
||||
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 {{ email: string } | null} 세션 정보
|
||||
*/
|
||||
export const getAdminSession = (event) => verifyAdminSessionToken(getCookie(event, adminSessionCookieName))
|
||||
|
||||
/**
|
||||
* 관리자 세션 필수 확인
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ email: string }} 세션 정보
|
||||
*/
|
||||
export const requireAdminSession = (event) => {
|
||||
const session = getAdminSession(event)
|
||||
|
||||
if (!session) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '관리자 로그인이 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
20
server/utils/admin-post-input.js
Normal file
20
server/utils/admin-post-input.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
import { postStatusSchema } from './content-schema'
|
||||
|
||||
export const adminPostInputSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
|
||||
content: z.string().default(''),
|
||||
excerpt: z.string().default(''),
|
||||
featuredImage: z.string().trim().nullable().default(null),
|
||||
status: postStatusSchema.default('draft'),
|
||||
publishedAt: z.string().datetime().nullable().default(null),
|
||||
tags: z.array(z.string().trim().min(1)).default([])
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 게시물 입력값 정리
|
||||
* @param {unknown} body - 요청 본문
|
||||
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
|
||||
*/
|
||||
export const parseAdminPostInput = (body) => adminPostInputSchema.safeParse(body)
|
||||
16
server/utils/admin-tag-input.js
Normal file
16
server/utils/admin-tag-input.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const adminTagInputSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
|
||||
description: z.string().default(''),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).default('#15171a')
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 태그 입력값 정리
|
||||
* @param {unknown} body - 요청 본문
|
||||
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
|
||||
*/
|
||||
export const parseAdminTagInput = (body) => adminTagInputSchema.safeParse(body)
|
||||
@@ -30,5 +30,7 @@ export const tagSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1),
|
||||
slug: z.string().min(1),
|
||||
description: z.string().default('')
|
||||
description: z.string().default(''),
|
||||
sortOrder: z.number().int().default(0),
|
||||
color: z.string().default('#15171a')
|
||||
})
|
||||
|
||||
@@ -48,13 +48,17 @@ const sampleTags = [
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
name: 'NOTE',
|
||||
slug: 'note',
|
||||
description: '생각과 기록을 모아두는 태그입니다.'
|
||||
description: '생각과 기록을 모아두는 태그입니다.',
|
||||
sortOrder: 10,
|
||||
color: '#f97316'
|
||||
},
|
||||
{
|
||||
id: '55555555-5555-4555-8555-555555555555',
|
||||
name: 'DEV',
|
||||
slug: 'dev',
|
||||
description: '개발과 제작 과정을 기록하는 태그입니다.'
|
||||
description: '개발과 제작 과정을 기록하는 태그입니다.',
|
||||
sortOrder: 20,
|
||||
color: '#06b6d4'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user