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