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