jwt 로그인 구현 진행 : axios 로 api 호출하여 생성된 쿠키로 인증상태값 변경기능 구현
This commit is contained in:
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
|
||||
|
||||
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 { AppHeaderDropdown } from './header/index'
|
||||
import { useAuth } from 'src/hooks/useAuth'
|
||||
|
||||
const AppHeader = () => {
|
||||
const headerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -35,6 +36,8 @@ const AppHeader = () => {
|
||||
|
||||
const dispatch = useDispatch()
|
||||
const sidebarShow = useSelector((state: RootState) => state.sidebarShow)
|
||||
const { state } = useAuth()
|
||||
const { isAuthenticated, member } = state
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -61,6 +64,11 @@ const AppHeader = () => {
|
||||
<CHeaderNav className="ms-auto">
|
||||
<CNavItem>
|
||||
<CNavLink href="#">
|
||||
{isAuthenticated && member && (
|
||||
<span className="me-2 text-body-secondary fw-semibold">
|
||||
{member.memberId}
|
||||
</span>
|
||||
)}
|
||||
<CIcon icon={cilBell} size="lg" />
|
||||
</CNavLink>
|
||||
</CNavItem>
|
||||
|
||||
@@ -27,10 +27,10 @@ import avatar8 from './../../assets/images/avatars/8.jpg'
|
||||
const AppHeaderDropdown = () => {
|
||||
return (
|
||||
<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" />
|
||||
</CDropdownToggle>
|
||||
<CDropdownMenu className="pt-0" placement="bottom-end">
|
||||
<CDropdownMenu className="pt-0">
|
||||
<CDropdownHeader className="bg-body-secondary fw-semibold mb-2">Account</CDropdownHeader>
|
||||
<CDropdownItem href="#">
|
||||
<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 store from './store'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</Provider>,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
CButton,
|
||||
CCard,
|
||||
@@ -15,8 +15,53 @@ import {
|
||||
} from '@coreui/react'
|
||||
import CIcon from '@coreui/icons-react'
|
||||
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 [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 (
|
||||
<div className="bg-body-tertiary min-vh-100 d-flex flex-row align-items-center">
|
||||
<CContainer>
|
||||
@@ -25,14 +70,20 @@ const Login = () => {
|
||||
<CCardGroup>
|
||||
<CCard className="p-4">
|
||||
<CCardBody>
|
||||
<CForm>
|
||||
<CForm onSubmit={handleSubmit}>
|
||||
<h1>Login</h1>
|
||||
<p className="text-body-secondary">Sign In to your account</p>
|
||||
{error && <div className="text-danger mb-3">{error}</div>}
|
||||
<CInputGroup className="mb-3">
|
||||
<CInputGroupText>
|
||||
<CIcon icon={cilUser} />
|
||||
</CInputGroupText>
|
||||
<CFormInput placeholder="Username" autoComplete="username" />
|
||||
<CFormInput
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
value={memberId}
|
||||
onChange={(e) => setMemberId(e.target.value)}
|
||||
/>
|
||||
</CInputGroup>
|
||||
<CInputGroup className="mb-4">
|
||||
<CInputGroupText>
|
||||
@@ -42,12 +93,14 @@ const Login = () => {
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</CInputGroup>
|
||||
<CRow>
|
||||
<CCol xs={6}>
|
||||
<CButton color="primary" className="px-4">
|
||||
Login
|
||||
<CButton color="primary" className="px-4" type="submit" disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</CButton>
|
||||
</CCol>
|
||||
<CCol xs={6} className="text-right">
|
||||
|
||||
0
src/vite-env.d.ts
vendored
Normal file
0
src/vite-env.d.ts
vendored
Normal file
Reference in New Issue
Block a user