본문 바로가기
React

보안과 사용성을 고려한 JWT 다루기 (React, Express)

by Luke K 2022. 11. 13.

이 글은 Web Front End에서 jwt를 다루는 것을 총정리한다.

많은 시행착오를 겪고 풀스택으로 JWT 관련 로직들을 개발한 경험과 실제 오픈 후 사용되고 있는 서비스를 운영해본 경험을 합쳐 2022-11-11의 필자가 생각하고 사용하고 있는 jwt 관리법을 다룬다.

또 이 글에서는 필자가 생각하기에 간단하게 accessToken을 refresh하는 방법을 다루고 바로 복사해서 사용 가능할만한 코드를 포함한다.

제일 먼저 생각해야할 것은 JWT의 역할

세션에 저장하는 방식이 아닌 JWT를 로그인 전략으로 활용한다면 이는 결국 서버에 저장하는 것 없이 클라이언트 사이드에 인증관련 정보를 넘겨버리겠다는 것이다.

이로 인해 서버는 세션에 데이터를 따로 저장하거나 할 필요없이 토큰을 만들어 클라이언트로 던져줘도 돼서 인증 서버를 여러개 가져도 된다. (1번서버 메모리에 저장한다면 2번 서버는 인증 정보를 알 수 없다.)

즉 토큰들 자체가 인증수단이므로 우리는 토큰이 탈취당하지 않도록 해야한다.

XSS공격과 CSRF 공격을 막아보자. 

조심해야할 공격은 크게 두 가지다. 

 

  • XSS (JS를 몰래 삽입해서 정보 빼가기)
  • CSRF (해커가 게시물에 악성 코드를 넣어놔서 게시물을 누르거나 사진을 열거나 할 때 의도치 않은 이벤트가 발생하는 것)

XSS를 막기 위해서는 브라우저에서 접근 가능한 곳에 토큰을 두면 안된다.

즉 브라우저에서 Window.localStorageWindow.sessionStorage로 전역(window)에서 접근이 가능한 모든 곳에는 토큰을 놓지 않는 것이 좋다.

여기서 잠깐 다른 얘기를 해보자.

Next.js 등을 사용해서 ssr할 때 토큰 관리에서 브라우저 storage를 사용한다면 서버 사이드에는 browser 스토리지가 없기 때문에 문제가 생긴다.

하지만 이렇게 브라우저와 의존성이 있는 storage를 사용하지 않는다면 자연스럽게 서버 사이드에서도 토큰을 가지고 있을 수 있다.

refreshToken은 cookie에 httpOnly, secure 옵션을 붙여 서버만 접근하도록 하자.

그러면 클라이언트에서는 refreshToken에 접근할 수 없다.

 

CSRF 공격을 막기 위해서는 accessToken을 특정 요청을 통해 보낼 수 없게 만들어야한다.

 

이유부터 설명하자면 옥션이 당한 CSRF 공격의 예시를 들어야한다. (옥션 또 털려)

옥션 관리자가 당했던 csrf 공격은 이런 것이다.

<img src="http://auction.com/changeAccout?id=hacker&password=hacker" />

이렇게 생긴 이미지를 관리자의 이메일에 첨부해서 보낸다.

이런 이미지 태그를 관리자의 메일로 보낸 후 관리자는 이메일을 확인하다보니 저 img의 src를 불러오면서 id password가 hacker의 id와 password로 바뀌어버린 것이다.

옥션이 당했던 이유를 짐작해보면 관리자가 로그인해있던 상태이기에 쿠키에 정보를 가지고 있어 저 요청이 성공했을 것이라고 생각해볼 수 있다. (로그인하고 박힌 쿠키는 메일 확인에도 보내진다.)

이를 방어하기 위해 JWT 관점에서 하면 도움이 될 일은 세 가지 정도가 있다.

1. 실제 동작해야할 client origin 말고는 요청을 보낼 때 cors error를 띄운다.

2. 서버가 accessToken을 처리하는 것은 body나 query string 즉 url 뒤에 붙는 ?accessToken='1234'로 하지 않는다.

3. 또 보안 관련 우려가 되는 API들을 get 요청 외의 요청을 사용한다. (ex: post)

즉 여기까지의 논리를 적용하면 로그인 전략은 이렇게 구성된다.

1. 사용자가 화면을 이용하여 로그인한다.
2. 서버는 클라이언트에서 받은 사용자 정보를 확인하고 accessToken과 refreshToken을 만든다.
3. 서버는 refreshToken을 httpOnly 옵션과 secure 옵션을 적용하여 cookie에 넣어서 사용자에게 보낸다. ( 이제 클라이언트는 refreshToken에 접근이 안된다.)
4. 서버는 accessToken을 body에 넘겨준다.
5. 클라이언트는 accessToken을 전역 store를 포함한 클라이언트 메모리에 저장한다.

위 전략을 구현한 로그인 컴포넌트 코드 with react

로그인 화면과 form 제출 시 로직이다. 

