JWT 로그인 기능 구현

혜진 조·2022년 8월 9일
1

JWT

목록 보기
2/2

JWT 를 이용한 로그인 기능 구현 과정

사용한 라이브러리

  • react-hook-form (짧은 코드로 유효성 체크가 가능하다)
  • react-cookie (토큰을 쿠키에 set, get할 때 사용)
  • jwt-decode (Base64Url로 인코딩된 jwt token을 복호화)

구현 내용

  • 자동으로 focus 주기
  • 아이디 저장 유무 체크
    1. 아이디 저장이 true이면 쿠키에 아이디를 저장한다.(expires기간 30일)
    2. 아이디 저장이 false이면서 쿠키에 아이디가 저장되어 있다면, 쿠키에 저장된 아이디를 삭제한다.
    (+3. 로그아웃을 하면 쿠키에 저장된 아이디를 삭제한다.)
  • 토큰 만료 기간 체크
    • 권한이 필요한 페이지 접근 시, 헤더에 토근을 담아 axios 호출을 해야한다.
    • API 요청 전에 axios 요청을 인터셉트 해서 로컬 저장소에 있는 Access Token 의 만료기간이 지났는지 확인하는 절차를 거친다. 만료기간이 지났다면 API 요청을 하지 않고 Refresh Token 으로 Access Token 재발급 요청을 진행한다.(토큰 만료 기간 설정은 프로젝트마다 유동적일 수 있다)
    • 타임스탬프(Timestamp) 값을 이용하여 시간을 비교한다. 타임스탬프는 현재 시간을 밀리세컨드 단위로 변환하여 보여주며 특히 값을 비교하는 경우 매우 유용하게 사용할 수 있다.

export const Test = () => {
  const cookies = new Cookies();
  const {
    register,
    handleSubmit,
    // formState: { errors },
    setFocus,
  } = useForm<Inputs>({ mode: "onSubmit" });
 
  //focus
   useEffect(() => {
    setFocus("id");
    cookies.get("user_save_id") && setFocus("password");
  }, [setFocus]);

 //쿠키에 아이디 값이 저장되어 있으면 아이디 저장 체크박스를 true로 설정
  const [idChecked, setIdChecked] = useState<boolean>(
    cookies.get("user_save_id") ? true : false
  );
  
   //로그인 시 아이디 저장 유무 체크
    if (idChecked) {
      const expires = new Date();
      expires.setDate(expires.getDate() + 30);
      cookies.set("user_save_id", data.id, { expires });
    } else if (!idChecked && cookies.get("user_save_id")) {
      cookies.remove("user_save_id");
    }
  };
   const onSubmit: SubmitHandler<Inputs> = async (data) => {
    console.log(data);
    const res = await CallApi.request(
      "/api/v1/users/signin",
      "POST",
      null,
      null,
      data
    );
     
     //token 쿠키에 저장
    cookies.set("accessToken", res.accessToken, {
      secure: true,
      // httpOnly: true, XSS 공격으로부터 보호
    });
    cookies.set("refreshToken", res.refreshToken, {
      secure: true,
      // httpOnly: true,
    });
 
return (
 <div id="Login">
      <form onSubmit={handleSubmit(onSubmit)}>
        <input
          type="text"
          defaultValue={
            cookies.get("user_save_id") && cookies.get("user_save_id")
          }
          placeholder="아이디"
          {...register("id")}
        />
        {/* register()의 인자로 들어가는 값이 input의 name이다 */}
        {/* {errors && errors.id?.message} */}
        <input
          type="password"
          placeholder="비밀번호"
          {...register("password")}
        />
        <label>
          <input
            type={"checkbox"}
            name={"idCheck"}
            checked={idChecked}
            onChange={() => setIdChecked(!idChecked)}
          />
          아이디저장
        </label>
        <input type="submit" />
      </form>
    </div>
);
};

axios 인터셉터로 토큰 만료기간 체크하기

