React + Redux + Redux-Saga + Typescript - 로그인 구현 및 로그인 유지 (Feat JWT Token, Cookie)

Jinnny·2023년 8월 4일
0

React

목록 보기
4/11

로그인하면 아마 가장 먼저 token이 떠오를 것이다.
HTTP는 Stateless 프로토콜이기 때문에 현재 상태를 기억하지 못하고 이러한 특성으로 인해 서버는 요청자가 누구이고 같은 사용자인지 다른 사용자인지를 구별할 수 없다. 이러한 문제점을 해결하고자 현재 수많은 사이트에서 사용자 식별 정보를 쿠키에 저장하고 있고 쿠키에 저장된 정보를 사용하여 사용자를 식별하고 있다. 예를 들어, 유튜브 프리미엄 이용자와 비이용자를 구별할 수 있는 이유도 쿠키에 저장되어 있는 token 때문이라고 할 수 있다.

오늘은 진행하고 있는 프로젝트에서 직접 로그인 기능을 구현하면서 token을 어떻게 사용했는지 즉, 서버로부터 받아온 token을 어떻게 cookie에 저장하고 로그인을 유지하는지 알아보고자 한다.

🛠 기능

  1. 로그인 UI 및 서버로 Id, Password 전달
  2. 서버에서 받아온 token을 cookie에 저장
  3. Refresh token으로 로그인 유지

👩‍💻 구현

먼저 구현에 앞서 쿠키에 저장하고 값을 가져오고 삭제할 수 있도록 universal-cookie를 사용해주었다.

여기서 주절주절을 하나 하자면,,,, 처음에는 react-cookie를 사용했었는데 간편 로그인을 구현하면서 cookie에 token을 저장할 때 path 지정이 되지 않았다. 그러다 나랑 동일한 문제를 겪었던 사람의 글을 보게 되었고 universal-cookie를 사용했다는 말에 라이브러리를 변경했더니 내가 설정한 path로 cookie에 저장할 수 있었다.

npm install universal-cookie
yarn add universal-cookie

import Cookies from "universal-cookie";

const cookies = new Cookies();

export const setCookie = (name: string, value: string, option?: any) =>
  cookies.set(name, value, { ...option });

export const getCookie = (name: string) => cookies.get(name);

export const removeCookie = (name: string, option?: any) =>
  cookies.remove(name, { ...option });

✔ 로그인 UI 및 서버로 Id, Password 전달
그 다음 아이디와 비밀번호 input을 각각 만든 후 Redux-Saga를 사용하여 서버로 보내주기 위해 입력값을 dispatch 해주었다.

const Login = () => {
  const [loginForm, setLoginForm] = useState<LoginFormType>({
    id: "",
    password: "",
  });

  const isFormValid = loginForm.id !== "" && loginForm.password !== "";

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    dispatch(
      loginLoading({
        id: loginForm.id,
        password: loginForm.password,
      }),
    );
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setLoginForm((prev) => ({
      ...prev,
      [name]: value,
    }));
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <St.InputField>
        <Input
          type="text"
          name="id"
          value={loginForm.id}
          placeholder="아이디"
          onChange={handleChange}
        />
        <PasswordInput
          name="password"
          value={loginForm.password}
          placeholder="비밀번호"
          onChange={handleChange}
        />
      </St.InputField>
      <St.LoginBtn disabled={!isFormValid}>로그인</St.LoginBtn>
    </form>
    <Modal page="login" />
  )
}

로그인 UI

✔ 서버에서 받아온 token을 cookie에 저장
로그인 컴포넌트에서 dispatch한 loginLoading 액션이 감지되면 loginSaga가 실행되고 서버에 id와 password를 보내주고 response 값을 받아온다.
서버로부터 받아온 reponse에는 access token과 refresh token이 담겨있는데 token 앞에 있는 Bearer를 제거한 후 토큰 값을 cookie에 저장했다.

