[JWT인증] 프론트엔드에서 JWT 인증 구현하기

이은지·2023년 12월 31일
1

테이브 연합프로젝트에서 로그인 방식으로 JWT인증을 사용하기로 하여, JWT 방식에 대해 배우고 중요한 것들에 대해 정리해보려 한다. 일단, JWT 인증에 대한 개념을 익히기 전, 인증과 인가 개념에 대해 참고하자.

1. JWT ?

  • 인증에 필요한 정보를 Base64 URL-safe Encode을 통해 암호화시킨 JSON 토큰
    • Base64 URL-safe Encode: 일반적인 Base64 Encode에서 URL에서 오류없이 사용하도록 ‘+’, ‘/’를 각각 ‘-’, ‘_’로 표현한 것
  • JWT 기반 인증은 JWT토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별하는 방식

JWT 기반 인증의 경우, 토큰 탈취의 위험성을 피하고자 Access Token, Refresh Token으로 이중으로 나누어 인증을 하는 방식을 많이 사용한다.

Access Token

  • 인증 시, 실제로 필요한 정보가 담긴 토큰으로 인증을 통과할 수 있는 key
  • 인증에 필요한 정보가 담기기 때문에 토큰의 유효기간이 매우 짧음

Refresh Token

  • 새로운 Access Token을 발급해주기 위해 사용하는 토큰으로 짧은 수명을 가지는 Access Token에게 새로운 토큰을 발급해주기 위해 사용

💡 Access Token은 접근에 관여하는 토큰, Refresh Token은 재발급에 관여하는 토큰의 역할로 사용되는 JWT

2. JWT 토큰 인증 시스템 구현

시스템 플로우에서 발생할 수 있는 각 시나리오에 따라 구현 과정을 정리하려 한다.

설계 내용

  • Access Token과 Refresh Token은 쿠키에 저장하여 보관

시나리오 1. 로그인 요청

  • 서버에서 새로운 Access Token, Refresh Token을 발행하여 프론트 측에 헤더로 전달
  • 프론트에서는 헤더에서 Access Token, Refresh Token을 받아와 쿠키에 저장
  • 해당 프로젝트에서는 사용자 정보를 조회하기 위해 userId가 필요하였음. 백엔드와 논의 후, Access Token의 payload에 userId 정보가 담겨 있으므로, 프론트 측에서 디코딩하여 처리하였음. (참고: 프론트에서 jwt토큰 디코딩하기)
//로그인 요청 코드
  const onSubmit = async (e) => {
    const {username, password} = getValues();
    try{
      const res = await axios.post('/login',{
        username: username,
        password: password
      });

      //header jwt 토큰 정보 받아오기 
      let jwtToken = res.headers.get("Authorization"); 
      let refreshToken = res.headers.get("refresh");

      //쿠키에 jwtToken, refreshToken 저장
      setCookie('jwtToken', jwtToken, {path: '/'});
      setCookie('refreshToken', refreshToken, {path: '/'});

      // jwt토큰 디코딩 
      let decodingInfo = DecodingInfo(jwtToken);
  
      //userId 추출 
      let userId = decodingInfo.id;

      //사용자 정보 API 요청 
      const response = await axios.get(`/users/${userId}?userId=${userId}`);
      console.log(response.data);

      //사용자 정보 리덕스 저장 
      dispatch(authUser({
        userIdx: userId,
        userProfile: response.data.image,
        nickname: response.data.nickname,
      }));

      //메인화면 이동 
      navigate('/')
    } catch (error) {
      switch(error.response.data.message){
        //401-Unauthorized: Password Invalid
        case "Password Invalid":
          return alert("비밀번호가 맞지 않습니다. 비밀번호를 다시 확인해주세요")
        //401-Unauthorized: Username Invalid
          case "Username Invalid":
          return alert("존재하지 않는 아이디입니다. 아이디를 다시 확인해주세요")
      }
  }
};

시나리오 2. 클라이언트의 API 호출

  • 이제 프론트는 사용자 인증이 필요한 기능에서 API 헤더에 Authorization: Bearer (Access Token)을 전달해야 한다.
  • 이 때 두 가지 경우가 발생

(1) Access Token의 유효기간이 만료되지 않은 경우

  • 서버에서 정상적으로 유저를 식별하여 로직을 처리

(2) Access Token의 유효기간이 만료된 경우: Access Token 재발급

  • 서버에서 Access Token의 유효 기간이 만료되었다는 응답을 프론트엔드에 전달함
  • 프론트엔드는 Access Token을 재발급 받기 위해 API 헤더에 Refresh: Bearer + refresh token을 전달

2-2-1. Refresh Token의 유효기간이 만료되지 않은 경우

  • 프론트엔드는 서버에서 재발급된 Access Token을 전달 받아 쿠키 갱신

2-2-2. Refresh Token의 유효 기간이 만료된 경우

  • 서버에서는 DB에 저장된 Refresh Token을 지우고, 유효 기간이 만료되었다는 응답을 프론트엔드에 전달
  • 프론트는 해당 응답을 받으면, 유저를 로그인 화면을 이동시켜 다시 로그인하게 함

여기서 access token이 만료되었을 경우, 공통으로 refresh token을 활용해 access token을 재발급 받아 다시 함수를 처리할 수 있도록 axios interceptor을 활용해 구현하였다.

// API 요청을 보낼 때마다 호출되는 인터셉터를 설정
axios.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;
    if (error.response.data.message === "JWT Token Expired" && !originalRequest._retry) {
      // JWT 토큰이 만료되었다는 응답을 받으면, 토큰 새로고침을 시도
      originalRequest._retry = true;
      const refreshToken = getCookie('refreshToken');
      if (refreshToken) {
        originalRequest.headers['Refresh'] = `Bearer ${refreshToken}`;

        try {
          // 새 토큰으로 원래 요청을 다시 시도
          const res = await axios(originalRequest);
          const newJwtToken = res.headers['Authorization'];
          const newRefreshToken = res.headers['Refresh'];

          // 갱신된 JWT 토큰과 리프레시 토큰을 저장
          setCookie('jwtToken', newJwtToken);
          setCookie('refreshToken', newRefreshToken);

          return res;
        } catch (error) {
          return Promise.reject(error);
        }
      } else {
        alert('로그인 먼저 진행해주세요.')
        navigate('/login');
      }
    }
    return Promise.reject(error);
  }
);
profile
소통하는 개발자가 꿈입니다!

0개의 댓글