임동욱 임동욱 08-11
feat: accessToken 유효성 검사 추가
세션에 실린 백엔드 accessToken의 형식·만료·유저 일치(adminId/loginId)를 검사하는
isAccessTokenFresh를 추가하고, getSessionAdmin·getSessionAccessToken이 공통 헬퍼
getValidSessionPayload를 통해 이 검사를 공유하도록 dal.ts를 리팩터링한다. 검사 실패 시
두 함수 모두 기존과 동일하게 null을 반환한다(fail-closed, 외부 계약 불변).

Co-Authored-By: Claude Opus 5 
@b95fcc1c41ecef8f24ddbae1e43e54d71a682ac3
lib/auth/dal.ts
--- lib/auth/dal.ts
+++ lib/auth/dal.ts
@@ -2,7 +2,8 @@
 import { cache } from 'react';
 import { redirect } from 'next/navigation';
 import { readSessionToken } from '@/lib/auth/session';
-import { verifySessionToken } from '@/lib/auth/session-token';
+import { verifySessionToken, type SessionTokenPayload } from '@/lib/auth/session-token';
+import { decodeJwtPayload } from '@/lib/auth/decode-jwt-payload';
 import type { AdminUser } from '@/lib/domain/admin-user';
 
 /**
@@ -17,14 +18,78 @@
  * (adminId/loginId/admRoleCd)만으로 AdminUser를 구성한다 — Repository 왕복이 없다.
  */
 
-/** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */
-export const getSessionAdmin = cache(async (): Promise<AdminUser | null> => {
-  const token = await readSessionToken();
-  if (!token) {
-    return null;
+/** 백엔드 accessToken(JWT) payload에서 신선도 검사에 필요한 클레임만 최소로 본다. */
+type AccessTokenFreshnessClaims = {
+  exp: unknown;
+  adminId: unknown;
+  loginId: unknown;
+};
+
+/**
+ * 세션에 실린 백엔드 accessToken이 여전히 신선한지 검사한다 — **서명 검증이 아니다.** 위조
+ * 방지는 이미 이 세션 자체의 HMAC 서명(verifySessionToken)이 담당하고, 이 함수는 그 서명을
+ * 통과한 세션 안에 실린 accessToken이 그 뒤로 "만료·손상·불일치" 상태가 되지 않았는지만 본다
+ * (decode-jwt-payload.ts 상단 주석의 신뢰 구분과 동일한 경계).
+ *
+ * 세 가지를 확인한다:
+ *   1. 형식 — JWT 3-파트 구조이고 payload가 파싱 가능한가. `decodeJwtPayload`가 실패하면
+ *      null을 반환하므로 그대로 무효 처리한다.
+ *   2. 만료 — accessToken 자체의 `exp` 클레임이 아직 미래인가. 세션 HMAC 토큰의 `exp`
+ *      (verifySessionToken이 이미 검사함)와는 **별개 값**이다 — 로그인 시점엔 두 값이 같지만
+ *      (createSession이 백엔드 accessToken의 exp를 그대로 세션 exp로 쓴다) 그 사실이 이 검사의
+ *      존재 이유를 없애지 않는다: 세션 HMAC의 exp 검사는 "우리 세션 자체가 안 끊겼는지"만
+ *      보장하고, 이 검사는 "그 세션이 담고 있는 백엔드 토큰이 여전히 유효한지"를 재확인한다.
+ *   3. 유저 일치 — 토큰 클레임의 adminId·loginId가 세션 payload에 저장된 값(로그인 시점에 같은
+ *      토큰에서 읽어 저장해 둔 값, lib/data/repositories/auth-repository.ts 참고)과 같은가.
+ *      다르면 토큰 바꿔치기·세션 오염 신호이므로 무효로 취급한다.
+ *
+ * clock skew 허용치는 두지 않는다 — verifySessionToken()의 기존 exp 검사도 허용치 없이
+ * `exp <= now`로 엄격하게 판정하고(session-token.ts), 이 서버가 백엔드와 시계를 맞출 별도
+ * 장치가 없는 단일 배포 환경이라 허용치를 두면 "이미 만료된 토큰을 짧게 유효로 오판"하는
+ * 위험만 늘어난다.
+ */
+function isAccessTokenFresh(session: SessionTokenPayload): boolean {
+  const claims = decodeJwtPayload<AccessTokenFreshnessClaims>(session.accessToken);
+  if (!claims) {
+    return false;
   }
 
-  const session = verifySessionToken(token);
+  if (typeof claims.exp !== 'number') {
+    return false;
+  }
+  const now = Math.floor(Date.now() / 1000);
+  if (claims.exp <= now) {
+    return false;
+  }
+
+  return claims.adminId === session.adminId && claims.loginId === session.loginId;
+}
+
+/**
+ * 서명·형식·만료·유저 일치까지 모두 통과한 세션 payload만 반환한다 — `getSessionAdmin`·
+ * `getSessionAccessToken` 공통 진입점(이 파일 밖으로 내보내지 않는 내부 헬퍼). 검사 로직을 이
+ * 함수 하나에만 두어 두 공개 함수가 중복 구현 없이 같은 판단을 공유하게 한다. 렌더 패스당
+ * 1회로 dedupe(cache()).
+ */
+const getValidSessionPayload = cache(
+  async (): Promise<SessionTokenPayload | null> => {
+    const token = await readSessionToken();
+    if (!token) {
+      return null;
+    }
+
+    const session = verifySessionToken(token);
+    if (!session) {
+      return null;
+    }
+
+    return isAccessTokenFresh(session) ? session : null;
+  }
+);
+
+/** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */
+export const getSessionAdmin = cache(async (): Promise<AdminUser | null> => {
+  const session = await getValidSessionPayload();
   if (!session) {
     return null;
   }
@@ -42,16 +107,13 @@
  * 백엔드 호출에 부착할 accessToken을 세션에서 꺼낸다 — **서버 전용, 화면으로 내려보내지 않는다.**
  *
  * `getSessionAdmin()`이 반환하는 `AdminUser`에는 토큰이 없다(민감 값이라 화면 DTO에서 의도적으로
- * 제외). 그래서 백엔드 API를 부르는 Repository용 경로를 따로 둔다. 세션이 없거나 만료·위조면
- * null이며, 그 경우 호출부는 Authorization 없이 요청하게 되고 백엔드가 401로 거절한다(fail-closed).
+ * 제외). 그래서 백엔드 API를 부르는 Repository용 경로를 따로 둔다. 세션이 없거나 위조·만료·
+ * 손상·유저 불일치(모두 `isAccessTokenFresh`가 판정)면 null이며, 그 경우 호출부는 Authorization
+ * 없이 요청하게 되고 백엔드가 401로 거절한다(fail-closed).
  */
 export const getSessionAccessToken = cache(async (): Promise<string | null> => {
-  const token = await readSessionToken();
-  if (!token) {
-    return null;
-  }
-
-  return verifySessionToken(token)?.accessToken ?? null;
+  const session = await getValidSessionPayload();
+  return session?.accessToken ?? null;
 });
 
 /** 보호 라우트·Server Action 진입점 — 세션이 없으면 `/login`으로 redirect한다. */
Add a comment
List