로그인 인증 구현하기

1Hoit·2023년 7월 19일

토이프로젝트

목록 보기
4/13
post-thumbnail

시작하며

지난번 프로젝트에서 로그인을 하면 서버에서 액세스토큰만 발급해주어서 너무 아쉬웠다.

감사하게도 다른 백엔드 팀원분이 사이드 프로젝트를 같이 진행해 주셔서 좀 더 제대로된 로그인 인증 처리를 하려고 한다!

🔐 JWT

로그인에 JWT 방식을 사용할 것이다.

JWT는 인증에 필요한 정보를 Base64 URL-safe Encode을 통해 암화시킨 JSON 토큰이다.

AccessToken 🔑

인증시, 필요한 정보가 담긴 토큰으로 인증을 통과할 수 있는 key라고 보면 된다.
인증에 필요한 정보가 담기기 때문에 탈취에 대한 위험을 줄이기 위해 토큰의 유효기간이 매우 짧다.

RefreshToken 🔑

유효 기간이 짧은 Access Token을 보조해주는 토큰으로 Access Token보다 유효기간이 길며, Access Token을 발급하는데 사용된다.

그럼 어떻게 관리할 것인가?

1.AccessToken은 로컬스토리지로 관리하자!

2.RefreshToken은 httpOnly 쿠키로 받아서 저장하자!

AccessToken은 아무래도 인증에 사용되는 토큰이기때문에 외부에 노출되지 않도록 변수에 저장하는 방법을 많이 추천해서 처음엔 recoil을 사용해 변수에 저장했지만 새로고침시 값이 날아가기에 recoil-persist를 사용했지만 이도 로컬스토리지에 저장되기에 로컬 스토리지를 이요하기로 헀다.

RefreshToken은 인증에 직접적으로 사용하는 토큰이 아니지만 RefreshToken이 없어지면 로그인을 통해 다시 토큰을 받아야하기 때문에 브라우저의 어디인가에 저장되어야하고 로그인 유지 기능을 사용한다면 브라우저 창이 닫아도 저장이 유지되는 로컬 스토리지와 쿠키 중에서 선택해야한다.

둘 다 탈취의 위험은 존재한다.

로컬 스토리는 XSS(Cross Site Scripting) 공격에 취약하고 쿠키는 CSRF(Cross-Site Request Forgery) 공격에 취약하다.

그럼에도 많은 글들에서 쿠키를 추천한 이유는 XSS는 토큰 값 자체를 탈 취하는 방식이며
RefreshToken은 AccessToken을 발급받는 용도일뿐 사용자의 정보가 담기기 않으며 httpOnly를 설정해 JS를 통한 접근을 막고 RefreshToken을 일회용 처럼 사용하는 RTR(Refresh Token Rotation)을 도입해 탈취된 RefreshToken의 사용을 막을 수 있다는 이유였다.


AccessToken

API 요청으로 서버에서 받은 AccessToken을 서버와 통신하는데 사용하는 라이브러리(axios, fetch)에 default값으로 header에 넣어 주면 된다.

나의 경우 짧게 코드를 보자면

 try {
        await axios
          .post(
            `${BASE_URL}/login`,
            { email: loginForm.email, password: loginForm.password },
            { withCredentials: true }
          )
          .then((res) => {
            window.localStorage.setItem('accessToken', res.headers.authorization);
            axios
              .get(`${BASE_URL}/user`, {
                headers: {
                  Authorization: res.headers.authorization,
                },
                withCredentials: true,
              })
              .then((res) => {
                setCurrentUser(res.data);
              })
              .then(() => {
                navigate('/');
              });
          });
      } catch (err: any) {
        alert('로그인 실패');
      }

위처럼 로그인이 성공하면 로컬스토리지에 토큰을 저장하고 현재 유저를 확인하고 로그인 성공을 시켜준다.

RefreshToken

RefreshToken을 서버에서 쿠키로 받아오기로 했다면, 프론트 엔드에서 RefreshToken을 쿠키에 저장하기 위해 따로 코드를 짤 필요 없다. 알아서 저장되고 (서버와 클라이언트의 도메인이 같다면) HTTP 요청 시, 서버에 알아서 보내지기 때문이다.

하지만 서버와 클라이언트의 도메인이 다를 경우에 RefreshToken을 쿠키로 공유할 거라면 주의해야할 부분이 있다.

프론트 엔드에서는 withCredentials를 꼭 설정해주어야한다. 그래야 CORS오류를 피하고, 브라우저가 서버로부터 쿠키를 받아서 저장할 수 있다.

위와같이 하고 백엔드에서는 아래의 설정을 해서
응답과 함께 Set-Cookie 헤더에 쿠키를 담아 보내주었다.

SameSite=None
secrue
httpOnly
Access-Control-Allow-Credentials :true
Access-Control-Allow-Origin : 클라이언트 도메인


위처럼 이제 토큰을 잘 받아왔다!
하지만 문제가 있었다.

accessToken이 만료되면 refreshToken으로 accessToken 재 발급 요청을 해야했다.

처음엔 그냥 axios를 사용했지만 매 요청마다 작성하는 것이 귀찮고 코드도 길어져서
Axios interceptor를 사용하게 되었다!

Axios interceptor

axios 라는 http 통신 라이브러리에는 interceptor 가 존재한다.

이는 http 통신을 하기 직전 함수를 실행하여 AxiosRequestConfig 를 수정할 수 있도록 한다.

즉, API 요청을 보내기 직전에 토큰이 만료되었는지 확인하고 만료되었다면 저장된 refresh 토큰을 사용해서 새로운 Token을 발급받아서 토큰을 교체하는 작업을 수행하는 것이다.

경우를 따져보자

  1. accessToken 만료 refreshToken 존재

  2. accessToken 만료 refreshToken 만료

위의 두 가지 경우에 따라 처리를 해야한다.
나의 경우 Axios interceptor를 아래와 같이 사용했다

import axios from 'axios';

const client = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
  withCredentials: true,
});
client.interceptors.request.use(function (config) {
  const token = window.localStorage.getItem('accessToken');
  config.headers.Authorization = token;
  return config;
});

client.interceptors.response.use(
  function (response) {
    return response;
  },
  async (error) => {
    const {
      config,
      response: { status },
    } = error;

    const originalRequest = config;

    if (status === 401) {
      try {
        const res = await axios.post(`${process.env.REACT_APP_API_URL}/refresh`, {}, { withCredentials: true });
        window.localStorage.setItem('accessToken', res.headers.authorization);
        const token = window.localStorage.getItem('accessToken');
        originalRequest.headers = {
          Authorization: token,
        };
        return await axios(originalRequest);

      } catch (error) {
        window.localStorage.removeItem('accessToken');
        window.localStorage.removeItem('recoil-persist');
        alert('토큰이 만료되었습니다. 다시 로그인해 주세요');
        window.location.href = `http://localhost:3000/login`;
      }
    }
    return Promise.reject(error);
  },
);

export default client;

Axios interceptor의 경우 따로 자세히 정리해야겠다.


마무리

지난번에 아쉬웠던 부분을 보완할 수 있는 기회가 있어서 좋았다.
아직 부족한 부분이 많겠지만 조금씩 채워 나가야겠다!

Ref

참고사이트1
참고사이트2
참고사이트3

profile
프론트엔드 개발자를 꿈꾸는 원호잇!

1개의 댓글

comment-user-thumbnail
2023년 7월 19일

이 글을 통해 많은 것을 배웠습니다.

답글 달기