임동욱 임동욱 08-10
feat: 백엔드 토큰을 세션에 보관하도록 전환
Co-Authored-By: Claude Opus 5 
@5723d36934ff9aa997e0fc4ed812b1052127548e
app/(public)/login/_actions.ts
--- app/(public)/login/_actions.ts
+++ app/(public)/login/_actions.ts
@@ -2,7 +2,7 @@
 
 import { redirect } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
-import { verifyAdminCredentials } from '@/lib/data/repositories/auth-repository';
+import { requestAdminLogin } from '@/lib/data/repositories/auth-repository';
 import { createSession } from '@/lib/auth/session';
 
 export type LoginFormState = {
@@ -44,12 +44,19 @@
     return { error: GENERIC_LOGIN_ERROR };
   }
 
-  const admin = await verifyAdminCredentials({ loginId, password });
-  if (!admin) {
+  const result = await requestAdminLogin({ loginId, password });
+  if (!result) {
     return { error: GENERIC_LOGIN_ERROR };
   }
 
-  await createSession(admin.id);
+  await createSession({
+    adminId: result.admin.id,
+    loginId: result.admin.loginId,
+    admRoleCd: result.admin.roleCode,
+    accessToken: result.accessToken,
+    refreshToken: result.refreshToken,
+    exp: result.accessTokenExpiresAt,
+  });
   revalidatePath('/', 'layout');
   redirect('/');
 }
lib/auth/dal.ts
--- lib/auth/dal.ts
+++ lib/auth/dal.ts
@@ -3,7 +3,6 @@
 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';
 
 /**
@@ -13,6 +12,9 @@
  * 이후 모든 mutation(Server Action)은 Repository를 직접 호출하기 전에 반드시
  * `verifySession()`(또는 `getSessionAdmin()`)을 거쳐야 한다 — Server Action은 UI를 거치지 않고
  * 직접 호출될 수 있으므로 이 검증이 유일한 최종 방어선이다.
+ *
+ * 백엔드에 관리자 프로필 조회 API가 없어(2026-08 기준) 세션 payload에 이미 실려 있는 클레임
+ * (adminId/loginId/admRoleCd)만으로 AdminUser를 구성한다 — Repository 왕복이 없다.
  */
 
 /** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */
@@ -22,12 +24,18 @@
     return null;
   }
 
-  const verified = verifySessionToken(token);
-  if (!verified) {
+  const session = verifySessionToken(token);
+  if (!session) {
     return null;
   }
 
-  return fetchAdminById(verified.adminId);
+  return {
+    id: session.adminId,
+    // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항).
+    name: session.loginId,
+    loginId: session.loginId,
+    roleCode: session.admRoleCd,
+  };
 });
 
 /** 보호 라우트·Server Action 진입점 — 세션이 없으면 `/login`으로 redirect한다. */
lib/auth/session-token.ts
--- lib/auth/session-token.ts
+++ lib/auth/session-token.ts
@@ -6,13 +6,38 @@
  * 세션 토큰 발급·검증 — Node 내장 crypto로 서명하는 stateless HMAC-SHA256 토큰.
  * 형식: `base64url(JSON payload).base64url(signature)`.
  * 서명 불일치·만료·파싱 실패 등 어떤 이유로든 검증에 실패하면 null을 반환한다(fail-closed).
+ *
+ * 세션 payload는 백엔드(edupay-backend) 로그인 응답의 accessToken/refreshToken을 그대로
+ * 담는다 — 백엔드 토큰을 우리가 서명 검증할 수 없는 채로 쿠키에 그대로 두면(우리는 백엔드
+ * 시크릿이 없다) 공격자가 `exp`를 조작한 위조 토큰으로 라우트 가드를 통과시킬 수 있다. 이
+ * HMAC 서명이 그 위조를 막는 경계다.
+ *
+ * 세션 만료(exp)는 고정 TTL 상수가 아니라 호출부(로그인 Server Action)가 백엔드 accessToken의
+ * `exp` 클레임을 그대로 넘겨받아 정렬시킨다(`CreateSessionTokenInput.exp`) — 세션은 결국
+ * accessToken을 담는 그릇이므로 수명이 어긋나면 두 가지 문제가 생긴다: (1) 세션이 더 오래
+ * 살면 이미 만료된 accessToken을 유효한 세션이 계속 들고 있다가 실제 API 호출 시점에야 실패가
+ * 드러나고, (2) 세션이 accessToken보다 먼저 끊기면 아직 쓸 수 있는 토큰을 조기 폐기하게 된다.
+ * 과거의 고정 12시간 TTL(mock 인증 시절 자체 발급 토큰 기준)은 이제 의미가 없다 — 백엔드가
+ * accessToken 수명(현재 24시간)의 유일한 소유자이므로 그 값을 그대로 따른다.
  */
 
