임동욱 임동욱 08-10
feat: 관리자 로그인 백엔드 연동
Co-Authored-By: Claude Opus 5 
@74538e869296b53da475922c99f0507878843f55
 
lib/auth/decode-jwt-payload.ts (added)
+++ lib/auth/decode-jwt-payload.ts
@@ -0,0 +1,28 @@
+import 'server-only';
+
+/**
+ * JWT의 payload(두 번째 세그먼트)만 파싱한다 — 서명 검증은 하지 않는다.
+ *
+ * 백엔드(edupay-backend)가 발급한 accessToken/refreshToken은 백엔드의 서명 시크릿으로
+ * 서명되어 있고 우리는 그 시크릿을 갖고 있지 않아 서명 검증 자체가 불가능하다. 따라서 이
+ * 함수가 반환하는 값은 "신뢰된 인가 판단"에 직접 쓰지 않는다 — 여기서 읽은 클레임은 우리
+ * 자체 HMAC 세션 토큰(lib/auth/session-token.ts)에 실어 서명한 뒤에만 위조 불가능한 형태로
+ * 라우트 가드에 쓰인다. 이 함수는 백엔드가 방금 응답한 토큰에서 표시용 정보(로그인 아이디,
+ * 역할 코드 등)를 꺼내는 매핑 용도로만 사용한다.
+ *
+ * 형식 오류·JSON 파싱 실패 등 어떤 이유로든 실패하면 예외를 던지지 않고 null을 반환한다
+ * (fail-closed) — 호출부가 매번 try/catch를 두지 않아도 되게 한다.
+ */
+export function decodeJwtPayload<T>(token: string): T | null {
+  const segments = token.split('.');
+  if (segments.length !== 3) {
+    return null;
+  }
+
+  try {
+    const json = Buffer.from(segments[1], 'base64url').toString('utf8');
+    return JSON.parse(json) as T;
+  } catch {
+    return null;
+  }
+}
lib/data/repositories/auth-repository.ts
--- lib/data/repositories/auth-repository.ts
+++ lib/data/repositories/auth-repository.ts
@@ -1,56 +1,101 @@
 import 'server-only';
-import { createHash, timingSafeEqual } from 'node:crypto';
-import { getMockAdminCredentials } from '@/lib/env';
+import { backendFetch } from '@/lib/http/backend-fetch';
+import { decodeJwtPayload } from '@/lib/auth/decode-jwt-payload';
 import type { AdminUser } from '@/lib/domain/admin-user';
 
 /**
- * mock 인증 Repository.
+ * 관리자 인증 Repository — 백엔드(edupay-backend) 실연동.
  *
- * 백엔드(edupay-backend)에 관리자 인증 API가 아직 없어 mock으로 구현한다.
- * 공개 시그니처(도메인 타입만 주고받음)는 백엔드 연동 후에도 유지한다 — 연동 시점에는
- * 이 파일의 내부 구현만 실제 API 호출로 교체하고, 각 함수에 캐시 전략(`cache: 'no-store'`)을
- * 명시적으로 추가해야 한다. 인증·개인 데이터는 요청 간 캐시 잔존이 금지되기 때문이다.
+ * 엔드포인트: `POST /api/v1/common/auth/admin/login` (baseURL: lib/env.ts의 getApiBaseUrl()).
+ * 요청 필드명은 `loginPw`다(`password`가 아님 — 백엔드 계약 그대로).
+ *
+ * 백엔드에는 관리자 프로필 조회 API가 없다(2026-08 기준) — 그래서 로그인 응답으로 받은
+ * accessToken(JWT)의 payload를 디코딩해 AdminUser를 구성한다. 서명 검증은 하지 않는다
+ * (백엔드 시크릿이 없어 불가능 — lib/auth/decode-jwt-payload.ts 참고, 위조 방지는 이후
+ * 우리 세션 HMAC 서명이 담당한다). 관리자 "이름" 클레임이 토큰에 없어 loginId로 대체한다
+ * (사용자 확정 사항).
  */
-
-const MOCK_ADMIN_ID = 'mock-admin-1';
 
 export type AdminCredentials = {
   loginId: string;
   password: string;
 };
 
-/** SHA-256 다이제스트 후 timingSafeEqual 비교 — 길이·타이밍 정보 누출을 방지한다. */
-function digestsMatch(a: string, b: string): boolean {
-  const digestA = createHash('sha256').update(a, 'utf8').digest();
-  const digestB = createHash('sha256').update(b, 'utf8').digest();
-  return timingSafeEqual(digestA, digestB);
-}
+export type AdminLoginResult = {
+  admin: AdminUser;
+  accessToken: string;
+  refreshToken: string;
+  /** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시키는 데 쓴다. */
+  accessTokenExpiresAt: number;
+};
 
