feat: 세션 토큰 서명과 쿠키 관리 및 세션 검증 DAL 추가
Co-Authored-By: Claude Opus 5
@453d2ffd291551d2437c03894a27dd9835650336
+++ lib/auth/dal.ts
... | ... | @@ -0,0 +1,41 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { cache } from 'react'; | |
| 3 | +import { redirect } from 'next/navigation'; | |
| 4 | +import { readSessionToken } from '@/lib/auth/session'; | |
| 5 | +import { verifySessionToken } from '@/lib/auth/session-token'; | |
| 6 | +import { fetchAdminById } from '@/lib/data/repositories/auth-repository'; | |
| 7 | +import type { AdminUser } from '@/lib/domain/admin-user'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 공식 가이드의 Data Access Layer(DAL) — 인증·인가 판단을 이 파일에 집중시킨다. | |
| 11 | + * `cache()`로 렌더 패스당 세션 조회를 1회로 dedupe한다. | |
| 12 | + * | |
| 13 | + * 이후 모든 mutation(Server Action)은 Repository를 직접 호출하기 전에 반드시 | |
| 14 | + * `verifySession()`(또는 `getSessionAdmin()`)을 거쳐야 한다 — Server Action은 UI를 거치지 않고 | |
| 15 | + * 직접 호출될 수 있으므로 이 검증이 유일한 최종 방어선이다. | |
| 16 | + */ | |
| 17 | + | |
| 18 | +/** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */ | |
| 19 | +export const getSessionAdmin = cache(async (): Promise<AdminUser | null> => { | |
| 20 | + const token = await readSessionToken(); | |
| 21 | + if (!token) { | |
| 22 | + return null; | |
| 23 | + } | |
| 24 | + | |
| 25 | + const verified = verifySessionToken(token); | |
| 26 | + if (!verified) { | |
| 27 | + return null; | |
| 28 | + } | |
| 29 | + | |
| 30 | + return fetchAdminById(verified.adminId); | |
| 31 | +}); | |
| 32 | + | |
| 33 | +/** 보호 라우트·Server Action 진입점 — 세션이 없으면 `/login`으로 redirect한다. */ | |
| 34 | +export async function verifySession(): Promise<AdminUser> { | |
| 35 | + const admin = await getSessionAdmin(); | |
| 36 | + if (!admin) { | |
| 37 | + redirect('/login'); | |
| 38 | + } | |
| 39 | + | |
| 40 | + return admin; | |
| 41 | +} |
+++ lib/auth/session-token.ts
... | ... | @@ -0,0 +1,80 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { createHmac, timingSafeEqual } from 'node:crypto'; | |
| 3 | +import { getSessionSecret } from '@/lib/env'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 세션 토큰 발급·검증 — Node 내장 crypto로 서명하는 stateless HMAC-SHA256 토큰. | |
| 7 | + * 형식: `base64url(JSON payload).base64url(signature)`. | |
| 8 | + * 서명 불일치·만료·파싱 실패 등 어떤 이유로든 검증에 실패하면 null을 반환한다(fail-closed). | |
| 9 | + */ | |
| 10 | + | |
| 11 | +const TOKEN_TTL_SECONDS = 60 * 60 * 12; // 12시간 — 보안 우선으로 짧게 설정 | |
| 12 | + | |
| 13 | +type SessionTokenPayload = { | |
| 14 | + adminId: string; | |
| 15 | + iat: number; | |
| 16 | + exp: number; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +function base64UrlEncode(input: string): string { | |
| 20 | + return Buffer.from(input, 'utf8').toString('base64url'); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function base64UrlDecode(input: string): string { | |
| 24 | + return Buffer.from(input, 'base64url').toString('utf8'); | |
| 25 | +} | |
| 26 | + | |
| 27 | +function sign(encodedPayload: string): string { | |
| 28 | + return createHmac('sha256', getSessionSecret()) | |
| 29 | + .update(encodedPayload) | |
| 30 | + .digest('base64url'); | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function createSessionToken(adminId: string): string { | |
| 34 | + const issuedAt = Math.floor(Date.now() / 1000); | |
| 35 | + const payload: SessionTokenPayload = { | |
| 36 | + adminId, | |
| 37 | + iat: issuedAt, | |
| 38 | + exp: issuedAt + TOKEN_TTL_SECONDS, | |
| 39 | + }; | |
| 40 | + | |
| 41 | + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); | |
| 42 | + const signature = sign(encodedPayload); | |
| 43 | + return `${encodedPayload}.${signature}`; | |
| 44 | +} | |
| 45 | + | |
| 46 | +export function verifySessionToken(token: string): { adminId: string } | null { | |
| 47 | + const [encodedPayload, signature] = token.split('.'); | |
| 48 | + if (!encodedPayload || !signature) { | |
| 49 | + return null; | |
| 50 | + } | |
| 51 | + | |
| 52 | + const expectedSignature = sign(encodedPayload); | |
| 53 | + const signatureBuffer = Buffer.from(signature); | |
| 54 | + const expectedBuffer = Buffer.from(expectedSignature); | |
| 55 | + | |
| 56 | + if ( | |
| 57 | + signatureBuffer.length !== expectedBuffer.length || | |
| 58 | + !timingSafeEqual(signatureBuffer, expectedBuffer) | |
| 59 | + ) { | |
| 60 | + return null; | |
| 61 | + } | |
| 62 | + | |
| 63 | + let payload: SessionTokenPayload; | |
| 64 | + try { | |
| 65 | + payload = JSON.parse(base64UrlDecode(encodedPayload)) as SessionTokenPayload; | |
| 66 | + } catch { | |
| 67 | + return null; | |
| 68 | + } | |
| 69 | + | |
| 70 | + if (typeof payload.adminId !== 'string' || !payload.adminId) { | |
| 71 | + return null; | |
| 72 | + } | |
| 73 | + | |
| 74 | + const now = Math.floor(Date.now() / 1000); | |
| 75 | + if (typeof payload.exp !== 'number' || payload.exp <= now) { | |
| 76 | + return null; | |
| 77 | + } | |
| 78 | + | |
| 79 | + return { adminId: payload.adminId }; | |
| 80 | +} |
+++ lib/auth/session.ts
... | ... | @@ -0,0 +1,25 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { cookies } from 'next/headers'; | |
| 3 | +import { | |
| 4 | + SESSION_COOKIE_NAME, | |
| 5 | + SESSION_COOKIE_OPTIONS, | |
| 6 | +} from '@/lib/auth/session-cookie'; | |
| 7 | +import { createSessionToken } from '@/lib/auth/session-token'; | |
| 8 | + | |
| 9 | +/** 세션 쿠키 조작 — Server Action에서만 set/delete 가능 (Next.js cookies() 제약). */ | |
| 10 | + | |
| 11 | +export async function createSession(adminId: string): Promise<void> { | |
| 12 | + const token = createSessionToken(adminId); | |
| 13 | + const cookieStore = await cookies(); | |
| 14 | + cookieStore.set(SESSION_COOKIE_NAME, token, SESSION_COOKIE_OPTIONS); | |
| 15 | +} | |
| 16 | + | |
| 17 | +export async function readSessionToken(): Promise<string | null> { | |
| 18 | + const cookieStore = await cookies(); | |
| 19 | + return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export async function deleteSession(): Promise<void> { | |
| 23 | + const cookieStore = await cookies(); | |
| 24 | + cookieStore.delete(SESSION_COOKIE_NAME); | |
| 25 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?