wr: jwt 남은시간
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user