jwt 로그인 구현 진행 : axios 로 api 호출하여 생성된 쿠키로 인증상태값 변경기능 구현
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
18
src/_nav.tsx
18
src/_nav.tsx
@@ -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
83
src/axios/authService.ts
Normal 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
26
src/axios/axios.ts
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
144
src/context/AuthContext.tsx
Normal 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
12
src/hooks/useAuth.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
0
src/vite-env.d.ts
vendored
Normal 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user