Skip to content

FE ‐ JWT 와 Axios Instance

Minjoon Park edited this page Jun 7, 2024 · 1 revision

🛠️ JWT 동작원리

1. 서버에서 JWT를 클라이언트에 발급 (Access, Refresh)

2. 클라이언트는 이 JWT를 쿠키나 로컬스토리지에 저장해놨다가 서버에 요청(GET, POST 등)을 보낼때 이 JWT를 같이 실어서 보냄

방법1. 쿠키 공유

    클라이언트에서 WithCredentials true 옵션을 지정하면 요청을 보낼때 서버랑 쿠키를 공유할수 있음. 서버는 쿠키에서 토큰정보를 받아와서 인가를 진행하면됨
    
    **장점**: 구현이 쉽고, HTTPOnly (JS로 접근 불가능) 설정을 통해 XSS(Cross Site Scripting) 방어
    
    **단점**: 모바일등 웹 환경이 아닌 다른환경에서는 구현이 어려움</p>

방법2. Header에 추가

    클라이언트에서 요청을 보낼때 마다 요청 Header에 JWT토큰을 같이 실어서 보냄
    
    클래식한 방법이며 CSRF에 대해 안전함
    
    **장점**: Session, 모바일 등 다른 환경, 인증 인가 방식도 적용 가능
    
    **단점**: 구현 난이도가 있음

3. 서버에서는 Secret key 대조를 통해 유저를 파악하고 통과하면 요청된 작업을 수행

4. Access Token 의 기간(최소1분 - 최대60일)이 만료되었다면 클라이언트는 Refresh Token을 보냄

5. Refresh Token 의 기간이 만료되지 않았다면 새로운 Access Token을 발급해줌

❓ localStorage & Header 방식 채택 이유

배포시 Https를 적용하여 요청 헤더역시 암호화 될 것 이기에 header를 통해 전송하여도 문제가 없다고 판단하였습니다.

💻 백엔드 코드

Spring Security를 채택하여 사용자를 검증하였습니다.

Client에서 어떠한 요청을 보내게 된다면 JWT Filter에 정의된 로그인 로직을 거칩니다.

HTTP header를 통해 전달된 access Token을 검증하여 요청의 허가/거부 를 결정합니다.

SecurityConfig.java

Security Config에 Filter를 추가합니다.

        http
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
        //필터 추가 (LoginFilter()는 인자를 받음 (AuthenticationManager() 메소드에 authenticationConfiguration 객체를 넣어야 함) 따라서 등록 필요)

JwtFilter.java

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //Access 토큰 검증
        // 헤더에서 access키에 담긴 토큰을 꺼냄
        String accessToken = request.getHeader("access");

        // 토큰이 없다면 다음 필터로 넘김
        if (accessToken == null) {

            filterChain.doFilter(request, response);

            return;
        }

💻 프론트엔드 코드

최초 로그인시 accessToken을 발급받아 local Storage에 저장하였습니다.

이후 매 요청시에 사용자 검증을 위해 access Token을 HTTP header에 담아서 보내야 했습니다.

Axios 라이브러리를 채택하며 Axios Instance를 정의해 항상 access Token을 Header에 포함하도록 하였습니다.

또한 refresh Token을 활용하여 access Token이 만료되었다면 자동으로 재발급을 신청하도록 구현하였습니다.

api.ts

import axios from "axios";

const axiosInstance = axios.create(); // 매 요청별로 코드 전송
axiosInstance.defaults.withCredentials = true;
axiosInstance.defaults.baseURL = import.meta.env.VITE_SERVER_URL;

axiosInstance.interceptors.request.use(
  async (config) => {
    const accessToken = localStorage.getItem("access"); // 로컬 스토리지에서 토큰을 가져오기
    const refreshToken = localStorage.getItem("refresh"); // 로컬 스토리지에서 리프레시 토큰을 가져오기

    config.headers["Content-Type"] = "application/json";

    if (config.url === "/reissue") {
      config.headers["refresh"] = refreshToken;
    } else {
      config.headers["access"] = accessToken;
    }
    return config;
  },
  (error: any) => {
    console.log(error);
    return Promise.reject(error);
  }
);

axiosInstance.interceptors.response.use(
  (response) => {
    return response;
  },
  async (error) => {
    const {
      config,
      response: { status, data },
    } = error;
    if (status == 401) {
      const originalRequest = config;
      const refreshToken = localStorage.getItem("refresh");

      const response = await axios.post(
        "/reissue",
        {},
        { headers: { refresh: refreshToken } }
      );
      const accessToken = response.headers["access"];
      localStorage.removeItem("access");

      localStorage.setItem("access", accessToken);

      originalRequest.headers["access"] = accessToken;

      return originalRequest;
    }

    if (status === 400 && data == "You have already been logged out.") {
      localStorage.removeItem("access");
      localStorage.removeItem("refresh");
      window.location.href = "/";
    } else if (status === 400 && data == "invalid refresh token") {
      localStorage.removeItem("access");
      localStorage.removeItem("refresh");
      window.location.href = "/";
    }
    return Promise.reject(error);
  }
);

export default axiosInstance;

apiUtil.ts

import { AxiosInstance } from "axios";
import { JwtPayload, jwtDecode } from "jwt-decode";

export const tokenRefresh = async (instance: AxiosInstance) => {
  const refreshToken = localStorage.getItem("refresh"); // 리프레시 토큰을 가져오기
  if (!refreshToken) {
    // 리프레시 토큰이 없는 경우
    return false;
  }
  const data = await instance
    .post("/reissue", {
      headers: {
        "Content-Type": "application/json",
        refresh: refreshToken,
      },
    })
    .then((res) => {
      const newAccessToken = res.headers["access"];
      localStorage.setItem("access", newAccessToken);
      return true;
    })
    .catch((err) => {
      console.log(err);
      return false;
    });
}; // tokenRefresh() - 토큰을 갱신해주는 함수

export const isTokenExpired = () => {
  // 토큰 만료 검사
  const accessToken = localStorage.getItem("access");
  if (!accessToken) {
    // 토큰이 없는경우
    return true;
  }
  const decodedToken = jwtDecode<JwtPayload>(accessToken);
  const currentTime = Date.now() / 1000;
  if (decodedToken.exp !== undefined && decodedToken.exp < currentTime) {
    // 토큰이 만료된 경우
    return true;
  }
  return false;
};