React JWT Authentication

Error Coder·2023년 1월 23일
0

JWT

JSON 웹 토큰은 선택적 서명 및 선택적 암호화를 사용하여 데이터를 만들기 위한 인터넷 표준으로,
페이로드는 몇몇 클레임 표명을 처리하는 JSON을 보관하고 있다.
토큰은 비공개 시크릿 키 또는 공개/비공개 키를 사용하여 서명된다.
참고
쉽게 말해 정보 전달 및 권한 인가(Authorization)을 위해 사용되는 JSON 형태의 웹 토큰이다.

Refresh Token & Access Token

  • Access Token : 실질적인 인증을 위한 JWT로 유효기간이 매우 짧은 특징을 가지고 있다.
  • Refresh Token : Access Token의 짧은 유효기간을 보완하기 위해 사용되며, 본 토큰을 사용해 Access Token 만료 시 재발급을 위해 사용된다.

프론트에서 JWT 인증 구현하기

개발 환경

Django 백엔드와 연계하여 프론트에서 로그인 후 JWT를 이용해 사용자 인증을 구현했다.
순서는 다음과 같다.

  1. 프론트에서 로그인 시도
  2. 유저 정보가 올바르다면 백에서 JWT 발급
  3. 발급 받은 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를 비워 두었다.

출처 : https://5xjin.github.io/blog/react_jwt_router/

profile
개발자 지망생

0개의 댓글