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

View File

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

View File

@@ -1,29 +1,43 @@
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'
// routes config
import routes from 'src/routes/routes'
import { useAuth } from 'src/hooks/useAuth'
const AppContent = () => {
const { state } = useAuth()
const { loading: authLoading, verifiedKey } = state
const location = useLocation()
// 현재 라우트 키가 인증된 키와 다르면 로딩 상태로 간주 (화면이 그려지기 전 차단)
const isVerifying = authLoading || (verifiedKey !== location.key)
return (
<CContainer className="px-4" lg>
<Suspense fallback={<CSpinner color="primary" />}>
<Routes>
{routes.map((route, idx) => {
return (
route.element && (
<Route
key={idx}
path={route.path}
element={<route.element />}
/>
{isVerifying ? (
<div className="pt-3 text-center">
<CSpinner color="primary" variant="grow" />
</div>
) : (
<Routes>
{routes.map((route, idx) => {
return (
route.element && (
<Route
key={idx}
path={route.path}
element={<route.element />}
/>
)
)
)
})}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/login" element={<Navigate to="/dashboard" replace />} />
</Routes>
})}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/login" element={<Navigate to="/dashboard" replace />} />
</Routes>
)}
</Suspense>
</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';
@@ -13,6 +13,8 @@ interface AuthState {
isAuthenticated: boolean;
member: Member | null;
loading: boolean;
isInitialized: boolean;
verifiedKey: string | null;
error: string | null;
}
@@ -23,13 +25,15 @@ type AuthAction =
| { type: 'AUTH_ERROR'; payload: string }
| { type: 'CLEAR_ERROR' }
| { type: 'SET_LOADING' }
| { type: 'MEMBER_LOADED'; payload: Member };
| { type: 'MEMBER_LOADED'; payload: Member; key?: string };
// 초기 상태
const initialState: AuthState = {
isAuthenticated: false,
member: null,
loading: true,
isInitialized: false,
verifiedKey: null,
error: null
};
@@ -39,6 +43,7 @@ export interface AuthContextType {
dispatch: React.Dispatch<AuthAction>;
login: (token: string, member: Member) => void;
logout: () => void;
checkAuth: (key?: string) => Promise<void>;
}
// Context 생성
@@ -46,7 +51,8 @@ export const AuthContext = createContext<AuthContextType>({
state: initialState,
dispatch: () => null,
login: () => null,
logout: () => null
logout: () => null,
checkAuth: async () => { }
});
// 리듀서 함수
@@ -59,6 +65,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
isAuthenticated: true,
member: action.payload.member,
loading: false,
isInitialized: true,
error: null
};
case 'LOGOUT':
@@ -71,6 +78,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
isAuthenticated: false,
member: null,
loading: false,
isInitialized: true,
error: null
};
case 'AUTH_ERROR':
@@ -83,6 +91,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
isAuthenticated: false,
member: null,
loading: false,
isInitialized: true,
error: action.payload
};
case 'CLEAR_ERROR':
@@ -100,7 +109,9 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => {
...state,
isAuthenticated: true,
member: action.payload,
loading: false
loading: false,
isInitialized: true,
verifiedKey: (action as any).key || state.verifiedKey
};
default:
return state;
@@ -112,15 +123,15 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [state, dispatch] = useReducer(authReducer, initialState);
// 로그인 함수
const login = (token: string, member: Member) => {
const login = useCallback((token: string, member: Member) => {
dispatch({
type: 'LOGIN_SUCCESS',
payload: { token, member }
});
};
}, [dispatch]);
// 로그아웃 함수
const logout = async () => {
const logout = useCallback(async () => {
try {
await apiLogout();
} catch (error) {
@@ -128,66 +139,89 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} finally {
dispatch({ type: 'LOGOUT' });
}
};
}, [dispatch]);
// 인증 상태 확인 함수
const checkAuth = useCallback(async (key?: string) => {
const lsToken = localStorage.getItem('accessToken') || localStorage.getItem('access_token');
const cookieToken = getAccessTokenFromCookie();
let token = null;
// 1. 쿠키에 유효한 토큰이 있는 경우를 최상순위로 신뢰
if (cookieToken && isTokenValid(cookieToken)) {
token = cookieToken;
}
// 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) {
// 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)) {
dispatch({ type: 'LOGOUT' });
return;
}
// 5. 최종 인증 상태 업데이트
try {
const decodedToken = getUserFromToken(token);
if (decodedToken) {
const member: Member = {
memberId: decodedToken.memberId,
memberName: decodedToken.memberName
};
// key를 함께 전달하여 이 라우트의 검증이 완료되었음을 알림
dispatch({ type: 'MEMBER_LOADED', payload: member, key } as any);
} else {
dispatch({ type: 'AUTH_ERROR', payload: 'Invalid token structure' });
}
} catch (error) {
dispatch({ type: 'AUTH_ERROR', payload: 'Authentication sync failed' });
}
}, [dispatch]);
// 초기 인증 상태 확인
useEffect(() => {
const loadUser = async () => {
// localStorage 또는 Cookie에서 토큰 확인
let token = localStorage.getItem('accessToken') || localStorage.getItem('access_token') || getAccessTokenFromCookie();
if (!token || !isTokenValid(token)) {
// Access Token이 없거나 만료된 경우 Refresh Token 확인
let refreshToken = localStorage.getItem('refreshToken') || localStorage.getItem('refresh_token') || getRefreshTokenFromCookie();
// refreshToken으로 Access Token 갱신 시도
try {
const response = await renewAccessToken(refreshToken || '');
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);
}
}
} catch (error) {
// 갱신 실패 무시
}
}
if (!token || !isTokenValid(token)) {
dispatch({ type: 'LOGOUT' });
return;
}
try {
// 토큰이 존재하고 유효한 경우, localStorage와 동기화 (갱신 등으로 변경되었을 수 있음)
if (token && localStorage.getItem('accessToken') !== token) {
localStorage.setItem('accessToken', token);
}
const decodedToken = getUserFromToken(token!); // 위에서 유효성 검사 완료됨
if (decodedToken) {
const member: Member = {
memberId: decodedToken.memberId,
memberName: decodedToken.memberName
};
dispatch({ type: 'MEMBER_LOADED', payload: member });
} else {
dispatch({ type: 'AUTH_ERROR', payload: 'Invalid token' });
}
} catch (error) {
dispatch({ type: 'AUTH_ERROR', payload: 'Authentication failed' });
}
};
loadUser();
}, []);
checkAuth('initial');
}, [checkAuth]);
return (
<AuthContext.Provider value={{ state, dispatch, login, logout }}>
<AuthContext.Provider value={{ state, dispatch, login, logout, checkAuth }}>
{children}
</AuthContext.Provider>
);

View File

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