처음에는 access token만 받아오기로 했는데 access token의 만료시간을 줄여 token을 자주 발급 받아 보안을 강화하는게 좋을 것 같아 refresh token도 같이 사용하기로 하였다. Refresh token을 사용하여 access token이 만료되면 refresh token을 보내 새로운 access token과 refresh token을 발급받을 수 있도록 하였고 사용자가 서비스를 이용할 때마다 token을 함께 보내주어 로그인을 유지할 수 있도록 하였다. Refresh token의 경우 만료시간을 1일로 지정하였는데 만료된 token으로 정보를 요청할 경우 로그아웃이 되도록 하였다.

const loginReducer = createSlice({
  name: "loginReducer",
  initialState,
  reducers: {
    loginLoading: (state, action) => {
      state.loading = true;
      state.isLogin = false;
    },
    loginSuccess: (state, action) => {
      state.loading = false;
      state.isLogin = true;
      return state;
    },
    loginFail: (state, action) => {
      state.loading = false;
      state.isLogin = false;
      state.error = action.payload;
    },
  },
});

function* loginSaga(
  action: PayloadAction<LoginFormType>,
): Generator<any, void, any> {
  try {
    const response = yield call(login.checkLoginUser, action.payload);
    const accessToken = response.data.Access_token.substr(7);
    const refreshToken = response.data.Refresh_token.substr(7);
    setCookie("accessToken", accessToken);
    setCookie("refreshToken", refreshToken);
    yield put(loginSuccess(response));
    yield call(redirect, "/home");
  } catch (error: any) {
    yield put(loginFail(error));
    yield put(openModal(error.message));
  }
}
export function* watchLoginSaga() {
  yield takeLatest(loginLoading.type, loginSaga);
} 

쿠키에 저장된 token

✔ Refresh token으로 로그인 유지
서버로 통신하기 위해 Axios를 사용했고 사용자 정보를 서버에 요청할 때 access token을 header에 넣어서 보내주고자 interceptor를 사용했다.

export const authAxios = axios.create({
  baseURL: `${process.env.REACT_APP_SERVER_URL}`,
  headers: {
    "Content-type": "application/json",
  },
});

authAxios.interceptors.request.use(
  (config) => {
    const accessToken = getCookie("accessToken");
    config.headers.Access_token = `Bearer ${accessToken}`;
    return config;
  },
  (error) => {
    console.log(error);
    return Promise.reject(error);
  },
);

이때 access token이 만료될 경우 401 에러가 발생하는데 refresh token을 헤더에 넣어 새로운 access token을 받아오고 새로운 access token으로 기존에 요청했던 통신(사용자 정보 요청)이 실행될 수 있도록 했다.
여기서 새로운 access token을 받아올 때 새로운 refresh token도 같이 받아온 이유는 사용자가 서비스를 이용하고 있을 때 refresh token이 만료되어 로그아웃 되는 현상을 막고자 refresh token도 새로 받아올 수 있도록 하였다.

authAxios.interceptors.response.use(
  (response) => response,
  async (error) => {
    console.log(error);
    const errorMessage = error.response.data.message;
    const errorStatus = error.response.status;
    const originalRequest = error.config;
    const refreshToken = getCookie("refreshToken");
    let response;
    let newAccessToken;
    let newRefreshToken;

    switch (errorStatus) {
      case 401:
        switch (errorMessage) {
          case "Token is expired":
            response = await baseAxios.post("/token/refresh", null, {
              headers: { Refresh_token: `Bearer ${refreshToken}` },
            });
            newAccessToken = response.data.Access_token.substr(7);
            newRefreshToken = response.data.Refresh_token.substr(7);
            setCookie("accessToken", newAccessToken);
            setCookie("refreshToken", newRefreshToken);
            originalRequest.headers.Access_token = `Bearer ${newAccessToken}`;
            return axios(originalRequest);
          default:
            break;
        }
        break;
      default:
      break;
    }
  },
);

이렇게 universal-cookie 라이브러리를 사용하여 사용자를 식별할 수 있도록 로그인을 구현해보았다 😣

1개의 댓글

comment-user-thumbnail
2023년 8월 4일

많은 도움이 되었습니다, 감사합니다.

답글 달기