From c6a99ce4ce3607ad2eee125c3832c2de38f2bbbc Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 24 Dec 2025 09:59:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20JWT=20=ED=86=A0=ED=81=B0=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B0=B1=EC=8B=A0=20(=EC=8A=AC=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=94=A9=20=EC=84=B8=EC=85=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토큰 만료 시간 30일로 변경 - /auth/refresh 엔드포인트 추가 (만료 7일 전 갱신) - 프론트엔드에서 1시간마다 토큰 자동 갱신 체크 --- backend/routes/auth.js | 42 +++++++++++++++++++++++++-- frontend/src/contexts/AuthContext.jsx | 33 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 5bb5ba6..d412738 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -290,11 +290,11 @@ router.post("/login", async (req, res) => { // 로그인 성공 clearLoginAttempts(ip); - // JWT 토큰 생성 + // JWT 토큰 생성 (30일 유효) const token = jwt.sign( { id: user.id, email: user.email, isAdmin: user.is_admin }, JWT_SECRET, - { expiresIn: "24h" } + { expiresIn: "30d" } ); console.log(`[Auth] 로그인 성공: ${user.email} - IP: ${ip}`); @@ -356,6 +356,44 @@ router.get("/me", async (req, res) => { } }); +/** + * POST /auth/refresh - 토큰 갱신 (슬라이딩 세션) + * 만료 7일 전이면 새 토큰 발급 + */ +router.post("/refresh", async (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ error: "토큰이 없습니다." }); + } + + const token = authHeader.split(" ")[1]; + + try { + const decoded = jwt.verify(token, JWT_SECRET); + + // 만료까지 남은 시간 계산 (초) + const expiresIn = decoded.exp - Math.floor(Date.now() / 1000); + const sevenDaysInSeconds = 7 * 24 * 60 * 60; + + // 만료 7일 전이면 새 토큰 발급 + if (expiresIn < sevenDaysInSeconds) { + const newToken = jwt.sign( + { id: decoded.id, email: decoded.email, isAdmin: decoded.isAdmin }, + JWT_SECRET, + { expiresIn: "30d" } + ); + console.log(`[Auth] 토큰 갱신: ${decoded.email}`); + return res.json({ refreshed: true, token: newToken }); + } + + // 아직 갱신 불필요 + res.json({ refreshed: false }); + } catch (error) { + // 토큰이 만료된 경우 + return res.status(401).json({ error: "토큰이 만료되었습니다." }); + } +}); + /** * POST /auth/logout - 로그아웃 (클라이언트에서 토큰 삭제) */ diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 23c5979..94d9741 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -45,6 +45,39 @@ export function AuthProvider({ children }) { } }; + // 토큰 자동 갱신 (슬라이딩 세션) + const refreshToken = async () => { + const token = localStorage.getItem('token'); + if (!token) return; + + try { + const response = await fetch('/auth/refresh', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + + if (data.refreshed && data.token) { + localStorage.setItem('token', data.token); + console.log('[Auth] 토큰 자동 갱신됨'); + } + } catch (error) { + // 갱신 실패 시 무시 (다음 주기에 재시도) + } + }; + + // 1시간마다 토큰 갱신 체크 + useEffect(() => { + if (!user) return; + + // 초기 갱신 체크 + refreshToken(); + + // 1시간마다 갱신 체크 + const interval = setInterval(refreshToken, 60 * 60 * 1000); + return () => clearInterval(interval); + }, [user]); + // 로그인 const login = async (email, password) => { try {