페이지 라우팅 할 때마다 access token (저장소/쿠키) 확인기능 추가

This commit is contained in:
2026-01-02 17:12:32 +09:00
parent 88b70011c5
commit 90bbb0dd50
5 changed files with 151 additions and 99 deletions

View File

@@ -1,5 +1,5 @@
import React, { Suspense, useEffect } from 'react' import React, { Suspense, useEffect } from 'react'
import { HashRouter, Route, Routes, Navigate } from 'react-router-dom' import { HashRouter, Route, Routes, Navigate, useLocation } from 'react-router-dom'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { RootState } from 'src/store' import { RootState } from 'src/store'
@@ -21,11 +21,22 @@ const ProtectedRoute = React.lazy(() => import('src/routes/ProtectedRoute'))
import { useAuth } from 'src/hooks/useAuth' import { useAuth } from 'src/hooks/useAuth'
const RouteWatcher = () => {
const { checkAuth } = useAuth()
const location = useLocation()
useEffect(() => {
checkAuth(location.key)
}, [location.key, checkAuth])
return null
}
const App = () => { const App = () => {
const { isColorModeSet, setColorMode } = useColorModes('coreui-free-react-admin-template-theme') const { isColorModeSet, setColorMode } = useColorModes('coreui-free-react-admin-template-theme')
const storedTheme = useSelector((state: RootState) => state.theme) const storedTheme = useSelector((state: RootState) => state.theme)
const { state: authState } = useAuth() const { state: authState } = useAuth()
const { isAuthenticated, loading: authLoading } = authState const { isAuthenticated, loading: authLoading, isInitialized } = authState
useEffect(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.href.split('?')[1]) const urlParams = new URLSearchParams(window.location.href.split('?')[1])
@@ -41,7 +52,8 @@ const App = () => {
setColorMode(storedTheme) setColorMode(storedTheme)
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
if (authLoading) { // 초기 로딩 (앱 최초 실행 시에만 전역 스피너 표시)
if (!isInitialized) {
return ( return (
<div className="pt-3 text-center"> <div className="pt-3 text-center">
<CSpinner color="primary" variant="grow" /> <CSpinner color="primary" variant="grow" />
@@ -51,6 +63,7 @@ const App = () => {
return ( return (
<HashRouter> <HashRouter>
<RouteWatcher />
<Suspense <Suspense
fallback={ fallback={
<div className="pt-3 text-center"> <div className="pt-3 text-center">

View File

@@ -104,12 +104,9 @@ export const getUserFromToken = (token: string): DecodedToken | null => {
}; };
// Access Token 갱신 API // Access Token 갱신 API
export const renewAccessToken = async (refreshToken: string): Promise<AuthResponse> => { export const renewAccessToken = async (refreshToken?: string): Promise<AuthResponse> => {
// Refresh Token Header에 담아 전송 (Authorization: Bearer <token>) // Refresh Token이 있으면 Header에 담고, 없으면 쿠키(HttpOnly)에 의존
const response = await axios.post<AuthResponse>('/auth/renewAccessToken', null, { const headers = refreshToken ? { 'Authorization': `Bearer ${refreshToken}` } : {};
headers: { const response = await axios.post<AuthResponse>('/auth/renewAccessToken', null, { headers });
'Authorization': `Bearer ${refreshToken}`
}
});
return response.data; return response.data;
}; };

View File

@@ -1,14 +1,27 @@
import React, { Suspense } from 'react' import React, { Suspense } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { CContainer, CSpinner } from '@coreui/react' import { CContainer, CSpinner } from '@coreui/react'
// routes config // routes config
import routes from 'src/routes/routes' import routes from 'src/routes/routes'
import { useAuth } from 'src/hooks/useAuth'
const AppContent = () => { const AppContent = () => {
const { state } = useAuth()
const { loading: authLoading, verifiedKey } = state
const location = useLocation()
// 현재 라우트 키가 인증된 키와 다르면 로딩 상태로 간주 (화면이 그려지기 전 차단)
const isVerifying = authLoading || (verifiedKey !== location.key)
return ( return (
<CContainer className="px-4" lg> <CContainer className="px-4" lg>
<Suspense fallback={<CSpinner color="primary" />}> <Suspense fallback={<CSpinner color="primary" />}>
{isVerifying ? (
<div className="pt-3 text-center">
<CSpinner color="primary" variant="grow" />
</div>
) : (
<Routes> <Routes>
{routes.map((route, idx) => { {routes.map((route, idx) => {
return ( return (
@@ -24,6 +37,7 @@ const AppContent = () => {
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/login" element={<Navigate to="/dashboard" replace />} /> <Route path="/login" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
)}
</Suspense> </Suspense>
</CContainer> </CContainer>
) )

View File

@@ -1,4 +1,4 @@
import React, { createContext, useReducer, useEffect } from 'react'; import React, { createContext, useReducer, useEffect, useCallback } from 'react';
import { isTokenValid, getAccessTokenFromCookie, getUserFromToken, getRefreshTokenFromCookie, renewAccessToken, logout as apiLogout } from 'src/axios/authService'; import { isTokenValid, getAccessTokenFromCookie, getUserFromToken, getRefreshTokenFromCookie, renewAccessToken, logout as apiLogout } from 'src/axios/authService';
@@ -13,6 +13,8 @@ interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
member: Member | null; member: Member | null;
loading: boolean; loading: boolean;
isInitialized: boolean;
verifiedKey: string | null;
error: string | null; error: string | null;
} }
@@ -23,13 +25,15 @@ type AuthAction =
| { type: 'AUTH_ERROR'; payload: string } | { type: 'AUTH_ERROR'; payload: string }
| { type: 'CLEAR_ERROR' } | { type: 'CLEAR_ERROR' }
| { type: 'SET_LOADING' } | { type: 'SET_LOADING' }
| { type: 'MEMBER_LOADED'; payload: Member }; | { type: 'MEMBER_LOADED'; payload: Member; key?: string };
// 초기 상태 // 초기 상태
const initialState: AuthState = { const initialState: AuthState = {
isAuthenticated: false, isAuthenticated: false,
member: null, member: null,
loading: true, loading: true,
isInitialized: false,
verifiedKey: null,
error: null error: null
}; };
@@ -39,6 +43,7 @@ export interface AuthContextType {
dispatch: React.Dispatch<AuthAction>; dispatch: React.Dispatch<AuthAction>;
login: (token: string, member: Member) => void; login: (token: string, member: Member) => void;
logout: () => void; logout: () => void;
checkAuth: (key?: string) => Promise<void>;
} }
// Context 생성 // Context 생성
@@ -46,7 +51,8 @@ export const AuthContext = createContext<AuthContextType>({
state: initialState, state: initialState,
dispatch: () => null, dispatch: () => null,
login: () => null, login: () => null,
logout: () => null logout: () => null,
checkAuth: async () => { }
}); });
// 리듀서 함수 // 리듀서 함수
@@ -59,6 +65,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
isAuthenticated: true, isAuthenticated: true,
member: action.payload.member, member: action.payload.member,
loading: false, loading: false,
isInitialized: true,
error: null error: null
}; };
case 'LOGOUT': case 'LOGOUT':
@@ -71,6 +78,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
isAuthenticated: false, isAuthenticated: false,
member: null, member: null,
loading: false, loading: false,
isInitialized: true,
error: null error: null
}; };
case 'AUTH_ERROR': case 'AUTH_ERROR':
@@ -83,6 +91,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
isAuthenticated: false, isAuthenticated: false,
member: null, member: null,
loading: false, loading: false,
isInitialized: true,
error: action.payload error: action.payload
}; };
case 'CLEAR_ERROR': case 'CLEAR_ERROR':
@@ -100,7 +109,9 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
...state, ...state,
isAuthenticated: true, isAuthenticated: true,
member: action.payload, member: action.payload,
loading: false loading: false,
isInitialized: true,
verifiedKey: (action as any).key || state.verifiedKey
}; };
default: default:
return state; return state;
@@ -112,15 +123,15 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [state, dispatch] = useReducer(authReducer, initialState); const [state, dispatch] = useReducer(authReducer, initialState);
// 로그인 함수 // 로그인 함수
const login = (token: string, member: Member) => { const login = useCallback((token: string, member: Member) => {
dispatch({ dispatch({
type: 'LOGIN_SUCCESS', type: 'LOGIN_SUCCESS',
payload: { token, member } payload: { token, member }
}); });
}; }, [dispatch]);
// 로그아웃 함수 // 로그아웃 함수
const logout = async () => { const logout = useCallback(async () => {
try { try {
await apiLogout(); await apiLogout();
} catch (error) { } catch (error) {
@@ -128,66 +139,89 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} finally { } finally {
dispatch({ type: 'LOGOUT' }); dispatch({ type: 'LOGOUT' });
} }
}; }, [dispatch]);
// 초기 인증 상태 확인 // 인증 상태 확인 함수
useEffect(() => { const checkAuth = useCallback(async (key?: string) => {
const loadUser = async () => { const lsToken = localStorage.getItem('accessToken') || localStorage.getItem('access_token');
// localStorage 또는 Cookie에서 토큰 확인 const cookieToken = getAccessTokenFromCookie();
let token = localStorage.getItem('accessToken') || localStorage.getItem('access_token') || getAccessTokenFromCookie();
if (!token || !isTokenValid(token)) { let token = null;
// Access Token이 없거나 만료된 경우 Refresh Token 확인
let refreshToken = localStorage.getItem('refreshToken') || localStorage.getItem('refresh_token') || getRefreshTokenFromCookie();
// refreshToken으로 Access Token 갱신 시도 // 1. 쿠키에 유효한 토큰이 있는 경우를 최상순위로 신뢰
try { if (cookieToken && isTokenValid(cookieToken)) {
const response = await renewAccessToken(refreshToken || ''); token = cookieToken;
if (response.resultCode === '200') {
// 갱신 성공 시 새로운 Access Token 가져오기
const newToken = response.resultData || getAccessTokenFromCookie() || localStorage.getItem('accessToken');
if (newToken && isTokenValid(newToken)) {
token = newToken;
// 갱신된 토큰을 localStorage에 즉시 저장
localStorage.setItem('accessToken', token);
} }
// 2. 쿠키가 없는데 로컬 스토리지에만 토큰이 있는 경우 -> "불완전한 상태"로 보고 갱신 시도
else if (lsToken && isTokenValid(lsToken)) {
// LS에 유효한 토큰이 있다면 일단 이를 믿고 화면 전환을 허용 (깜빡임 방지)
token = lsToken;
}
// 2. 만약 유효한 토큰이 물리적으로 하나도 없다면 "차단형 로딩" 후 갱신 시도
if (!token) {
dispatch({ type: 'SET_LOADING' });
const refreshToken = localStorage.getItem('refreshToken') || localStorage.getItem('refresh_token') || getRefreshTokenFromCookie();
try {
const response = await renewAccessToken(refreshToken || undefined);
if (response.resultCode === '200') {
token = response.resultData || getAccessTokenFromCookie();
if (token) localStorage.setItem('accessToken', token);
} }
} catch (error) { } catch (error) {
// 갱신 실패 무시 // Ignore silent failure
} }
} }
// 3. 토큰은 있지만 쿠키가 없는 경우 등 "불완전한 상태"라면 백그라운드에서 조용히 갱신
else if (!cookieToken || !isTokenValid(cookieToken)) {
const refreshToken = localStorage.getItem('refreshToken') || localStorage.getItem('refresh_token') || getRefreshTokenFromCookie();
// 갱신을 호출하지만 'SET_LOADING'은 하지 않음 (사용자는 모름)
renewAccessToken(refreshToken || undefined).then(response => {
if (response.resultCode === '200') {
const newToken = response.resultData || getAccessTokenFromCookie();
if (newToken) {
localStorage.setItem('accessToken', newToken);
}
}
}).catch(() => {
// Ignore silent failure
});
}
// 4. 모든 시도 후에도 토큰이 없으면 로그아웃
if (!token || !isTokenValid(token)) { if (!token || !isTokenValid(token)) {
dispatch({ type: 'LOGOUT' }); dispatch({ type: 'LOGOUT' });
return; return;
} }
// 5. 최종 인증 상태 업데이트
try { try {
// 토큰이 존재하고 유효한 경우, localStorage와 동기화 (갱신 등으로 변경되었을 수 있음) const decodedToken = getUserFromToken(token);
if (token && localStorage.getItem('accessToken') !== token) {
localStorage.setItem('accessToken', token);
}
const decodedToken = getUserFromToken(token!); // 위에서 유효성 검사 완료됨
if (decodedToken) { if (decodedToken) {
const member: Member = { const member: Member = {
memberId: decodedToken.memberId, memberId: decodedToken.memberId,
memberName: decodedToken.memberName memberName: decodedToken.memberName
}; };
dispatch({ type: 'MEMBER_LOADED', payload: member });
// key를 함께 전달하여 이 라우트의 검증이 완료되었음을 알림
dispatch({ type: 'MEMBER_LOADED', payload: member, key } as any);
} else { } else {
dispatch({ type: 'AUTH_ERROR', payload: 'Invalid token' }); dispatch({ type: 'AUTH_ERROR', payload: 'Invalid token structure' });
} }
} catch (error) { } catch (error) {
dispatch({ type: 'AUTH_ERROR', payload: 'Authentication failed' }); dispatch({ type: 'AUTH_ERROR', payload: 'Authentication sync failed' });
} }
}; }, [dispatch]);
loadUser(); // 초기 인증 상태 확인
}, []); useEffect(() => {
checkAuth('initial');
}, [checkAuth]);
return ( return (
<AuthContext.Provider value={{ state, dispatch, login, logout }}> <AuthContext.Provider value={{ state, dispatch, login, logout, checkAuth }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -80,8 +80,8 @@ const Login = () => {
<CIcon icon={cilUser} /> <CIcon icon={cilUser} />
</CInputGroupText> </CInputGroupText>
<CFormInput <CFormInput
placeholder="Username" placeholder="Member ID"
autoComplete="username" autoComplete="memberId"
value={memberId} value={memberId}
onChange={(e) => setMemberId(e.target.value)} onChange={(e) => setMemberId(e.target.value)}
/> />
@@ -99,27 +99,21 @@ const Login = () => {
/> />
</CInputGroup> </CInputGroup>
<CRow> <CRow>
<CCol xs={6}> <CCol xs={12}>
<CButton color="primary" className="px-4" type="submit" disabled={loading}> <CButton color="primary" className="w-100" type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'} {loading ? 'Logging in...' : 'Login'}
</CButton> </CButton>
</CCol> </CCol>
<CCol xs={6} className="text-right">
<CButton color="link" className="px-0">
Forgot password?
</CButton>
</CCol>
</CRow> </CRow>
</CForm> </CForm>
</CCardBody> </CCardBody>
</CCard> </CCard>
<CCard className="text-white bg-primary py-5" style={{ width: '44%' }}> <CCard className="text-white bg-primary py-5">
<CCardBody className="text-center"> <CCardBody className="text-center">
<div> <div>
<h2>Sign up</h2> <h2>Sign up</h2>
<p> <p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod , .
tempor incididunt ut labore et dolore magna aliqua.
</p> </p>
<Link to="/register"> <Link to="/register">
<CButton color="primary" className="mt-3" active tabIndex={-1}> <CButton color="primary" className="mt-3" active tabIndex={-1}>