토큰이 만료되었기 때문에 response 값으로 error를 내려줄텐데,
이 에러를 인터셉트해서 accessToken을 재발급 하고, 새로 발급된 토큰으로 재요청을 실행한다.

그전에 프론트에서 refreshToken이 만료되었는지 체크를 먼저 해야한다.

//Check refreshToken rxpiry
const validateTimeRefreshToken = () => {
  const refreshToken = cookies.get("refreshToken");
  const decodeRefreshToken: JwtPayload = jwt_decode(refreshToken);

  let exp = Number(decodeRefreshToken.exp);
  let timestamp = new Date().getTime();
  let nowDate: number = Number(
    timestamp.toString().slice(0, String(timestamp).length - 3)
  );
  if (nowDate < exp) {
    console.log("RefreshToken is valid");
    return true;
  } else {
    console.log("RefreshToken is invalid");
    return false;
  }
};

refreshToken이 아직 유효하다면 accessToken을 재발급 받고,
refreshToken이 만료되었다면 쿠키에 저장된 토큰을 모두 삭제한뒤, 로그아웃시킨다.

💡여기서 잠깐, 만약 한 페이지에서 동시에 여러개의 api호출이 이루어진다면 어떻게 될까.
request가 3번 있었다면, error도 3번 발생하므로
토큰 재발급 호출이 requset 개수 만큼 중복되어 실행되는 현상이 생긴다.
기존 refreshToken으로 accessToken 재발급은 단 한 번만 실행되도록 설정해주어야 한다.
아래 자료가 상당히 도움이 되었다.

👀토큰이 만료된 상태에서 request가 여러개 동시에 이루어졌을 때,
토큰 재발급 호출이 requset 개수 만큼 중복되어 실행되는 현상 막기
https://gist.github.com/mkjiau/650013a99c341c9f23ca00ccb213db1c
https://javascript.plainenglish.io/handle-refresh-token-with-axios-1e0f45e9afa

전체 코드

instance.interceptors.response.use(
  function (response) {
    return response;
  },
  function (err) {
    if (err.config.headers.Authorization !== undefined) {
      //Handle expired token
      if (
        err.response.status === 401 &&
        !err.config._retry &&
        err.response.data.message === "JWT 토큰이 만료되었습니다."
      ) {
        err.config._retry = true;
        console.log(err.response.data.message);
        if (validateTimeRefreshToken()) {
          return (async () => {
            try {
              //[api 호출이 여러개여도 accessToken 재발급은 한 번만 실행하도록 설정]
              //-> 최초 refreshing_token에 값이 null이므로 refresh_token()실행 O
              //-> 그 다음부터는 refreshing_token의 값이 refresh_token()에서 return한 res값이므로 refresh_token()실행 X
              refreshing_token = refreshing_token
                ? refreshing_token
                : refresh_token();
              const res: any = await refreshing_token;
              if (res.data.code === 200) {
                console.log("토근 재발급 성공");
                cookies.set("accessToken", res.data.data.accessToken, {
                  secure: true,
                });
                cookies.set("refreshToken", res.data.data.refreshToken, {
                  secure: true,
                });
                err.config.headers.Authorization =
                  "Bearer " + cookies.get("accessToken");
                //Axios Retry
                return instance(err.config);
              }
            } catch (err: any) {
              console.log(err.response.data.message);
              //refreshToken 에러 스펙 정리 400
              if (err.response.data.data.errorCode === "REFRESH_TOKEN_USED") {
                console.log("true");
                window.location.href = "/signin";
                cookies.remove("accessToken");
                cookies.remove("refreshToken");
              }
            }
          })();
        } else if (!validateTimeRefreshToken()) {
          window.location.href = "/signin";
          cookies.remove("accessToken");
          cookies.remove("refreshToken");
        }
      }
    }
    return Promise.reject(err);
  }
);

⭐️ 도움이 되었던 자료
https://inpa.tistory.com/entry/TIP-자바스크립트-타임스탬프
https://hydev.tistory.com/3

profile
나를 믿고 한 걸음 한 걸음 내딛기! 🍏

0개의 댓글