jwt 로그인 구현 진행 : axios 로 api 호출하여 생성된 쿠키로 인증상태값 변경기능 구현

This commit is contained in:
2025-12-31 19:39:42 +09:00
parent fd40ce5e75
commit 985ba75d34
12 changed files with 340 additions and 35 deletions

View File

@@ -33,9 +33,11 @@
"@types/prop-types": "^15.7.15", "@types/prop-types": "^15.7.15",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"core-js": "^3.47.0", "core-js": "^3.47.0",
"jwt-decode": "^4.0.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",

View File

@@ -76,24 +76,6 @@ const _nav = [
}, },
], ],
}, },
{
component: CNavItem,
name: 'Demo',
href: 'https://coreui.io/demos/react/5.5/free/?theme=light#/dashboard',
icon: <CIcon icon={cilBrowser} customClassName="nav-icon" />,
},
{
component: CNavItem,
name: 'Docs',
href: 'https://coreui.io/react/docs/templates/installation/',
icon: <CIcon icon={cilDescription} customClassName="nav-icon" />,
},
{
component: CNavItem,
name: 'Src',
href: 'https://github.com/coreui/coreui-free-react-admin-template',
icon: <CIcon icon={cilBoatAlt} customClassName="nav-icon" />,
},
] ]
export default _nav export default _nav

83
src/axios/authService.ts Normal file
View File

@@ -0,0 +1,83 @@
import axios from './axios';
import { jwtDecode } from 'jwt-decode';
export const getAccessTokenFromCookie = (): string | null => {
const name = 'access_token';
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
export interface LoginCredentials {
memberId: string;
password: string;
}
export interface RegisterData {
username: string;
email: string;
password: string;
}
export interface AuthResponse {
resultCode: string;
resultMessage: string;
resultData: string;
}
export interface DecodedToken {
encryptedPayload: string;
memberId: string;
memberName: string;
expiration: number;
}
// 로그인 API
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
const response = await axios.post<AuthResponse>('/auth/login', credentials);
return response.data;
};
// 회원가입 API
export const register = async (data: RegisterData): Promise<{ message: string }> => {
const response = await axios.post<{ message: string }>('/auth/register', data);
return response.data;
};
// 로그아웃 API
export const logout = async (): Promise<void> => {
await axios.post('/auth/logout');
//localStorage.removeItem('access_token');
//localStorage.removeItem('refresh_token');
document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/';
document.cookie = 'refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/';
};
// 토큰이 유효한지 확인
export const isTokenValid = (token: string): boolean => {
try {
const decoded = jwtDecode<DecodedToken>(token);
const currentTime = Date.now() / 1000;
return decoded.expiration > currentTime;
} catch (error) {
return false;
}
};
// 토큰에서 사용자 정보 추출
export const getUserFromToken = (token: string): DecodedToken | null => {
try {
const decoded = jwtDecode<DecodedToken>(token);
return decoded;
} catch (error) {
return null;
}
};
// 현재 사용자 정보 가져오기
export const getCurrentUser = async () => {
const response = await axios.get('/auth/me');
return response.data;
};

26
src/axios/axios.ts Normal file
View File

@@ -0,0 +1,26 @@
import axios from 'axios';
const API_URL = '/api/v1/';
// Axios 인스턴스 생성
const instance = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 요청 인터셉터
instance.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
export default instance;

View File

