JSON 웹 토큰은 선택적 서명 및 선택적 암호화를 사용하여 데이터를 만들기 위한 인터넷 표준으로,
페이로드는 몇몇 클레임 표명을 처리하는 JSON을 보관하고 있다.
토큰은 비공개 시크릿 키 또는 공개/비공개 키를 사용하여 서명된다.
참고
쉽게 말해 정보 전달 및 권한 인가(Authorization)을 위해 사용되는 JSON 형태의 웹 토큰이다.
Django 백엔드와 연계하여 프론트에서 로그인 후 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
./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 삭제를 위한 함수. 로그아웃 시 사용할 예정이다.
./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 를 선언해 준다.
./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를 비워 두었다.