페이지 라우팅 할 때마다 access token (저장소/쿠키) 확인기능 추가
This commit is contained in:
19
src/App.tsx
19
src/App.tsx
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
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" />}>
|
||||
{isVerifying ? (
|
||||
<div className="pt-3 text-center">
|
||||
<CSpinner color="primary" variant="grow" />
|
||||
</div>
|
||||
) : (
|
||||
<Routes>
|
||||
{routes.map((route, idx) => {
|
||||
return (
|
||||
@@ -24,6 +37,7 @@ const AppContent = () => {
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/login" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
)}
|
||||
</Suspense>
|
||||
</CContainer>
|
||||
)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// 초기 인증 상태 확인
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
// localStorage 또는 Cookie에서 토큰 확인
|
||||
let token = localStorage.getItem('accessToken') || localStorage.getItem('access_token') || getAccessTokenFromCookie();
|
||||
// 인증 상태 확인 함수
|
||||
const checkAuth = useCallback(async (key?: string) => {
|
||||
const lsToken = localStorage.getItem('accessToken') || localStorage.getItem('access_token');
|
||||
const cookieToken = getAccessTokenFromCookie();
|
||||
|
||||
if (!token || !isTokenValid(token)) {
|
||||
// Access Token이 없거나 만료된 경우 Refresh Token 확인
|
||||
let refreshToken = localStorage.getItem('refreshToken') || localStorage.getItem('refresh_token') || getRefreshTokenFromCookie();
|
||||
let token = null;
|
||||
|
||||
// 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);
|
||||
// 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 {
|
||||
// 토큰이 존재하고 유효한 경우, localStorage와 동기화 (갱신 등으로 변경되었을 수 있음)
|
||||
if (token && localStorage.getItem('accessToken') !== token) {
|
||||
localStorage.setItem('accessToken', token);
|
||||
}
|
||||
|
||||
const decodedToken = getUserFromToken(token!); // 위에서 유효성 검사 완료됨
|
||||
const decodedToken = getUserFromToken(token);
|
||||
if (decodedToken) {
|
||||
const member: Member = {
|
||||
memberId: decodedToken.memberId,
|
||||
memberName: decodedToken.memberName
|
||||
};
|
||||
dispatch({ type: 'MEMBER_LOADED', payload: member });
|
||||
|
||||
// key를 함께 전달하여 이 라우트의 검증이 완료되었음을 알림
|
||||
dispatch({ type: 'MEMBER_LOADED', payload: member, key } as any);
|
||||
} else {
|
||||
dispatch({ type: 'AUTH_ERROR', payload: 'Invalid token' });
|
||||
dispatch({ type: 'AUTH_ERROR', payload: 'Invalid token structure' });
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({ type: 'AUTH_ERROR', payload: 'Authentication failed' });
|
||||
dispatch({ type: 'AUTH_ERROR', payload: 'Authentication sync failed' });
|
||||
}
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
// 초기 인증 상태 확인
|
||||
useEffect(() => {
|
||||
checkAuth('initial');
|
||||
}, [checkAuth]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, dispatch, login, logout }}>
|
||||
<AuthContext.Provider value={{ state, dispatch, login, logout, checkAuth }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user