Skip to content

Commit 591d5c9

Browse files
authored
πŸ”’ Logout automatically when tokens expired (#112)
### πŸ“ μž‘μ—… λ‚΄μš© - 22μ‹œκ°„μ΄ μ§€λ‚˜λ©΄ μžλ™μœΌλ‘œ λ‘œκ·Έμ•„μ›ƒ 되게 μ„€μ •ν–ˆμŠ΅λ‹ˆλ‹€. λ‘œκ·ΈμΈν•  λ•Œ, 둜그인 μ‹œκ°„μ„ `zustand`에 μ €μž₯ν•˜κ³ ,, 이후 κ·Έ μ‹œκ°„κ³Ό api μš”μ²­μ΄ μΌμ–΄λ‚˜λŠ” μ‹œκ°„μ„ λΉ„κ΅ν•˜μ—¬ 22μ‹œκ°„μ΄ μ§€λ‚˜λ©΄ μžλ™μœΌλ‘œ λ‘œκ·Έμ•„μ›ƒ λ©λ‹ˆλ‹€. λ‹€μŒ 3κ°€μ§€ λ°©μ‹μœΌλ‘œ 검사λ₯Ό μˆ˜ν–‰ν•©λ‹ˆλ‹€. - API Interceptor 검사 - λΈŒλΌμš°μ € νƒ­ 볡귀 μ‹œμ  감지 - 타이머 기반 μžλ™ 감지 - λΈŒλΌμš°μ € νƒ­ 볡귀 μ‹œμ  감지와 타이머 기반 μžλ™ 감지 λͺ¨λ‘ μžλ°”μŠ€ν¬λ¦½νŠΈ μ—”μ§„κ³Ό λΈŒλΌμš°μ €μ˜ κΈ°λ³Έ 제곡 κΈ°λŠ₯(Web API)을 ν™œμš©ν•΄ `Event-Driven` λ°©μ‹μœΌλ‘œ μž‘λ™ν•˜μ—¬ μ„±λŠ₯적인 μ•…μ˜ν–₯은 거의 없을 것이라고 ν•©λ‹ˆλ‹€. - μΆ”ν›„ λ°±μ—”λ“œμ—μ„œ `refreshToken`을 λ„μž…ν•˜λ©΄ μžλ™ λ‘œκ·Έμ•„μ›ƒ 뢀뢄을 `refreshToken`을 μš”μ²­ν•˜κ²Œ μˆ˜μ •ν•˜λ©΄ 될 것 κ°™μŠ΅λ‹ˆλ‹€. ### πŸ“Έ μŠ€ν¬λ¦°μƒ· (선택) <img width="1911" height="1293" alt="image" src="https://github.com/user-attachments/assets/a37aad8e-42be-4df4-8658-88418aff3c4c" /> ### πŸš€ 리뷰 μš”κ΅¬μ‚¬ν•­ (선택) - λ‘œκ·Έμ•„μ›ƒ μ•ˆλ‚΄ λͺ¨λ‹¬μ΄ λ‚˜νƒ€λ‚˜κ³  둜그인 νŽ˜μ΄μ§€λ‘œ μ΄λ™ν•˜κ²Œ λ§Œλ“€μ—ˆλŠ”λ°, λͺ¨λ‹¬ λ¬Έκ΅¬λŠ” λŒ€μΆ© λ§Œλ“€μ—ˆμŠ΅λ‹ˆλ‹€. ν˜Ήμ‹œ 더 쒋은 의견 μžˆμœΌμ‹ κ°€μš”...?
1 parent 91347a6 commit 591d5c9

File tree

10 files changed

+217
-27
lines changed

10 files changed

+217
-27
lines changed

β€Žpackage.jsonβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"class-variance-authority": "^0.7.1",
3030
"clsx": "^2.1.1",
3131
"framer-motion": "^12.33.0",
32+
"jwt-decode": "^4.0.0",
3233
"lucide-react": "^0.562.0",
3334
"motion": "^12.29.2",
3435
"radix-ui": "^1.4.3",

β€Žsrc/App.tsxβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import './App.css';
22

33
import { GlobalErrorModal } from '@/components/GlobalErrorModal';
44
import { RouterProvider } from 'react-router/dom';
5+
import { useAutoLogout } from './hooks/useAutoLogout';
56
import { router } from './routes';
67

78
export default function App() {
9+
useAutoLogout();
10+
811
return (
912
<>
1013
<RouterProvider router={router} />

β€Žsrc/api/apiClient.tsβ€Ž

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios, { AxiosError } from 'axios';
2+
import { jwtDecode } from 'jwt-decode';
23
import useAuthStore from '../hooks/useAuthStore';
34
import { useErrorStore } from '../hooks/useErrorStore';
45

@@ -8,6 +9,9 @@ interface ApiErrorResponse {
89
message: string;
910
}
1011

12+
// 5λΆ„ 버퍼 (λ°€λ¦¬μ΄ˆ)
13+
const BUFFER_TIME = 5 * 60 * 1000;
14+
1115
// 곡톡 섀정을 κ°€μ§„ axios μΈμŠ€ν„΄μŠ€ 생성
1216
const apiClient = axios.create({
1317
baseURL: `${import.meta.env.VITE_API_BASE_URL}/api`,
@@ -17,9 +21,33 @@ const apiClient = axios.create({
1721

1822
// μš”μ²­ 인터셉터: λͺ¨λ“  μš”μ²­ 직전에 싀행됨
1923
apiClient.interceptors.request.use((config) => {
20-
const token = useAuthStore.getState().token;
24+
const { token, logout } = useAuthStore.getState();
2125

2226
if (token) {
27+
try {
28+
const decoded = jwtDecode(token);
29+
30+
if (decoded.exp) {
31+
const expTimeMs = decoded.exp * 1000;
32+
const now = Date.now();
33+
34+
// λ§Œλ£Œμ‹œκ°„ 5λΆ„ μ „(ν˜Ήμ€ 이미 지남)이면 κ±°λΆ€
35+
if (expTimeMs - BUFFER_TIME - now <= 0) {
36+
// μƒνƒœ 관리 λ‘œκ·Έμ•„μ›ƒ 처리만 μˆ˜ν–‰
37+
// μ—λŸ¬ λͺ¨λ‹¬(showError)κ³Ό λ¦¬λ‹€μ΄λ ‰νŠΈ(window.location.href)λŠ”
38+
// μ΅œμƒλ‹¨ App.tsx에 마운트된 useAutoLogout ν›…μ—μ„œ μΌκ΄„μ μœΌλ‘œ μ²˜λ¦¬ν•˜λ„λ‘ μœ„μž„ν•¨.
39+
// μ—¬κΈ°μ„œ 쀑볡 호좜 μ‹œ λͺ¨λ‹¬μ΄ 2번 λœ¨λŠ” Race Condition λ°œμƒ κ°€λŠ₯μ„± 차단
40+
logout();
41+
return Promise.reject(new Error('TOKEN_EXPIRED_LOCAL'));
42+
}
43+
}
44+
} catch {
45+
// λ””μ½”λ”© μ‹€νŒ¨ μ‹œ μ§„ν–‰ν•˜μ§€ μ•ŠμŒ (μ„œλ²„κ°€ κ±°μ ˆν•˜λ„λ‘ λ‘κ±°λ‚˜ μ—¬κΈ°μ„œ λ°”λ‘œ λ§‰μŒ)
46+
// λ³΄μ•ˆμƒ μ—¬κΈ°μ„œ λ°”λ‘œ λ§‰λŠ” 것이 μ•ˆμ „
47+
logout();
48+
return Promise.reject(new Error('INVALID_TOKEN_FORMAT'));
49+
}
50+
2351
config.headers.Authorization = `Bearer ${token}`;
2452
}
2553

@@ -36,14 +64,16 @@ apiClient.interceptors.response.use(
3664

3765
// 1. 토큰 만료 λ“± νŠΉμˆ˜ν•œ 경우 확인 λ²„νŠΌ μ•‘μ…˜ μ •μ˜
3866
let onConfirm = undefined;
67+
let confirmText = undefined;
3968
if (code === 'TOKEN_EXPIRED') {
4069
onConfirm = () => {
4170
window.location.href = '/login';
4271
};
72+
confirmText = 'λ‹€μ‹œ λ‘œκ·ΈμΈν•˜κΈ°';
4373
}
4474

45-
// 2. μŠ€ν† μ–΄λ₯Ό 톡해 λͺ¨λ‹¬ λ„μš°κΈ° (μˆœμ„œ 주의: message, title, onConfirm)
46-
showError(message, title || '였λ₯˜ λ°œμƒ', onConfirm);
75+
// 2. μŠ€ν† μ–΄λ₯Ό 톡해 λͺ¨λ‹¬ λ„μš°κΈ° (μˆœμ„œ 주의: message, title, onConfirm, confirmText)
76+
showError(message, title || '였λ₯˜ λ°œμƒ', onConfirm, confirmText);
4777
} else {
4878
// μ„œλ²„ 응닡 μžμ²΄κ°€ μ—†λŠ” 경우 (λ„€νŠΈμ›Œν¬ 였λ₯˜ λ“±)
4979
showError(

β€Žsrc/components/GlobalErrorModal.tsxβ€Ž

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,30 @@ import {
1010
import { useErrorStore } from '@/hooks/useErrorStore';
1111

1212
export function GlobalErrorModal() {
13-
const { isOpen, title, message, onConfirm, closeError } = useErrorStore();
13+
const { isOpen, title, message, onConfirm, closeError, confirmText } =
14+
useErrorStore();
1415

1516
const handleConfirm = () => {
1617
if (onConfirm) {
1718
onConfirm();
1819
}
19-
closeError();
20+
21+
// νŽ˜μ΄μ§€ 이동 λ“± 무거운 μž‘μ—…μ΄ λ°œμƒν•  λ•Œ λ¦¬μ•‘νŠΈ λ Œλ”λ§ 사이클이 κΌ¬μ΄λ©΄μ„œ
22+
// 빈 λͺ¨λ‹¬μ΄ κΉœλΉ‘μ΄λŠ” ν˜„μƒ(Race condition)을 막기 μœ„ν•΄ μ•½κ°„μ˜ λ”œλ ˆμ΄λ₯Ό 두고 λ‹«μŒ
23+
setTimeout(() => {
24+
closeError();
25+
}, 100);
26+
};
27+
28+
const handleOpenChange = (open: boolean) => {
29+
if (!open) {
30+
// λ°”κΉ₯ μ˜μ—­ 클릭(μ˜€λ²„λ ˆμ΄) λ“± μˆ˜λ™μœΌλ‘œ 닫을 λ•Œλ§Œ μ¦‰μ‹œ 처리
31+
closeError();
32+
}
2033
};
2134

2235
return (
23-
<AlertDialog open={isOpen} onOpenChange={closeError}>
36+
<AlertDialog open={isOpen} onOpenChange={handleOpenChange}>
2437
<AlertDialogContent>
2538
<AlertDialogHeader>
2639
<AlertDialogTitle className="text-destructive">
@@ -31,7 +44,9 @@ export function GlobalErrorModal() {
3144
</AlertDialogDescription>
3245
</AlertDialogHeader>
3346
<AlertDialogFooter>
34-
<AlertDialogAction onClick={handleConfirm}>확인</AlertDialogAction>
47+
<AlertDialogAction onClick={handleConfirm}>
48+
{confirmText}
49+
</AlertDialogAction>
3550
</AlertDialogFooter>
3651
</AlertDialogContent>
3752
</AlertDialog>

β€Žsrc/hooks/useAuthStore.tsβ€Ž

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,18 @@ const useAuthStore = create<AuthState>()(
2828
isLoggedIn: false,
2929
guestRegistrations: {},
3030

31-
login: (user, token) => set({ user, token, isLoggedIn: true }),
32-
logout: () => set({ user: null, token: null, isLoggedIn: false }),
31+
login: (user, token) =>
32+
set({
33+
user,
34+
token,
35+
isLoggedIn: true,
36+
}),
37+
logout: () =>
38+
set({
39+
user: null,
40+
token: null,
41+
isLoggedIn: false,
42+
}),
3343
updateUser: (user) => set({ user }),
3444
setGuestRegistration: (eventId, registrationId) =>
3545
set((state) => ({

β€Žsrc/hooks/useAutoLogout.tsβ€Ž

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { jwtDecode } from 'jwt-decode';
2+
import { useEffect, useRef } from 'react';
3+
import useAuthStore from './useAuthStore';
4+
import { useErrorStore } from './useErrorStore';
5+
6+
// 5λΆ„ 버퍼 (λ°€λ¦¬μ΄ˆ) -> 만료 5λΆ„ 전에 미리 λ‘œκ·Έμ•„μ›ƒ μ‹œν‚΄
7+
const BUFFER_TIME = 5 * 60 * 1000;
8+
9+
export function useAutoLogout() {
10+
const { token, logout } = useAuthStore();
11+
const showError = useErrorStore((state) => state.showError);
12+
13+
const isLoggingOut = useRef(false);
14+
15+
useEffect(() => {
16+
if (!token) {
17+
isLoggingOut.current = false;
18+
return;
19+
}
20+
21+
const checkAndLogout = () => {
22+
if (isLoggingOut.current) return;
23+
24+
try {
25+
const decoded = jwtDecode(token);
26+
if (!decoded.exp) return;
27+
28+
// 토큰 expλŠ” 초 λ‹¨μœ„μ΄λ―€λ‘œ λ°€λ¦¬μ΄ˆλ‘œ λ³€ν™˜
29+
const expTimeMs = decoded.exp * 1000;
30+
const now = Date.now();
31+
32+
// (λ§Œλ£Œμ‹œκ°„ - 버퍼) μ‹œμ κΉŒμ§€ 남은 μ‹œκ°„
33+
const timeLeftToLogout = expTimeMs - BUFFER_TIME - now;
34+
35+
if (timeLeftToLogout <= 0) {
36+
// 이미 만료 μ‹œμ μ„(ν˜Ήμ€ 버퍼 μ‹œμ μ„) 지났닀면 μ¦‰μ‹œ λ‘œκ·Έμ•„μ›ƒ
37+
handleLogout();
38+
} else {
39+
// 아직 μ‹œκ°„μ΄ λ‚¨μ•˜λ‹€λ©΄ 타이머 μ„€μ •
40+
const timerId = setTimeout(() => {
41+
handleLogout();
42+
}, timeLeftToLogout);
43+
44+
return timerId;
45+
}
46+
} catch (error) {
47+
// 토큰 λ””μ½”λ”© μ‹€νŒ¨ μ‹œ (μœ νš¨ν•˜μ§€ μ•Šμ€ 토큰) κ°•μ œ λ‘œκ·Έμ•„μ›ƒ
48+
console.error('Invalid token format', error);
49+
handleLogout();
50+
}
51+
};
52+
53+
const handleLogout = () => {
54+
if (isLoggingOut.current) return;
55+
isLoggingOut.current = true;
56+
57+
logout();
58+
showError(
59+
'둜그인 μœ μ§€ μ‹œκ°„μ΄ λ§Œλ£Œλ˜μ–΄ μžλ™μœΌλ‘œ λ‘œκ·Έμ•„μ›ƒλ˜μ—ˆμŠ΅λ‹ˆλ‹€.',
60+
'λ‘œκ·Έμ•„μ›ƒ μ•ˆλ‚΄',
61+
() => {
62+
window.location.href = '/login';
63+
},
64+
'λ‹€μ‹œ λ‘œκ·ΈμΈν•˜κΈ°'
65+
);
66+
};
67+
68+
// 1. 마운트 μ‹œμ μ— 체크 & 타이머 μ„€μ •
69+
let timerId = checkAndLogout();
70+
71+
// 2. λΈŒλΌμš°μ € νƒ­ ν™œμ„±ν™”(포컀슀 볡귀) μ‹œμ μ— λ‹€μ‹œ 체크
72+
const handleVisibilityChange = () => {
73+
if (document.visibilityState === 'visible') {
74+
if (timerId) clearTimeout(timerId);
75+
timerId = checkAndLogout();
76+
}
77+
};
78+
79+
document.addEventListener('visibilitychange', handleVisibilityChange);
80+
81+
return () => {
82+
if (timerId) clearTimeout(timerId);
83+
document.removeEventListener('visibilitychange', handleVisibilityChange);
84+
};
85+
}, [token, logout, showError]);
86+
}

β€Žsrc/hooks/useErrorStore.tsβ€Ž

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,30 @@ interface ErrorState {
44
isOpen: boolean;
55
title: string;
66
message: string;
7+
confirmText: string;
78
onConfirm?: () => void; // 확인 λ²„νŠΌ 클릭 μ‹œ μ‹€ν–‰ν•  콜백 ν•¨μˆ˜
8-
showError: (message: string, title?: string, onConfirm?: () => void) => void;
9+
showError: (
10+
message: string,
11+
title?: string,
12+
onConfirm?: () => void,
13+
confirmText?: string
14+
) => void;
915
closeError: () => void;
1016
}
1117

1218
export const useErrorStore = create<ErrorState>((set) => ({
1319
isOpen: false,
1420
title: '였λ₯˜ λ°œμƒ',
1521
message: '',
22+
confirmText: '확인',
1623
onConfirm: undefined,
17-
showError: (message, title = '였λ₯˜ λ°œμƒ', onConfirm) =>
18-
set({ isOpen: true, message, title, onConfirm }),
19-
closeError: () => set({ isOpen: false, message: '', onConfirm: undefined }),
24+
showError: (
25+
message,
26+
title = '였λ₯˜ λ°œμƒ',
27+
onConfirm,
28+
confirmText = '확인'
29+
) => {
30+
set({ isOpen: true, message, title, onConfirm, confirmText });
31+
},
32+
closeError: () => set({ isOpen: false }), // λ¦¬λ‹€μ΄λ ‰νŠΈ 쀑 κΉœλΉ‘μž„ λ°©μ§€λ₯Ό μœ„ν•΄ λ‚΄μš© 보쑴
2033
}));

β€Žsrc/mocks/handlers/auth.tsβ€Ž

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,21 @@ export const authHandlers = [
2626

2727
if (user) {
2828
await delay(500); // μ‹€μ œ μ„œλ²„μ²˜λŸΌ μ•½κ°„μ˜ μ§€μ—° μ‹œκ°„ μΆ”κ°€
29+
30+
// ν…ŒμŠ€νŠΈλ₯Ό μœ„ν•΄ 만료 5λΆ„ 10초(310초)짜리 μ§„μ§œ JWT ꡬ쑰 λ°œκΈ‰ (Base64 인코딩)
31+
// Header: {"alg":"HS256","typ":"JWT"} -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
32+
const header = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
33+
const payloadObj = {
34+
sub: user.id.toString(),
35+
iat: Math.floor(Date.now() / 1000),
36+
exp: Math.floor(Date.now() / 1000) + 86400, // ν˜„μž¬ μ‹œκ°„ + 24μ‹œκ°„ 만료
37+
};
38+
const payload = btoa(JSON.stringify(payloadObj));
39+
const signature = 'mock-signature';
40+
const mockJwtToken = `${header}.${payload}.${signature}`;
41+
2942
return HttpResponse.json({
30-
token: user.token,
43+
token: mockJwtToken,
3144
});
3245
}
3346

β€Žsrc/mocks/handlers/user.tsβ€Ž

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { GetMeResponse } from '@/types/users';
2-
import { http, HttpResponse, delay } from 'msw';
2+
import { http, HttpResponse } from 'msw';
33
import { userDB } from '../db/user.db';
44
import type { MockUser } from '../types';
55

@@ -22,24 +22,35 @@ export const userHandlers = [
2222
);
2323
}
2424

25-
// ν—€λ”μ—μ„œ ν† ν°κ°’λ§Œ μΆ”μΆœ (예: "Bearer mock-token-1" -> "mock-token-1")
25+
// ν—€λ”μ—μ„œ ν† ν°κ°’λ§Œ μΆ”μΆœ
2626
const requestToken = authHeader.split(' ')[1];
2727

28-
// 토큰을 기반으둜 DBμ—μ„œ μœ μ € μ°ΎκΈ°
29-
const user = userDB.find((u: MockUser) => u.token === requestToken);
28+
try {
29+
// ν΄λΌμ΄μ–ΈνŠΈμ—μ„œ 보낸 JWT λ””μ½”λ”© (base64 λΆ€λΆ„ μ°Ύμ•„μ„œ νŒŒμ‹±)
30+
const payloadBase64 = requestToken.split('.')[1];
31+
const payloadJson = atob(payloadBase64);
32+
const decoded = JSON.parse(payloadJson);
33+
const userId = Number(decoded.sub);
3034

31-
if (!user) {
32-
return new HttpResponse(
33-
{ message: 'ν•΄λ‹Ή μœ μ €λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.' },
34-
{ status: 404 }
35-
);
36-
}
35+
// 토큰 payload의 sub(μœ μ € ID)λ₯Ό 기반으둜 DBμ—μ„œ μœ μ € μ°ΎκΈ°
36+
const user = userDB.find((u: MockUser) => u.id === userId);
3737

38-
await delay(300);
38+
if (!user) {
39+
return new HttpResponse(
40+
{ message: 'ν•΄λ‹Ή μœ μ €λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.' },
41+
{ status: 404 }
42+
);
43+
}
3944

40-
const { password, ...userResponse } = user as MockUser;
45+
const { password, ...userResponse } = user as MockUser;
4146

42-
return HttpResponse.json(userResponse as GetMeResponse);
47+
return HttpResponse.json(userResponse as GetMeResponse);
48+
} catch (_error) {
49+
return new HttpResponse(
50+
{ message: 'μœ νš¨ν•˜μ§€ μ•Šμ€ ν† ν°μž…λ‹ˆλ‹€.' },
51+
{ status: 401 }
52+
);
53+
}
4354
}
4455
),
4556
];

β€Žyarn.lockβ€Ž

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6775,6 +6775,13 @@ __metadata:
67756775
languageName: node
67766776
linkType: hard
67776777

6778+
"jwt-decode@npm:^4.0.0":
6779+
version: 4.0.0
6780+
resolution: "jwt-decode@npm:4.0.0"
6781+
checksum: 10c0/de75bbf89220746c388cf6a7b71e56080437b77d2edb29bae1c2155048b02c6b8c59a3e5e8d6ccdfd54f0b8bda25226e491a4f1b55ac5f8da04cfbadec4e546c
6782+
languageName: node
6783+
linkType: hard
6784+
67786785
"kleur@npm:^3.0.3":
67796786
version: 3.0.3
67806787
resolution: "kleur@npm:3.0.3"
@@ -7232,6 +7239,7 @@ __metadata:
72327239
class-variance-authority: "npm:^0.7.1"
72337240
clsx: "npm:^2.1.1"
72347241
framer-motion: "npm:^12.33.0"
7242+
jwt-decode: "npm:^4.0.0"
72357243
knip: "npm:^5.59.1"
72367244
lucide-react: "npm:^0.562.0"
72377245
motion: "npm:^12.29.2"

0 commit comments

Comments
Β (0)