필자는 contextAPI를 활용해서 dispatch가 발생하면 isLoggedIn이 true로 바뀐다.

src/Components/Login

import React, { useCallback, FormEvent } from 'react';
import useInput from '../../Hooks/useInput';
import useAuth from '../../Hooks/useAuth';
import { LogInWrapper } from './styles';
import { useUserDispatch } from '../../context/userContext';

function Login(): JSX.Element {
  const [email, onChangeEmail] = useInput('');
  const [password, onChangePassword] = useInput('');
  const { login } = useAuth();
  const dispatch = useUserDispatch();
  const onSubmit = useCallback(
    (e: FormEvent<HTMLFormElement | HTMLButtonElement>) => {
      e.preventDefault();
      login({ email, password })
        .then(() => {
          dispatch('SET_ME');
        })
        .catch((err: any) => {
          console.error(err);
        });
    },
    [email, password],
  );

  return (
    <LogInWrapper>
      <form onSubmit={onSubmit}>
        <input
          type="email"
          required
          id="email"
          name="email"
          placeholder="이메일을 입력하세요"
          value={email}
          onChange={onChangeEmail}
        />
        <input
          type="password"
          id="password"
          name="password"
          placeholder="비밀먼호를 입력하세요"
          value={password}
          onChange={onChangePassword}
        />
        <button type={'submit'} onSubmit={onSubmit}>
          로그인
        </button>
      </form>
    </LogInWrapper>
  );
}

export default Login;

필자는 인증 절차를 Hook을 만들어 사용했는데 authServerCall 쪽을 살펴보면

myInfo와 accessToken이 data 안에 있으면 accessToken을 axios Header에 넣어주어 구현하였다.

src/Hooks/useAuth.ts

import axiosInstance from '../apis/axios';

interface UseAuth {
  login: ({ email, password }: LogInData) => Promise<void>;
  signup: ({ email, password, nickname }: SignUpData) => Promise<void>;
  logout: () => Promise<void>;
}

export function useAuth(): UseAuth {
  async function authServerCall(
    urlEndpoint: string,
    inputData: LogInData | SignUpData,
  ): Promise<void> {
    try {
      const { data, status } = await axiosInstance({
        url: urlEndpoint,
        method: 'POST',
        data: inputData,
        headers: { 'Content-Type': 'application/json' },
      });
      if (status === 400) {
        const title = 'message' in data ? data.message : 'Unauthorized';
        alert({ title, status: 'warning' });
        return;
      }
      if ('myInfo' in data && 'accessToken' in data) {
        alert('로그인 됐습니다.');
        setAccessTokenInAxiosHeader(data.accessToken);
      }
    } catch (errorResponse) {
      console.error(errorResponse);
    }
  }

  async function login(loginData: LogInData): Promise<void> {
    await authServerCall('user/login', loginData);
  }

  async function signup(signUpData: SignUpData): Promise<void> {
    await authServerCall('user/signup', signUpData);
  }

  async function logout(): Promise<void> {
    await logOutAPI();
  }

  return { login, signup, logout };
}

export const setAccessTokenInAxiosHeader: (accessToken: string) => void = (
  accessToken: string,
) => {
  axiosInstance.defaults.headers['x-access-token'] = accessToken;
};

 

서버 사이드 (로그인 API) 에서의 로그인 전략 구현 With Express

서버에서는 클라이언트에서 온 email, password가 적절한 지 확인 후 적절하다면 refreshToken을 cookie에 httpOnly와 secure 옵션을 넣어주고 accessToken과 accessToken 만료 시간을 body에 넣어서 보내주면 된다.

app.post("/login", async (req, res, next) => {
  try {
    const { email, password } = req.body;
    if (!email.length || !password.length)
      throw Error("이메일이나 비밀번호 정보가 부족합니다.");
    const exUser = await UserModel.find(email);
    if (!exUser) {
      res.status(401).send({ status: 401, message: "no auth" });
    }
    res.cookie("refreshToken", token.getRefreshToken(email), {
      httpOnly: true,
      secure: true,
      maxAge: 1000 * 60 * 60 * 24 * 7,
    });
    res.json({
      myInfo: exUser[0],
      accessToken: token.getAccessToken(email),
      expiresIn: token.getAccessTokenExpiresIn(),
    });
  } catch (err) {
    console.error(err);
  }
});

그렇다면 토큰 refresh는 어떻게 해야할까?

일단 accessToken이 refresh 돼야하는 상황을 생각해보자

accessToken이 만료됐고 refreshToken으로 새로운 accessToken을 받아야할 때 refresh를 해야한다.

클라이언트에서 refresh 로직 편하게 구현하기 with axios interceptor

모든 서버로의 요청을 보내기 전에 만료시간을 체크하고 refresh 로직을 넣는 것은 반복되는 코드가 많아지는 문제가 생긴다.

axios의 interceptor를 활용하여 문제를 해결해보자.

