JWT
JSON 웹 토큰은 선택적 서명 및 선택적 암호화를 사용하여 데이터를 만들기 위한 인터넷 표준으로,
페이로드는 몇몇 클레임 표명을 처리하는 JSON을 보관하고 있다.
토큰은 비공개 시크릿 키 또는 공개/비공개 키를 사용하여 서명된다.
참고
쉽게 말해 정보 전달 및 권한 인가(Authorization)을 위해 사용되는 JSON 형태의 웹 토큰이다.
Refresh Token & Access Token
- Access Token : 실질적인 인증을 위한 JWT로
유효기간이 매우 짧은 특징
을 가지고 있다. - Refresh Token : Access Token의 짧은 유효기간을 보완하기 위해 사용되며, 본 토큰을 사용해
Access Token 만료 시 재발급
을 위해 사용된다.
프론트에서 JWT 인증 구현하기
개발 환경
Django 백엔드
와 연계하여 프론트에서 로그인 후 JWT를 이용해 사용자 인증을 구현했다.
순서는 다음과 같다.
- 프론트에서 로그인 시도
- 유저 정보가 올바르다면 백에서 JWT 발급
- 발급 받은 JWT 를 브라우저 및 Redux 에 저장하여 백과의 통신 시 사용
JWT 저장소 만들기
Refresh Token은 브라우저 저장소(Cookie)
에, Access Token은 Redux를 이용하여 store
에 저장하여 사용할 예정이다.
Access Token의 경우 탈취의 위험
이 있기 때문에 브라우저 저장소가 아닌 store에 저장하기로 했다. 브라우저를 새로고침 할 때마다 값이 초기화되는 불편함이 있지만, Refresh Token을 이용해 재발급을 받으면 되니 문제는 되지 않는다.
Refresh Token의 경우 로컬 스토리지 - 세션 스토리지 - 쿠키
사이에서 많은 고민을 했다. 사용하기 편한 것은 스토리지에 저장하는 것인데 두 스토리지 모두 XSS 공격에 취약
한 단점이 있기 때문에 쿠키에 저장하기로 결정했다.
React에서 Cookie와 Redux를 사용하기 위해서는 다음의 설치가 필요하다.
# npm install react-cookie
# npm i redux react-redux @reduxjs/toolkit
Refresh Token 저장소
./src/storage/Cookie.js
import { Cookies } from 'react-cookie';
const cookies = new Cookies();
export const setRefreshToken = (refreshToken) => {
const today = new Date();
const expireDate = today.setDate(today.getDate() + 7);
return cookies.set('refresh_token', refreshToken, {
sameSite: 'strict',
path: "/",
expires: new Date(expireDate)
});
};
export const getCookieToken = () => {
return cookies.get('refresh_token');
};
export const removeCookieToken = () => {
return cookies.remove('refresh_token', { sameSite: 'strict', path: "/" })
}
setRefreshToken
: Refresh Token을 Cookie에 저장하기 위한 함수getCookieToken
: Cookie에 저장된 Refresh Token 값을 갖고 오기 위한 함수.removeCookieToken
: Cookie 삭제를 위한 함수.로그아웃
시 사용할 예정이다.
Access Token
./src/store/Auth.js
import { createSlice } from '@reduxjs/toolkit';
export const TOKEN_TIME_OUT = 600*1000;
export const tokenSlice = createSlice({
name: 'authToken',
initialState: {
authenticated: false,
accessToken: null,
expireTime: null
},
reducers: {
SET_TOKEN: (state, action) => {
state.authenticated = true;
state.accessToken = action.payload;
state.expireTime = new Date().getTime() + TOKEN_TIME_OUT;
},
DELETE_TOKEN: (state) => {
state.authenticated = false;
state.accessToken = null;
state.expireTime = null
},
}
})
export const { SET_TOKEN, DELETE_TOKEN } = tokenSlice.actions;
export default tokenSlice.reducer;
-
createSlice
를 이용하여 간단하게 redux 액션 생성자와 전체 슬라이스에 대한 reducer를 선언하여 사용할 수 있다. -
authenticated
: 현재 로그인 여부를 간단히 확인하기 위해 선언. -
accessToken
: Access Token 저장. -
expireTime
: Access Token 의 만료 시간 -
SET_TOKEN
: Access Token 정보를 저장한다. -
DELETE_TOKEN
: 값을 모두 초기화함으로써 Access Token에 대한 정보도 삭제한다.
./src/Store/index.js
import { configureStore } from '@reduxjs/toolkit';
import tokenReducer from './Auth';
export default configureStore({
reducer: {
authToken: tokenReducer,
},
});
- 위에서 선언한 reducer를 사용하기 위해
configureStore
를 선언해 준다.
저장소 사용을 위한 index.js 수정
./src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import store from './Store';
import { Provider } from 'react-redux';
import { CookiesProvider } from 'react-cookie';
ReactDOM.render(
<CookiesProvider>
<Provider store={store}>
<App />
</Provider>
</CookiesProvider>,
document.getElementById('root')
);
CookiesProvider
와Provider
선언으로 이제 Cookie와 Redux를 사용할 수 있다.
기본 컴포넌트 생성
./src/App.js
//
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from './pages/Home';
import Login from './pages/Login';
import Logout from './pages/Logout';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/logout" element={<Logout />} />
</Routes>
</Router>
);
}
export default App;
Route
를 사용할 경우 path 설정 및 해당 라우트에 대한 컴포넌트를element
에 선언해 준다.
./src/pages/Home.js
function Home() {
return(
<div>
Home
</div>
);
}
export default Home
./src/pages/Login.js
function Login() {
return(
<div>
Login
</div>
);
}
export default Login
./src/pages/Logout.js
function Logout() {
return(
<div>
Logout
</div>
);
}
export default Logout
로그인 기능 구현
로그인 정보 통신
./src/api/Users.js
// promise 요청 타임아웃 시간 선언
const TIME_OUT = 300*1000;
// 에러 처리를 위한 status 선언
const statusError = {
status: false,
json: {
error: ["연결이 원활하지 않습니다. 잠시 후 다시 시도해 주세요"]
}
};
// 백으로 요청할 promis
const requestPromise = (url, option) => {
return fetch(url, option);
};
// promise 타임아웃 처리
const timeoutPromise = () => {
return new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), TIME_OUT));
};
// promise 요청
const getPromise = async (url, option) => {
return await Promise.race([
requestPromise(url, option),
timeoutPromise()
]);
};
// 백으로 로그인 요청
export const loginUser = async (credentials) => {
const option = {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
body: JSON.stringify(credentials)
};
const data = await getPromise('/login-url', option).catch(() => {
return statusError;
});
if (parseInt(Number(data.status)/100)===2) {
const status = data.ok;
const code = data.status;
const text = await data.text();
const json = text.length ? JSON.parse(text) : "";
return {
status,
code,
json
};
} else {
return statusError;
}
};
loginUser
: 백으로 유저 정보와 함께 로그인 요청을 보낸다. 받은 응답 코드에 따라 에러 또는 응답 받은 json 정보를 리턴한다.getPromise
,requestPromise
: 실질적으로 백으로 로그인 요청을 보내는 함수timeoutPromise
: aixos를 사용할 경우 타임아웃을 지정할 수 있으나, fetch의 경우 타임아웃 에러처리를 따로 해 주어야 한다. 이를 위한 함수. (추후에 자세히 포스팅 예정)
로그인 컴포넌트
./src/pages/Login.js
을 다음과 같이 바꾸어 준다.
import { useNavigate } from 'react-router';
import { useDispatch } from 'react-redux';
import { useForm } from 'react-hook-form';
import { HiLockClosed } from 'react-icons/hi'
import { ErrorMessage } from '@hookform/error-message';
import { loginUser } from '../api/Users';
import { setRefreshToken } from '../storage/Cookie';
import { SET_TOKEN } from '../store/Auth';
function Login() {
const navigate = useNavigate();
const dispatch = useDispatch();
// useForm 사용을 위한 선언
const { register, setValue, formState: { errors }, handleSubmit } = useForm();
// submit 이후 동작할 코드
// 백으로 유저 정보 전달
const onValid = async ({ userid, password }) => {
// input 태그 값 비워주는 코드
setValue("password", "");
// 백으로부터 받은 응답
const response = await loginUser({ userid, password });
if (response.status) {
// 쿠키에 Refresh Token, store에 Access Token 저장
setRefreshToken(response.json.refresh_token);
dispatch(SET_TOKEN(response.json.access_token));
return navigate("/");
} else {
console.log(response.json);
}
};
return(
<div>
...
</div>
);
}
export default Login;
- 로그인 페이지는 폼으로 구현했기 때문에
useForm
훅을 사용했다. useForm 참고 onValid
: useForm 훅 사용을 위해 제출된 폼 값의 유효성을 확인 및 동작을 처리한다.- 정상적인 응답이 왔을 경우
setRefreshToken
을 통해 Refresh Token을 쿠키에 저장,dispatch()
를 통해 Access Token을 store에 저장한다. - Cookie와 store에 데이터를 모두 저장한 이후 홈으로 이동한다.
로그아웃 기능 구현
로그아웃 정보 통신
./src/api/Users.js
에 다음의 코드를 추가해 준다.
export const requestToken = async (refreshToken) => {
const option = {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
body: JSON.stringify({ refresh_token: refreshToken })
}
const data = await getPromise('/login-url', option).catch(() => {
return statusError;
});
if (parseInt(Number(data.status)/100)===2) {
const status = data.ok;
const code = data.status;
const text = await data.text();
const json = text.length ? JSON.parse(text) : "";
return {
status,
code,
json
};
} else {
return statusError;
}
};
로그아웃 컴포넌트
./src/pages/Logout.js
을 다음과 같이 바꾸어 준다.
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { getCookieToken, removeCookieToken } from '../storage/Cookie';
import { DELETE_TOKEN } from '../store/Auth';
import { logoutUser } from '../api/Users';
function Logout(){
// store에 저장된 Access Token 정보를 받아 온다
const { accessToken } = useSelector(state => state.token);
const dispatch = useDispatch();
const navigate = useNavigate();
// Cookie에 저장된 Refresh Token 정보를 받아 온다
const refreshToken = getCookieToken();
async function logout() {
// 백으로부터 받은 응답
const data = await logoutUser({ refresh_token: refreshToken }, accessToken);
if (data.status) {
// store에 저장된 Access Token 정보를 삭제
dispatch(DELETE_TOKEN());
// Cookie에 저장된 Refresh Token 정보를 삭제
removeCookieToken();
return navigate('/');
} else {
window.location.reload();
}
}
// 해당 컴포넌트가 요청된 후 한 번만 실행되면 되기 때문에 useEffect 훅을 사용
useEffect( () => {
logout();
}, [])
return (
<>
<Link to="/" />
</>
);
}
export default Logout;
- 정상적인 응답이 왔을 경우
removeCookieToken
을 통해 Cookie에 저장된 Refresh Token 정보와dispatch()
를 통해 store에 저장된 Access Token 정보를 모두 삭제한다 - Cookie와 store에서 데이터를 모두 삭제한 후 홈으로 이동한다.
- 로그아웃에 대한 요청은 해당 컴포넌트 요청 후 한 번만 실행되면 되기 때문에 useEffect 훅을 사용했으며, deps를 비워 두었다.