From 2de065ceb0bb29351521635962a83b751dbbdf4e Mon Sep 17 00:00:00 2001 From: ljpark Date: Fri, 10 Apr 2026 18:01:12 +0900 Subject: [PATCH] =?UTF-8?q?wr:=20jwt=20=EB=82=A8=EC=9D=80=EC=8B=9C?= =?UTF-8?q?=EA=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/wr/app/(with-layout)/jwttest/page.tsx | 164 ++++++++++++++++++ .../prfmncReg/chkgdnReg/rswrtChkgdn/index.tsx | 5 +- apps/wr/components/lib/api/index.ts | 41 +++++ .../wr/src/entities/auth/lib/token-manager.ts | 50 ++++++ 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 apps/wr/app/(with-layout)/jwttest/page.tsx diff --git a/apps/wr/app/(with-layout)/jwttest/page.tsx b/apps/wr/app/(with-layout)/jwttest/page.tsx new file mode 100644 index 000000000..25d47c1d2 --- /dev/null +++ b/apps/wr/app/(with-layout)/jwttest/page.tsx @@ -0,0 +1,164 @@ +/** + * JWT를 decoding하면 만료시간을 읽을 수 있다. + * JWT는 3부분으로 구성 : Header.Payload.Signature + * Payload 부분에 만료시간(exp)이 있다. + */ +"use client"; + +import { useEffect, useState } from "react"; +import { CLAIM_NAME } from "@/src/shared/config"; +import { getCookie } from "@/src/shared/lib"; + +interface JwtPayload { + sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보 + exp?: number; // 만료 시각(Unix timestamp, 초 단위) + iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위) + [key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임 +} + +interface JwtState { + token: string; // 쿠키에서 읽어온 JWT 원본 문자열 + payload: JwtPayload | null; // JWT를 파싱한 payload 객체 + exp: number | null; // payload에서 추출한 exp 값 + expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값 + remainingMs: number | null; // 현재 시각 기준 남은 시간(ms) + error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지 +} + + +function parseJwt(token: string): JwtPayload { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("유효한 JWT 형식이 아닙니다."); + } + + const base64Url = parts[1] ?? ""; // Payload를 가져온다. + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const jsonPayload = decodeURIComponent( + atob(padded) + .split("") + .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`) + .join("") + ); + + return JSON.parse(jsonPayload) as JwtPayload; +} + +function formatRemainingMs(remainingMs: number | null): string { + if (remainingMs === null) return "-"; + if (remainingMs <= 0) return "만료됨"; + + const totalSeconds = Math.floor(remainingMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${hours}시간 ${minutes}분 ${seconds}초`; +} + +function readJwtState(): JwtState { + const token = getCookie(CLAIM_NAME) ?? ""; + + if (!token) { + return { + token: "", + payload: null, + exp: null, + expDateLocal: null, + remainingMs: null, + error: `${CLAIM_NAME} 쿠키가 없습니다.`, + }; + } + + try { + const payload = parseJwt(token); + + if (typeof payload.exp !== "number") { + return { + token, + payload, + exp: null, + expDateLocal: null, + remainingMs: null, + error: "JWT payload에 exp 값이 없습니다.", + }; + } + + const expMs = payload.exp * 1000; + + return { + token, + payload, + exp: payload.exp, + expDateLocal: new Date(expMs).toLocaleString(), + remainingMs: Math.max(expMs - Date.now(), 0), + error: null, + }; + } catch (error) { + return { + token, + payload: null, + exp: null, + expDateLocal: null, + remainingMs: null, + error: + error instanceof Error + ? error.message + : "JWT 파싱 중 오류가 발생했습니다.", + }; + } +} + +export default function Page() { + const [jwtState, setJwtState] = useState(() => readJwtState()); + + useEffect(() => { + const syncJwtState = () => { + setJwtState(readJwtState()); + }; + + syncJwtState(); + const timer = window.setInterval(syncJwtState, 1000); + + return () => window.clearInterval(timer); + }, []); + + return ( +
+

JWT Exp Test

+ +
+
쿠키명
+
{CLAIM_NAME}
+
+ +
+
exp
+
{jwtState.exp ?? "-"}
+
+ +
+
만료시각
+
{jwtState.expDateLocal ?? "-"}
+
+ +
+
남은시간
+
{formatRemainingMs(jwtState.remainingMs)}
+
+ +
+
상태
+
{jwtState.error ?? "정상"}
+
+ +
+
payload
+
+          {jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
+        
+
+
+ ); +} diff --git a/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/index.tsx b/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/index.tsx index 102d44d75..71ae80eda 100644 --- a/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/index.tsx +++ b/apps/wr/app/store/prfmncReg/chkgdnReg/rswrtChkgdn/index.tsx @@ -1,4 +1,7 @@ -import { buildingType } from "@/app/store/common/code"; +/** + * 중수도 지도점검 store + */ +import { buildingType } from "@/app/store/common/code"; import type { TableUpperProps } from "@/app/store/common/type/header"; import { TEST_UUID, diff --git a/apps/wr/components/lib/api/index.ts b/apps/wr/components/lib/api/index.ts index 6ff75f5a6..aa257a71a 100644 --- a/apps/wr/components/lib/api/index.ts +++ b/apps/wr/components/lib/api/index.ts @@ -1,4 +1,6 @@ import { DEV_URL_FO, DEV_URL_WR, LOCAL_URL } from "../config/"; +import { CLAIM_NAME } from "@/src/shared/config"; +import { tokenManager } from "@/src/entities/auth/lib/token-manager"; export const fetchType = { COMMON: "common", @@ -7,6 +9,28 @@ export const fetchType = { CS: "CS", }; +const LOGIN_PAGE_URL = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + +const handleUnauthorizedResponse = () => { + if (typeof window === "undefined") return; + + const remainingMs = tokenManager.getRemainingMsFromToken(CLAIM_NAME); + const isExpired = remainingMs === null || remainingMs <= 0; + + if (!isExpired) return; + + tokenManager.clearAuthToken(); + + const isLoginPage = + window.location.pathname === "/auth/login" || + window.location.pathname.startsWith("/auth/login/"); + + if (!isLoginPage) { + window.location.assign(LOGIN_PAGE_URL); + } +}; + export const getFetch = async (type: string, url: string) => { const div = type === fetchType.COMMON @@ -24,6 +48,10 @@ export const getFetch = async (type: string, url: string) => { }, }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } + if (!response.ok) { console.log(`오류 발생: ${response.status}`); } @@ -72,6 +100,10 @@ export const getFetchWithCount = async (type: string, url: string) => { }, }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } + if (!response.ok) { console.log(`오류 발생: ${response.status}`); } @@ -114,6 +146,9 @@ export const postFectch = async (url: string, params: any, type?: string) => { }, body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if (!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -138,6 +173,9 @@ export const putFectch = async (url: string, params: any, type?: string) => { }, body: JSON.stringify(params), }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if (!response.ok) { throw new Error(`오류 발생: ${response.status}`); } @@ -166,6 +204,9 @@ export const deleteFectch = async ( }, body: params ? JSON.stringify(params) : undefined, }); + if (response.status === 401) { + handleUnauthorizedResponse(); + } if (!response.ok) { // 에러 응답 본문 파싱 const errorText = await response.text(); diff --git a/apps/wr/src/entities/auth/lib/token-manager.ts b/apps/wr/src/entities/auth/lib/token-manager.ts index 40d32f33e..919386622 100644 --- a/apps/wr/src/entities/auth/lib/token-manager.ts +++ b/apps/wr/src/entities/auth/lib/token-manager.ts @@ -1,6 +1,13 @@ import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config"; import { getCookie, removeCookie, setCookie } from "@/src/shared/lib/cookie"; +type JwtPayload = { + sub?: string; // 토큰 subject + exp?: number; // 만료 시각(초 단위) + iat?: number; // 발급 시각(초 단위) + [key: string]: unknown; +}; + export class TokenManager { private static instance: TokenManager; private refreshTimer: NodeJS.Timeout | null = null; @@ -18,6 +25,49 @@ export class TokenManager { return getCookie(key); } + private parseJwt(token: string): JwtPayload { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("유효한 JWT 형식이 아닙니다."); + } + + const base64Url = parts[1] ?? ""; // Payload를 가져온다. + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const jsonPayload = decodeURIComponent( + atob(padded) + .split("") + .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`) + .join("") + ); + + return JSON.parse(jsonPayload) as JwtPayload; + } + + public getRemainingMsFromToken(key: string = CLAIM_NAME): number | null { + const token = this.getToken(key); + if (!token) { + return null; + } + + try { + const payload = this.parseJwt(token); + if (typeof payload.exp !== "number") { + return null; + } + + return payload.exp * 1000 - Date.now(); + } catch (error) { + console.error("JWT 파싱 오류:", error); + return null; + } + } + + public isExpiredToken(key: string = CLAIM_NAME): boolean { + const remainingMs = this.getRemainingMsFromToken(key); + return remainingMs === null || remainingMs <= 0; + } + public setToken(key: string, value: string): void { setCookie(key, value, { secure: window.location.protocol === "https:",