-
React/Jwt 연동 (access token, refresh token)Language/React 2022. 8. 10. 23:34
https://github.com/dchkang83/project-board
redux-saga 및 axios도 활용해 보려다가 기본에 충실하고 너무 복잡하지 않기위해서 패스!! (이부분은 나중에라도 충분이 넣을수 있으니!)
- access token
탈취 위험이 있어 Redux를 이용하여 store에 저장
- refresh token :
유효기간까지 설정할수 있는 localstorage가 아닌 Cookie에 저장
- refresh token을 통하여 refresh token 및 accces token 갱신
새로고침이나 브라우저 이동시에는 refresh token 을 활용하여 refresh token 및 accces token 모두 갱신하는 방법으로 진행한다.
1. index.js & redux store & reducer
import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter as Router } from "react-router-dom"; import { Provider } from 'react-redux'; import store from '~/store' import App from '~/App' const root = ReactDOM.createRoot(document.getElementById("root")); root.render( <Provider store={store}> <Router> <App /> </Router> </Provider> );
import { configureStore } from "@reduxjs/toolkit"; import rootReducer from "~/store/slices/rootReducer"; const initialState = {}; const store = configureStore({ reducer: rootReducer, devTools: true, preloadedState: initialState, }); export default store;
import { combineReducers } from 'redux'; import { authReducer } from '~/store/slices/authSlice'; const rootReducer = combineReducers({ authReducer }); export default rootReducer;
2. app & router (public, private)
import React from 'react'; import RootRoutes from '~/routes'; const App = () => { return ( <React.Suspense> <RootRoutes /> </React.Suspense> ); } export default App;
// This is a React Router v6 app import { Routes, Route, Navigate } from "react-router-dom"; import Login from '~/views/Login'; import Home from '~/views/Home'; import Logout from '~/views/Logout'; import PublicRoute from './PublicRoute'; import PrivateRoute from './PrivateRoute'; const RootRoutes = () => ( <Routes> <Route element={<PublicRoute />}> <Route path="/login/" element={<Login />} /> </Route> <Route element={<PrivateRoute />}> <Route path="/" element={<Home />} /> <Route path="/logout" element={<Logout />} /> </Route> </Routes> ) export default RootRoutes;
const theme = createTheme(); export default function PublicRoute() { const location = useLocation(); const { isAuth } = CheckToken(location.key); if (isAuth === 'Success') { return ( <Navigate to="/" state={{ from: location }} /> ) } else if (isAuth === 'Loading') { return <Loading /> } return ( <ThemeProvider theme={theme}> <Container component="main" maxWidth="xs"> <Outlet /> </Container> </ThemeProvider> ) }
const theme = createTheme(); export default function PrivateRoute() { const location = useLocation(); const { isAuth } = CheckToken(location.key); if (isAuth === 'Failed') { return ( <Navigate to="/login" state={{ from: location }} /> ) } else if (isAuth === 'Loading') { return <Loading /> } return ( <ThemeProvider theme={theme}> <Container component="main" maxWidth="xs"> <Outlet /> </Container> </ThemeProvider> ) }
3. Views
function Home() { return( <div> Home </div> ); } export default Home
import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { useForm } from 'react-hook-form'; import Avatar from '@mui/material/Avatar'; import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; import FormControlLabel from '@mui/material/FormControlLabel'; import Checkbox from '@mui/material/Checkbox'; import Link from '@mui/material/Link'; import Grid from '@mui/material/Grid'; import Box from '@mui/material/Box'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; import Typography from '@mui/material/Typography'; import { setRefreshToken } from '~/utils/Cookie'; import { loginUser } from '~/api/Auth'; import { authActions } from '~/store/slices/authSlice'; function Copyright(props) { return ( <Typography variant="body2" color="text.secondary" align="center" {...props}> {'Copyright © '} <Link color="inherit" href="https://github.com/dchkang83"> Your Website </Link>{' '} {new Date().getFullYear()} {'.'} </Typography> ); } function Login() { const navigate = useNavigate(); const dispatch = useDispatch(); // useForm 사용을 위한 선언 const { register, setValue, formState: { errors }, handleSubmit } = useForm(); // submit 이후 동작할 코드 // 백으로 유저 정보 전달 const onValid = async ({ username, password }) => { const response = await loginUser({ username, password }); if (response.status) { console.log('response.jwtTokens : ', response.jwtTokens); // console.log('response.test : ', test); // 쿠키에 Refresh Token, store에 Access Token 저장 /* setRefreshToken(response.json11.refresh_token); dispatch(authActions.setAccessToken(response.json11.access_token)); */ dispatch(authActions.setAccessToken(response.jwtTokens.access_token)); setRefreshToken(response.jwtTokens.refresh_token); return navigate("/"); } else { console.log(response.json11); } // input 태그 값 비워주는 코드 setValue("password", ""); }; return ( <> <Box sx={{ marginTop: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', }} > <Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}> <LockOutlinedIcon /> </Avatar> <Typography component="h1" variant="h5"> Sign in </Typography> <Box component="form" onSubmit={handleSubmit(onValid)} noValidate sx={{ mt: 1 }}> <TextField {...register("username", { required: "Please Enter Your Email" })} label="Email Address" margin="normal" required fullWidth id="email" name="email" autoComplete="email" autoFocus value={"customer@naver.com"} /> <TextField {...register("password", { required: "Please Enter Your Password" })} label="Password" margin="normal" required fullWidth name="password" type="password" id="password" autoComplete="current-password" value={"test1234"} /> <FormControlLabel label="Remember me" control={<Checkbox value="remember" color="primary" />} /> <Button type="submit" fullWidth variant="contained" sx={{ mt: 3, mb: 2 }} >Sign in</Button> <Grid container> <Grid item xs> <Link href="#" variant="body2"> Forgot password? </Link> </Grid> <Grid item> <Link href="#" variant="body2"> {"Sign Up"} </Link> </Grid> </Grid> </Box> </Box> <Copyright sx={{ mt: 8, mb: 4 }} /> </> ); } export default Login;
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 '~/utils/Cookie'; import { authActions } from '~/store/slices/authSlice'; import { logoutUser } from '~/api/Auth'; function Logout() { const { accessToken } = useSelector(state => state.token); const dispatch = useDispatch(); const navigate = useNavigate(); const refreshToken = getCookieToken(); async function logout() { const data = await logoutUser({ refresh_token: refreshToken }, accessToken); if (data.status) { dispatch(authActions.delAccessToken()); removeCookieToken(); return navigate('/login'); } else { window.location.reload(); } } useEffect( () => { logout(); }, []) return ( <> <Link to="/login" /> </> ); } export default Logout;
4. APIS
const TIME_OUT = 300 * 1000; const statusError = { status: false, json: { error: ["연결이 원활하지 않습니다. 잠시 후 다시 시도해 주세요"] } }; const requestPromise = (url, option) => { return fetch(url, option); }; const timeoutPromise = () => { return new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), TIME_OUT)); }; const getPromise = async (url, option) => { return await Promise.race([ requestPromise(`http://localhost:8080${url}`, option), timeoutPromise() ]); }; export const loginUser = async (credentials) => { const option = { method: 'POST', headers: { // 'Content-Type': 'application/json;charset=UTF-8' 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }; const data = await getPromise('/login', 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 json11 = text.length ? JSON.parse(text) : ""; console.log('response.headers.authorization : ', data.headers.get('authorization')); for (let header of data.headers.entries()) { console.log(header); } const jwtTokens = { access_token: data.headers.get('authorization'), refresh_token: data.headers.get('refreshtoken'), }; return { status, code, json11, jwtTokens }; } else { return statusError; } }; export const logoutUser = async (credentials, accessToken) => { const option = { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8' }, body: JSON.stringify(credentials) }; const data = await getPromise('/logout-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; } } export const requestToken = async (refreshToken) => { const option = { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8', 'X-REFRESH-TOKEN': refreshToken }, // body: JSON.stringify({ refresh_token: refreshToken }) } const data = await getPromise('/api/v1/auth/refresh', 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) : ""; const jwtTokens = { access_token: data.headers.get('authorization'), refresh_token: data.headers.get('refreshtoken'), }; return { status, code, json, jwtTokens }; } else { return statusError; } };
import { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { getCookieToken, removeCookieToken } from '~/utils/Cookie'; import { requestToken } from '~/api/Auth'; import { setRefreshToken } from '~/utils/Cookie'; import { authActions } from '~/store/slices/authSlice'; export function CheckToken(key) { const [ isAuth, setIsAuth ] = useState('Loaded'); const { authenticated, accessToken, expireTime } = useSelector(state => state.authReducer); const refreshToken = getCookieToken(); const dispatch = useDispatch(); useEffect(() => { const checkAuthToken = async () => { if (refreshToken === undefined) { dispatch(authActions.delAccessToken()); setIsAuth('Failed'); } else { setIsAuth('Success'); if (authenticated && new Date().getTime() < expireTime) { setIsAuth('Success'); } else { const response = await requestToken(refreshToken); if (response.status) { const token = response.json.access_token; dispatch(authActions.setAccessToken(response.jwtTokens.access_token)); setRefreshToken(response.jwtTokens.refresh_token); setIsAuth('Success'); } else { dispatch(authActions.delAccessToken()); removeCookieToken(); setIsAuth('Failed'); } } } }; checkAuthToken(); }, [refreshToken, dispatch, key]); return { isAuth }; }
- back end는 spring boot + jwt 형태로 작업된 포스팅을 참조
참조)
- https://binaryjourney.tistory.com/pages/ReactCRUD-create-board-tutorial
'Language > React' 카테고리의 다른 글
React/AntV G2plot Chart 적용 (0) 2022.10.12 React/Swiper 적용 (0) 2022.10.12 React/nth-check - Dependabot cannot update nth-check to a non-vulnerable version (0) 2022.08.10 React/redux & react-redux & redux-saga & etc (0) 2022.08.04 React/router & Layout (0) 2022.08.04