@@ -28,6 +28,7 @@ import {
import { AppBreadcrumb } from './index' import { AppBreadcrumb } from './index'
import { AppHeaderDropdown } from './header/index' import { AppHeaderDropdown } from './header/index'
import { useAuth } from 'src/hooks/useAuth'
const AppHeader = () => { const AppHeader = () => {
const headerRef = useRef<HTMLDivElement>(null) const headerRef = useRef<HTMLDivElement>(null)
@@ -35,6 +36,8 @@ const AppHeader = () => {
const dispatch = useDispatch() const dispatch = useDispatch()
const sidebarShow = useSelector((state: RootState) => state.sidebarShow) const sidebarShow = useSelector((state: RootState) => state.sidebarShow)
const { state } = useAuth()
const { isAuthenticated, member } = state
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@@ -61,6 +64,11 @@ const AppHeader = () => {
<CHeaderNav className="ms-auto"> <CHeaderNav className="ms-auto">
<CNavItem> <CNavItem>
<CNavLink href="#"> <CNavLink href="#">
{isAuthenticated && member && (
<span className="me-2 text-body-secondary fw-semibold">
{member.memberId}
</span>
)}
<CIcon icon={cilBell} size="lg" /> <CIcon icon={cilBell} size="lg" />
</CNavLink> </CNavLink>
</CNavItem> </CNavItem>

View File

@@ -27,10 +27,10 @@ import avatar8 from './../../assets/images/avatars/8.jpg'
const AppHeaderDropdown = () => { const AppHeaderDropdown = () => {
return ( return (
<CDropdown variant="nav-item"> <CDropdown variant="nav-item">
<CDropdownToggle placement="bottom-end" className="py-0 pe-0" caret={false}> <CDropdownToggle className="py-0 pe-0" caret={false}>
<CAvatar src={avatar8} size="md" /> <CAvatar src={avatar8} size="md" />
</CDropdownToggle> </CDropdownToggle>
<CDropdownMenu className="pt-0" placement="bottom-end"> <CDropdownMenu className="pt-0">
<CDropdownHeader className="bg-body-secondary fw-semibold mb-2">Account</CDropdownHeader> <CDropdownHeader className="bg-body-secondary fw-semibold mb-2">Account</CDropdownHeader>
<CDropdownItem href="#"> <CDropdownItem href="#">
<CIcon icon={cilBell} className="me-2" /> <CIcon icon={cilBell} className="me-2" />

144
src/context/AuthContext.tsx Normal file
View File

@@ -0,0 +1,144 @@
import React, { createContext, useReducer, useEffect } from 'react';
import { getCurrentUser, isTokenValid } from 'src/axios/authService';
// 사용자 타입 정의
export interface Member {
memberId: string;
memberName: string;
}
// 인증 상태 타입 정의
interface AuthState {
isAuthenticated: boolean;
member: Member | null;
loading: boolean;
error: string | null;
}
// 액션 타입 정의
type AuthAction =
| { type: 'LOGIN_SUCCESS'; payload: { member: Member; token: string } }
| { type: 'LOGOUT' }
| { type: 'AUTH_ERROR'; payload: string }
| { type: 'CLEAR_ERROR' }
| { type: 'SET_LOADING' }
| { type: 'MEMBER_LOADED'; payload: Member };
// 초기 상태
const initialState: AuthState = {
isAuthenticated: false,
member: null,
loading: true,
error: null
};
// Context 생성
export const AuthContext = createContext<{
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
login: (token: string, member: Member) => void;
logout: () => void;
}>({
state: initialState,
dispatch: () => null,
login: () => null,
logout: () => null
});
// 리듀서 함수
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
switch (action.type) {
case 'LOGIN_SUCCESS':
localStorage.setItem('accessToken', action.payload.token);
return {
...state,
isAuthenticated: true,
member: action.payload.member,
loading: false,
error: null
};
case 'LOGOUT':
localStorage.removeItem('accessToken');
return {
...state,
isAuthenticated: false,
member: null,
loading: false,
error: null
};
case 'AUTH_ERROR':
localStorage.removeItem('accessToken');
return {
...state,
isAuthenticated: false,
member: null,
loading: false,
error: action.payload
};
case 'CLEAR_ERROR':
return {
...state,
error: null
};
case 'SET_LOADING':
return {
...state,
loading: true
};
case 'MEMBER_LOADED':
return {
...state,
isAuthenticated: true,
member: action.payload,
loading: false
};
default:
return state;
}
};
// Provider 컴포넌트
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
// 로그인 함수
const login = (token: string, member: Member) => {
dispatch({
type: 'LOGIN_SUCCESS',
payload: { token, member }
});
};
// 로그아웃 함수
const logout = () => {
dispatch({ type: 'LOGOUT' });
};
// 초기 인증 상태 확인
useEffect(() => {
const loadUser = async () => {
const token = localStorage.getItem('accessToken');
if (!token || !isTokenValid(token)) {
dispatch({ type: 'LOGOUT' });
return;
}
try {
const member = await getCurrentUser();
dispatch({ type: 'MEMBER_LOADED', payload: member });
} catch (error) {
dispatch({ type: 'AUTH_ERROR', payload: 'Authentication failed' });
}
};
loadUser();
}, []);
return (
<AuthContext.Provider value={{ state, dispatch, login, logout }}>
{children}
</AuthContext.Provider>
);
};

12
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useContext } from 'react';
import { AuthContext } from '../context/AuthContext';
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -5,9 +5,12 @@ import 'core-js'
import App from './App' import App from './App'
import store from './store' import store from './store'
import { AuthProvider } from './context/AuthContext'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<Provider store={store}> <Provider store={store}>
<AuthProvider>
<App /> <App />
</AuthProvider>
</Provider>, </Provider>,
) )

View File

@@ -1,5 +1,5 @@
import React from 'react' import React, { useState } from 'react'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { import {
CButton, CButton,
CCard, CCard,
@@ -15,8 +15,53 @@ import {
} from '@coreui/react' } from '@coreui/react'
import CIcon from '@coreui/icons-react' import CIcon from '@coreui/icons-react'
import { cilLockLocked, cilUser } from '@coreui/icons' import { cilLockLocked, cilUser } from '@coreui/icons'
import { login as apiLogin, DecodedToken, getAccessTokenFromCookie } from 'src/axios/authService';
import { useAuth } from 'src/hooks/useAuth';
import { jwtDecode } from 'jwt-decode';
const Login = () => { const Login = () => {
const [memberId, setMemberId] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await apiLogin({ memberId, password });
if (response.resultCode === '200') {
const accessToken = getAccessTokenFromCookie();
if (accessToken) {
const decodedToken = jwtDecode<DecodedToken>(accessToken);
const member = {
memberId: decodedToken.memberId,
memberName: decodedToken.memberName,
};
login(accessToken, member);
navigate('/dashboard');
} else {
setError('쿠키에서 accessToken을 찾을 수 없습니다.');
}
} else {
setError(response.resultMessage || '로그인에 실패했습니다.');
}
} catch (err: any) {
setError(err.response?.data?.message || '로그인 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
return ( return (
<div className="bg-body-tertiary min-vh-100 d-flex flex-row align-items-center"> <div className="bg-body-tertiary min-vh-100 d-flex flex-row align-items-center">
<CContainer> <CContainer>
@@ -25,14 +70,20 @@ const Login = () => {
<CCardGroup> <CCardGroup>
<CCard className="p-4"> <CCard className="p-4">
<CCardBody> <CCardBody>
<CForm> <CForm onSubmit={handleSubmit}>
<h1>Login</h1> <h1>Login</h1>
<p className="text-body-secondary">Sign In to your account</p> <p className="text-body-secondary">Sign In to your account</p>
{error && <div className="text-danger mb-3">{error}</div>}
<CInputGroup className="mb-3"> <CInputGroup className="mb-3">
<CInputGroupText> <CInputGroupText>
<CIcon icon={cilUser} /> <CIcon icon={cilUser} />
</CInputGroupText> </CInputGroupText>
<CFormInput placeholder="Username" autoComplete="username" /> <CFormInput
placeholder="Username"
autoComplete="username"
value={memberId}
onChange={(e) => setMemberId(e.target.value)}
/>
</CInputGroup> </CInputGroup>
<CInputGroup className="mb-4"> <CInputGroup className="mb-4">
<CInputGroupText> <CInputGroupText>
@@ -42,12 +93,14 @@ const Login = () => {
type="password" type="password"
placeholder="Password" placeholder="Password"
autoComplete="current-password" autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/> />
</CInputGroup> </CInputGroup>
<CRow> <CRow>
<CCol xs={6}> <CCol xs={6}>
<CButton color="primary" className="px-4"> <CButton color="primary" className="px-4" type="submit" disabled={loading}>
Login {loading ? 'Logging in...' : 'Login'}
</CButton> </CButton>
</CCol> </CCol>
<CCol xs={6} className="text-right"> <CCol xs={6} className="text-right">

0
src/vite-env.d.ts vendored Normal file
View File

View File

@@ -9,14 +9,6 @@
// Stricter Typechecking Options // Stricter Typechecking Options
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"verbatimModuleSyntax": false, "verbatimModuleSyntax": false,
"isolatedModules": true, "isolatedModules": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,