wr: jwt 남은시간

This commit is contained in:
2026-04-10 18:01:12 +09:00
parent 6f0f03f83b
commit 2de065ceb0
4 changed files with 259 additions and 1 deletions
+164
View File
@@ -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<JwtState>(() => readJwtState());
useEffect(() => {
const syncJwtState = () => {
setJwtState(readJwtState());
};
syncJwtState();
const timer = window.setInterval(syncJwtState, 1000);
return () => window.clearInterval(timer);
}, []);
return (
<div className="space-y-4 p-8 text-base">
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
<div className="rounded border p-4">
<div className="font-semibold">쿠키명</div>
<div>{CLAIM_NAME}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">exp</div>
<div>{jwtState.exp ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">만료시각</div>
<div>{jwtState.expDateLocal ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">남은시간</div>
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">상태</div>
<div>{jwtState.error ?? "정상"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">payload</div>
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
</pre>
</div>
</div>
);
}
@@ -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,
+41
View File
@@ -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();
@@ -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:",