-const TOKEN_TTL_SECONDS = 60 * 60 * 12; // 12시간 — 보안 우선으로 짧게 설정
-
-type SessionTokenPayload = {
+export type SessionTokenPayload = {
   adminId: string;
+  loginId: string;
+  admRoleCd: string;
+  accessToken: string;
+  refreshToken: string;
   iat: number;
+  exp: number;
+};
+
+export type CreateSessionTokenInput = {
+  adminId: string;
+  loginId: string;
+  admRoleCd: string;
+  accessToken: string;
+  refreshToken: string;
+  /** 백엔드 accessToken의 `exp` 클레임(unix seconds)을 그대로 전달한다. */
   exp: number;
 };
 
@@ -30,12 +55,16 @@
     .digest('base64url');
 }
 
-export function createSessionToken(adminId: string): string {
+export function createSessionToken(input: CreateSessionTokenInput): string {
   const issuedAt = Math.floor(Date.now() / 1000);
   const payload: SessionTokenPayload = {
-    adminId,
+    adminId: input.adminId,
+    loginId: input.loginId,
+    admRoleCd: input.admRoleCd,
+    accessToken: input.accessToken,
+    refreshToken: input.refreshToken,
     iat: issuedAt,
-    exp: issuedAt + TOKEN_TTL_SECONDS,
+    exp: input.exp,
   };
 
   const encodedPayload = base64UrlEncode(JSON.stringify(payload));
@@ -43,7 +72,7 @@
   return `${encodedPayload}.${signature}`;
 }
 
-export function verifySessionToken(token: string): { adminId: string } | null {
+export function verifySessionToken(token: string): SessionTokenPayload | null {
   const [encodedPayload, signature] = token.split('.');
   if (!encodedPayload || !signature) {
     return null;
@@ -67,7 +96,18 @@
     return null;
   }
 
-  if (typeof payload.adminId !== 'string' || !payload.adminId) {
+  if (
+    typeof payload.adminId !== 'string' ||
+    !payload.adminId ||
+    typeof payload.loginId !== 'string' ||
+    !payload.loginId ||
+    typeof payload.admRoleCd !== 'string' ||
+    !payload.admRoleCd ||
+    typeof payload.accessToken !== 'string' ||
+    !payload.accessToken ||
+    typeof payload.refreshToken !== 'string' ||
+    !payload.refreshToken
+  ) {
     return null;
   }
 
@@ -76,5 +116,5 @@
     return null;
   }
 
-  return { adminId: payload.adminId };
+  return payload;
 }
lib/auth/session.ts
--- lib/auth/session.ts
+++ lib/auth/session.ts
@@ -4,12 +4,17 @@
   SESSION_COOKIE_NAME,
   SESSION_COOKIE_OPTIONS,
 } from '@/lib/auth/session-cookie';
-import { createSessionToken } from '@/lib/auth/session-token';
+import {
+  createSessionToken,
+  type CreateSessionTokenInput,
+} from '@/lib/auth/session-token';
 
 /** 세션 쿠키 조작 — Server Action에서만 set/delete 가능 (Next.js cookies() 제약). */
 
-export async function createSession(adminId: string): Promise<void> {
-  const token = createSessionToken(adminId);
+export async function createSession(
+  input: CreateSessionTokenInput
+): Promise<void> {
+  const token = createSessionToken(input);
   const cookieStore = await cookies();
   cookieStore.set(SESSION_COOKIE_NAME, token, SESSION_COOKIE_OPTIONS);
 }
Add a comment
List