-export async function verifyAdminCredentials(
+/** 백엔드 accessToken(JWT)의 payload 클레임. */
+type AdminAccessTokenClaims = {
+  sub: string;
+  loginId: string;
+  admRoleCd: string;
+  adminId: string;
+  userType: string;
+  userId: string;
+  iat: number;
+  exp: number;
+};
+
+type AdminLoginResponse = {
+  accessToken: string;
+  refreshToken: string;
+};
+
+/**
+ * 관리자 로그인을 시도한다. 자격 불일치·존재하지 않는 계정 등 인증 실패는 예외가 아니라
+ * 정상 흐름이므로 null을 반환한다(네트워크·파싱 등 진짜 예외 상황은 lib/http/backend-fetch.ts가
+ * 흡수해 동일하게 `ok:false`로 내려주므로 이 함수 입장에서는 실패 사유를 구분하지 않는다 —
+ * 호출부가 항상 일반화된 오류 메시지로 응답하기 때문에 구분할 필요가 없다).
+ *
+ * 캐시 전략: `no-store` — 인증 요청은 재사용 캐시 대상이 아니다(매 시도가 백엔드에 도달해야 함).
+ */
+export async function requestAdminLogin(
   credentials: AdminCredentials
-): Promise<AdminUser | null> {
-  const seed = getMockAdminCredentials();
-  if (!seed) {
+): Promise<AdminLoginResult | null> {
+  const result = await backendFetch<AdminLoginResponse>(
+    '/api/v1/common/auth/admin/login',
+    {
+      method: 'POST',
+      body: { loginId: credentials.loginId, loginPw: credentials.password },
+      cache: 'no-store',
+    }
+  );
+
+  if (!result.ok) {
     return null;
   }
 
-  const loginIdMatches = digestsMatch(credentials.loginId, seed.loginId);
-  const passwordMatches = digestsMatch(credentials.password, seed.password);
-
-  if (!loginIdMatches || !passwordMatches) {
+  const claims = decodeJwtPayload<AdminAccessTokenClaims>(result.data.accessToken);
+  if (
+    !claims ||
+    typeof claims.adminId !== 'string' ||
+    !claims.adminId ||
+    typeof claims.loginId !== 'string' ||
+    !claims.loginId ||
+    typeof claims.admRoleCd !== 'string' ||
+    !claims.admRoleCd ||
+    typeof claims.exp !== 'number'
+  ) {
     return null;
   }
 
-  return { id: MOCK_ADMIN_ID, name: '관리자' };
-}
+  const admin: AdminUser = {
+    id: claims.adminId,
+    // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항).
+    name: claims.loginId,
+    loginId: claims.loginId,
+    roleCode: claims.admRoleCd,
+  };
 
-export async function fetchAdminById(
-  adminId: string
-): Promise<AdminUser | null> {
-  const seed = getMockAdminCredentials();
-  if (!seed || adminId !== MOCK_ADMIN_ID) {
-    return null;
-  }
-
-  return { id: MOCK_ADMIN_ID, name: '관리자' };
+  return {
+    admin,
+    accessToken: result.data.accessToken,
+    refreshToken: result.data.refreshToken,
+    accessTokenExpiresAt: claims.exp,
+  };
 }
lib/domain/admin-user.ts
--- lib/domain/admin-user.ts
+++ lib/domain/admin-user.ts
@@ -1,8 +1,17 @@
 /**
  * 관리자 도메인 타입 — 순수 데이터 표현, 외부 의존 없음.
- * 화면에는 최소 DTO만 노출한다 (비밀번호 등 민감 필드는 여기 포함하지 않는다).
+ * 화면에는 최소 DTO만 노출한다 (비밀번호·토큰 등 민감 필드는 여기 포함하지 않는다 — 토큰은
+ * 세션 payload에만 보관하고 화면까지 내려주지 않는다).
  */
 export type AdminUser = {
   id: string;
+  /**
+   * 백엔드 accessToken(JWT)에 관리자 "이름" 클레임이 없어 loginId를 표시용 이름으로 그대로
+   * 대체한다(사용자 확정 사항, 별도 이름 조회 API 없음). 이름 클레임/API가 추가되면 이 필드를
+   * 실제 이름으로 교체한다.
+   */
   name: string;
+  loginId: string;
+  /** 백엔드 accessToken의 admRoleCd 클레임 (예: "ROLE_SYSTEM"). */
+  roleCode: string;
 };
lib/env.ts
--- lib/env.ts
+++ lib/env.ts
@@ -32,27 +32,3 @@
 export function getApiBaseUrl(): string {
   return readRequiredEnv('EDUPAY_API_BASE_URL');
 }
-
-export type MockAdminCredentials = {
-  loginId: string;
-  password: string;
-};
-
-/**
- * mock 관리자 시드 계정.
- *
- * ⚠️ 관리자 로그인이 실제 백엔드(edupay-backend)로 연동되는 즉시(auth-repository.ts 교체 시점)
- * 이 함수와 `.env.development`의 `MOCK_ADMIN_*` 키는 제거 대상이다 — 실 연동 커밋에 묶어 정리한다.
- * 운영 환경(.env.production)에는 애초에 키를 두지 않아 누락 시 null을 반환 — mock 로그인이
- * 항상 실패하도록(fail-closed) 한다.
- */
-export function getMockAdminCredentials(): MockAdminCredentials | null {
-  const loginId = process.env.MOCK_ADMIN_LOGIN_ID;
-  const password = process.env.MOCK_ADMIN_PASSWORD;
-
-  if (!loginId || !password) {
-    return null;
-  }
-
-  return { loginId, password };
-}
Add a comment
List