interceptor가 해주는 일은 http 요청이나 응답전에 해야할 일을 추가하는 것이다.

우리는 request를 가로채서 token이 refresh 돼야할 지 확인할 것이다.

axios.interceptors.request.use(callback,errorHandler);

위에서 callback은 axios config을 인자로 받고 함수 안에서 config 관련된 처리를 마친 새로운 axios config를 return한다. (물론 config를 수정하지 않고 받은 config를 그대로 return해줘도 된다.)

그리고 errorHandler는 새로운 에러가 발생시의 발생할 이벤트 함수이다.

src/apis/interceptors/refresh.ts

import { AxiosRequestConfig } from "axios";
import { logOutAPI, tokenRefreshAPI } from "../auth";
import customLocalStorage from "@/utils/localStorage";

export const refreshInterceptor = async (
  config: AxiosRequestConfig
): Promise<AxiosRequestConfig> => {
  const expiresIn = customLocalStorage.getItem("expiresIn");
  if (expiresIn === null) return config;

  const nowDate = new Date();
  const expiresDate = new Date(expiresIn);
  if (nowDate <= expiresDate) return config;

  const { accessToken, expiresIn: newExpiresIn } = await tokenRefreshAPI();
  if (config.headers !== undefined) {
    config.headers.Authorization = `Bearer ${String(accessToken)}`;
  }
  customLocalStorage.setItem("expiresIn", newExpiresIn);
  return config;
};

export function refreshErrorHandler(err: any): void {
  if (err !== null) {
    (async () => {
      await logOutAPI();
    })().catch((err: any) => {
      console.error(err);
    });
    alert("로그인이 만료돼셨습니다. 다시 로그인 부탁드립니다.");
  }
}

 

로컬스토리지에서 expiresIn로직을 가져와서 만료가 됐는지 확인한다.

만료가 되지 않았으면 그대로 config를 return한다.

const expiresIn = customLocalStorage.getItem("expiresIn");
if (expiresIn === null) return config;

const nowDate = new Date();
const expiresDate = new Date(expiresIn);
if (nowDate <= expiresDate) return config;

만료되지 않았다면 refresh 요청 잉후에 refrsh를 성공하면 받은 accessToken을 axios header에 등록한 후 리턴한다.

const { accessToken, expiresIn: newExpiresIn } = await tokenRefreshAPI();
if (config.headers !== undefined) {
  config.headers["x-access-token"] = `Bearer ${String(accessToken)}`;
}
customLocalStorage.setItem("expiresIn", newExpiresIn);
return config;

필자의 경우 errorHandler를 다룰 때는 서버에 로그아웃 요청을 보내 쿠키에 있는 refreshToken을 지워주게끔 컨트롤했다.

export function refreshErrorHandler(err: any): void {
  if (err !== null) {
    (async () => {
      await logOutAPI();
    })().catch((err: any) => {
      console.error(err);
    });
    alert("로그인이 만료돼셨습니다. 다시 로그인 부탁드립니다.");
  }
}

마지막으로 axiox interceptor에 실제로 등록해주면 된다.

src/apis/axios.ts

import axios, { AxiosRequestConfig } from "axios";
import {
  refreshErrorHandler,
  refreshInterceptor,
} from "./interceptors/refresh";

const axiosConfig: AxiosRequestConfig = {
  baseURL: import.meta.env.VITE_API_SERVER_URL,
  timeout: 10000,
  withCredentials: true,
};

const axiosInstance = axios.create(axiosConfig);

axiosInstance.interceptors.request.use(refreshInterceptor, refreshErrorHandler);

export default axiosInstance;

 

마지막으로 생각해보아야하는 것들

axios에 토큰을 등록해놓는 것도 결국 브라우저 메모리에 토큰이 저장되는 것이다.

탈취하기 쉽지는 않지만 FE에서 모든 것은 털릴 수 있다고 가정해야한다. (결국 공개돼있기 때문)

즉 accessToken이 탈취 당했을 때도 가정하여 accessToken의 만료시간은 굉장히 짧게 잡아주는 것이 좋다.

또 API 설계를 할 때도 get요청은 해커의 의도를 반영할 수 있으므로 보안이 중요한 API들은 get요청을 사용하지 않는 것이 좋다.

Cors 에러를 컨트롤 할 때도 cors('*')을 애용하기보다는 요청이 와야하는 클라이언트 origin 만 포함하는 것도 도움이 된다.

 

마무리

jwt를 이용한 인증방식을 사용할 때 토큰 관리는 정답이 없고 어려운 소재이다.

이 글에서 적용되는 예시들도 반례를 분명히 찾을 수 있을 것이다.

그러기에 토큰을 관리할 때 어떻게 하면 조금 더 편하게 관리할까? 어떻게 하면 조금 더 안전하게 관리할까?를 여러 방면에서 생각해보면 확실히 도움이 될 것이다.

이 글이 그런 생각을 떠올릴 때 도움이 되는 재료가 됐으면 좋겠다.

 

댓글