임동욱 임동욱 07-27
feat: 세션 토큰 서명과 쿠키 관리 및 세션 검증 DAL 추가
Co-Authored-By: Claude Opus 5 
@453d2ffd291551d2437c03894a27dd9835650336
 
lib/auth/dal.ts (added)
+++ lib/auth/dal.ts
@@ -0,0 +1,41 @@
+import 'server-only';
+import { cache } from 'react';
+import { redirect } from 'next/navigation';
+import { readSessionToken } from '@/lib/auth/session';
+import { verifySessionToken } from '@/lib/auth/session-token';
+import { fetchAdminById } from '@/lib/data/repositories/auth-repository';
+import type { AdminUser } from '@/lib/domain/admin-user';
+
+/**
+ * 공식 가이드의 Data Access Layer(DAL) — 인증·인가 판단을 이 파일에 집중시킨다.
+ * `cache()`로 렌더 패스당 세션 조회를 1회로 dedupe한다.
+ *
+ * 이후 모든 mutation(Server Action)은 Repository를 직접 호출하기 전에 반드시
+ * `verifySession()`(또는 `getSessionAdmin()`)을 거쳐야 한다 — Server Action은 UI를 거치지 않고
+ * 직접 호출될 수 있으므로 이 검증이 유일한 최종 방어선이다.
+ */
+
+/** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */
+export const getSessionAdmin = cache(async (): Promise<AdminUser | null> => {
+  const token = await readSessionToken();
+  if (!token) {
+    return null;
+  }
+
+  const verified = verifySessionToken(token);
+  if (!verified) {
+    return null;
+  }
+
+  return fetchAdminById(verified.adminId);
+});
+
+/** 보호 라우트·Server Action 진입점 — 세션이 없으면 `/login`으로 redirect한다. */
+export async function verifySession(): Promise<AdminUser> {
+  const admin = await getSessionAdmin();
+  if (!admin) {
+    redirect('/login');
+  }
+
+  return admin;
+}
 
lib/auth/session-token.ts (added)
+++ lib/auth/session-token.ts
@@ -0,0 +1,80 @@
+import 'server-only';
+import { createHmac, timingSafeEqual } from 'node:crypto';
+import { getSessionSecret } from '@/lib/env';
+
+/**
+ * 세션 토큰 발급·검증 — Node 내장 crypto로 서명하는 stateless HMAC-SHA256 토큰.
+ * 형식: `base64url(JSON payload).base64url(signature)`.
+ * 서명 불일치·만료·파싱 실패 등 어떤 이유로든 검증에 실패하면 null을 반환한다(fail-closed).
+ */
+
+const TOKEN_TTL_SECONDS = 60 * 60 * 12; // 12시간 — 보안 우선으로 짧게 설정
+
+type SessionTokenPayload = {
+  adminId: string;
+  iat: number;
+  exp: number;
+};
+
+function base64UrlEncode(input: string): string {
+  return Buffer.from(input, 'utf8').toString('base64url');
+}
+
+function base64UrlDecode(input: string): string {
+  return Buffer.from(input, 'base64url').toString('utf8');
+}
+
+function sign(encodedPayload: string): string {
+  return createHmac('sha256', getSessionSecret())
+    .update(encodedPayload)
+    .digest('base64url');
+}
+
+export function createSessionToken(adminId: string): string {
+  const issuedAt = Math.floor(Date.now() / 1000);
+  const payload: SessionTokenPayload = {
+    adminId,
+    iat: issuedAt,
+    exp: issuedAt + TOKEN_TTL_SECONDS,
+  };
+
+  const encodedPayload = base64UrlEncode(JSON.stringify(payload));
+  const signature = sign(encodedPayload);
+  return `${encodedPayload}.${signature}`;
+}
+
+export function verifySessionToken(token: string): { adminId: string } | null {
+  const [encodedPayload, signature] = token.split('.');
+  if (!encodedPayload || !signature) {
+    return null;
+  }
+
+  const expectedSignature = sign(encodedPayload);
+  const signatureBuffer = Buffer.from(signature);
+  const expectedBuffer = Buffer.from(expectedSignature);
+
+  if (
+    signatureBuffer.length !== expectedBuffer.length ||
+    !timingSafeEqual(signatureBuffer, expectedBuffer)
+  ) {
+    return null;
+  }
+
+  let payload: SessionTokenPayload;
+  try {
+    payload = JSON.parse(base64UrlDecode(encodedPayload)) as SessionTokenPayload;
+  } catch {
+    return null;
+  }
+
+  if (typeof payload.adminId !== 'string' || !payload.adminId) {
+    return null;
+  }
+
+  const now = Math.floor(Date.now() / 1000);
+  if (typeof payload.exp !== 'number' || payload.exp <= now) {
+    return null;
+  }
+
+  return { adminId: payload.adminId };
+}
 
lib/auth/session.ts (added)
+++ lib/auth/session.ts
@@ -0,0 +1,25 @@
+import 'server-only';
+import { cookies } from 'next/headers';
+import {
+  SESSION_COOKIE_NAME,
+  SESSION_COOKIE_OPTIONS,
+} from '@/lib/auth/session-cookie';
+import { createSessionToken } from '@/lib/auth/session-token';
+
+/** 세션 쿠키 조작 — Server Action에서만 set/delete 가능 (Next.js cookies() 제약). */
+
+export async function createSession(adminId: string): Promise<void> {
+  const token = createSessionToken(adminId);
+  const cookieStore = await cookies();
+  cookieStore.set(SESSION_COOKIE_NAME, token, SESSION_COOKIE_OPTIONS);
+}
+
+export async function readSessionToken(): Promise<string | null> {
+  const cookieStore = await cookies();
+  return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;
+}
+
+export async function deleteSession(): Promise<void> {
+  const cookieStore = await cookies();
+  cookieStore.delete(SESSION_COOKIE_NAME);
+}
Add a comment
List