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
... | ... | @@ -2,7 +2,8 @@ |
| 2 | 2 |
import { cache } from 'react';
|
| 3 | 3 |
import { redirect } from 'next/navigation';
|
| 4 | 4 |
import { readSessionToken } from '@/lib/auth/session';
|
| 5 |
-import { verifySessionToken } from '@/lib/auth/session-token';
|
|
| 5 |
+import { verifySessionToken, type SessionTokenPayload } from '@/lib/auth/session-token';
|
|
| 6 |
+import { decodeJwtPayload } from '@/lib/auth/decode-jwt-payload';
|
|
| 6 | 7 |
import type { AdminUser } from '@/lib/domain/admin-user';
|
| 7 | 8 |
|
| 8 | 9 |
/** |
... | ... | @@ -17,14 +18,78 @@ |
| 17 | 18 |
* (adminId/loginId/admRoleCd)만으로 AdminUser를 구성한다 — Repository 왕복이 없다. |
| 18 | 19 |
*/ |
| 19 | 20 |
|
| 20 |
-/** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */ |
|
| 21 |
-export const getSessionAdmin = cache(async (): Promise<AdminUser | null> => {
|
|
| 22 |
- const token = await readSessionToken(); |
|
| 23 |
- if (!token) {
|
|
| 24 |
- return null; |
|
| 21 |
+/** 백엔드 accessToken(JWT) payload에서 신선도 검사에 필요한 클레임만 최소로 본다. */ |
|
| 22 |
+type AccessTokenFreshnessClaims = {
|
|
| 23 |
+ exp: unknown; |
|
| 24 |
+ adminId: unknown; |
|
| 25 |
+ loginId: unknown; |
|
| 26 |
+}; |
|
| 27 |
+ |
|
| 28 |
+/** |
|
| 29 |
+ * 세션에 실린 백엔드 accessToken이 여전히 신선한지 검사한다 — **서명 검증이 아니다.** 위조 |
|
| 30 |
+ * 방지는 이미 이 세션 자체의 HMAC 서명(verifySessionToken)이 담당하고, 이 함수는 그 서명을 |
|
| 31 |
+ * 통과한 세션 안에 실린 accessToken이 그 뒤로 "만료·손상·불일치" 상태가 되지 않았는지만 본다 |
|
| 32 |
+ * (decode-jwt-payload.ts 상단 주석의 신뢰 구분과 동일한 경계). |
|
| 33 |
+ * |
|
| 34 |
+ * 세 가지를 확인한다: |
|
| 35 |
+ * 1. 형식 — JWT 3-파트 구조이고 payload가 파싱 가능한가. `decodeJwtPayload`가 실패하면 |
|
| 36 |
+ * null을 반환하므로 그대로 무효 처리한다. |
|
| 37 |
+ * 2. 만료 — accessToken 자체의 `exp` 클레임이 아직 미래인가. 세션 HMAC 토큰의 `exp` |
|
| 38 |
+ * (verifySessionToken이 이미 검사함)와는 **별개 값**이다 — 로그인 시점엔 두 값이 같지만 |
|
| 39 |
+ * (createSession이 백엔드 accessToken의 exp를 그대로 세션 exp로 쓴다) 그 사실이 이 검사의 |
|
| 40 |
+ * 존재 이유를 없애지 않는다: 세션 HMAC의 exp 검사는 "우리 세션 자체가 안 끊겼는지"만 |
|
| 41 |
+ * 보장하고, 이 검사는 "그 세션이 담고 있는 백엔드 토큰이 여전히 유효한지"를 재확인한다. |
|
| 42 |
+ * 3. 유저 일치 — 토큰 클레임의 adminId·loginId가 세션 payload에 저장된 값(로그인 시점에 같은 |
|
| 43 |
+ * 토큰에서 읽어 저장해 둔 값, lib/data/repositories/auth-repository.ts 참고)과 같은가. |
|
| 44 |
+ * 다르면 토큰 바꿔치기·세션 오염 신호이므로 무효로 취급한다. |
|
| 45 |
+ * |
|
| 46 |
+ * clock skew 허용치는 두지 않는다 — verifySessionToken()의 기존 exp 검사도 허용치 없이 |
|
| 47 |
+ * `exp <= now`로 엄격하게 판정하고(session-token.ts), 이 서버가 백엔드와 시계를 맞출 별도 |
|
| 48 |
+ * 장치가 없는 단일 배포 환경이라 허용치를 두면 "이미 만료된 토큰을 짧게 유효로 오판"하는 |
|
| 49 |
+ * 위험만 늘어난다. |
|
| 50 |
+ */ |
|
| 51 |
+function isAccessTokenFresh(session: SessionTokenPayload): boolean {
|
|
| 52 |
+ const claims = decodeJwtPayload<AccessTokenFreshnessClaims>(session.accessToken); |
|
| 53 |
+ if (!claims) {
|
|
| 54 |
+ return false; |
|
| 25 | 55 |
} |
| 26 | 56 |
|
| 27 |
- const session = verifySessionToken(token); |
|
| 57 |
+ if (typeof claims.exp !== 'number') {
|
|
| 58 |
+ return false; |
|
| 59 |
+ } |
|
| 60 |
+ const now = Math.floor(Date.now() / 1000); |
|
| 61 |
+ if (claims.exp <= now) {
|
|
| 62 |
+ return false; |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ return claims.adminId === session.adminId && claims.loginId === session.loginId; |
|
| 66 |
+} |
|
| 67 |
+ |
|
| 68 |
+/** |
|
| 69 |
+ * 서명·형식·만료·유저 일치까지 모두 통과한 세션 payload만 반환한다 — `getSessionAdmin`· |
|
| 70 |
+ * `getSessionAccessToken` 공통 진입점(이 파일 밖으로 내보내지 않는 내부 헬퍼). 검사 로직을 이 |
|
| 71 |
+ * 함수 하나에만 두어 두 공개 함수가 중복 구현 없이 같은 판단을 공유하게 한다. 렌더 패스당 |
|
| 72 |
+ * 1회로 dedupe(cache()). |
|
| 73 |
+ */ |
|
| 74 |
+const getValidSessionPayload = cache( |
|
| 75 |
+ async (): Promise<SessionTokenPayload | null> => {
|
|
| 76 |
+ const token = await readSessionToken(); |
|
| 77 |
+ if (!token) {
|
|
| 78 |
+ return null; |
|
| 79 |
+ } |
|
| 80 |
+ |
|
| 81 |
+ const session = verifySessionToken(token); |
|
| 82 |
+ if (!session) {
|
|
| 83 |
+ return null; |
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 86 |
+ return isAccessTokenFresh(session) ? session : null; |
|
| 87 |
+ } |
|
| 88 |
+); |
|
| 89 |
+ |
|
| 90 |
+/** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */ |
|
| 91 |
+export const getSessionAdmin = cache(async (): Promise<AdminUser | null> => {
|
|
| 92 |
+ const session = await getValidSessionPayload(); |
|
| 28 | 93 |
if (!session) {
|
| 29 | 94 |
return null; |
| 30 | 95 |
} |
... | ... | @@ -42,16 +107,13 @@ |
| 42 | 107 |
* 백엔드 호출에 부착할 accessToken을 세션에서 꺼낸다 — **서버 전용, 화면으로 내려보내지 않는다.** |
| 43 | 108 |
* |
| 44 | 109 |
* `getSessionAdmin()`이 반환하는 `AdminUser`에는 토큰이 없다(민감 값이라 화면 DTO에서 의도적으로 |
| 45 |
- * 제외). 그래서 백엔드 API를 부르는 Repository용 경로를 따로 둔다. 세션이 없거나 만료·위조면 |
|
| 46 |
- * null이며, 그 경우 호출부는 Authorization 없이 요청하게 되고 백엔드가 401로 거절한다(fail-closed). |
|
| 110 |
+ * 제외). 그래서 백엔드 API를 부르는 Repository용 경로를 따로 둔다. 세션이 없거나 위조·만료· |
|
| 111 |
+ * 손상·유저 불일치(모두 `isAccessTokenFresh`가 판정)면 null이며, 그 경우 호출부는 Authorization |
|
| 112 |
+ * 없이 요청하게 되고 백엔드가 401로 거절한다(fail-closed). |
|
| 47 | 113 |
*/ |
| 48 | 114 |
export const getSessionAccessToken = cache(async (): Promise<string | null> => {
|
| 49 |
- const token = await readSessionToken(); |
|
| 50 |
- if (!token) {
|
|
| 51 |
- return null; |
|
| 52 |
- } |
|
| 53 |
- |
|
| 54 |
- return verifySessionToken(token)?.accessToken ?? null; |
|
| 115 |
+ const session = await getValidSessionPayload(); |
|
| 116 |
+ return session?.accessToken ?? null; |
|
| 55 | 117 |
}); |
| 56 | 118 |
|
| 57 | 119 |
/** 보호 라우트·Server Action 진입점 — 세션이 없으면 `/login`으로 redirect한다. */ |
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?