[pre-project: stackoverflow clone하기] 이메일 sign up/sign in, 자동 로그인 등 구현

이민선(Jasmine)·2023년 6월 14일
0
post-thumbnail

이번 프리프로젝트에서는 이메일 로그인과 google oAuth를 담당하게 되었다. 혼자 프로젝트할 때는 firebase로 함수를 만들면 뚝딱(뚝딱거린다는 뜻) 로그인을 구현할 수 있었지만, 서버와 통신하며 HTTP 방식으로 로그인을 구현하는 경험은 처음이라 긴장이 되었다. 그래도 할 수 있똬! 이걸 제대로 해낸다면 아주 많이 성장해있을 나를 생각하면 벌써 신이 난다.

1. 이메일 sign-up/sign-in flow

스택오버플로우에 들어가서 이메일 회원가입을 해보니 별도로 로그인을 하지 않아도 로그인 상태가 되었다.

생각해보면 최근에는 이메일 회원가입을 하면 자동으로 로그인이 되는 경우가 더 많은 것 같다. (이마저도 소셜 로그인으로 회원가입하는 경우가 압도적으로 많기도 했던 듯 하지만 여하튼.) 유저가 회원가입하는 이유가 지금 롸잇나우 바로지굼당장 서비스를 이용하고 싶은데 회원이 아니라 이용을 못해서이기 때문이라면? 중학교 때 한참 외모에 관심이 많을 때 미쳐라 같은 (ㅋㅋㅋㅋㅋㅋㅋ나 안 늙었지?) 쇼핑몰을 잔뜩 가입할 때가 있었는데 회원가입할 때마다 로그인을 또 해줘야해서 아주 귀찮았던 기억이 있다. 회원가입 직후 로그인이 바로 되는 것이 UX 측면에서 훨씬 낫다고 생각한다.

그래서 로그인 담당 백엔드이신 준기님께 우리 프로젝트에서도 이메일 회원가입 후 바로 로그인이 되도록 구현하자고 제안했고, 결과적으로 성공할 수 있었다!

우리의 sign-up flow는 아래와 같다.

1) 클라이언트 측에서 계정 정보 유효성 검사 후 (나는 react-hook-form 사용함) post 요청으로 계정 정보를 전송. Display name도 보내야 한다. (sign-in일 경우 Display name 생략)
2) DB에 계정 생성 후 (sign-in일 경우 유효한 회원인지 서버 측에서 확인 후) access token과 refresh token을 발급 받음.
이 때 access token은 payload에, refresh token은 http only 쿠키에 담겨 있도록 구현했다. 사용자 인증을 안전하게 관리하기 위한 일반적인 방식이라고 한다.

굳이? why?

  • 둘 다 local storage에 저장? -> 토큰을 탈취 당할 위험 커짐. 보안 측면에서 최악인 방법 (공격자가 악성스크립트를 심어 본래 유저로 위장하여 서버에 개인정보를 요청하는 XSS 공격 위험 커짐)
    access token은 만료 기간이라도 짧지, refresh token은 비교적 만료 기간이 길기 때문에 자바스크립트로 접근할 수 없는 http only cookie로 구현하기로 결정했다.

4) access token은 클라이언트에서 자체 암호화하여 local storage에 저장

  • 이 때 cryptoJS 라이브러리를 사용하여 access token 암호화, 복호화 로직을 짰다.

5) getMe 함수를 호출하여 유저정보 get 요청
6) 유저 정보를 받으면 userInfo store에 저장

아래는 오랜만에 피그마로 손 떨면서 만든 diagram이다.

sign-up flow (회원가입 직후 로그인되는 흐름 가정)

sign-in flow

sign-up과 sign-in의 차이가 뭔지 처음에 아주 헷갈렸는데, 사실 상 DB에서 계정을 새로 생성하는 건지, 아님 유효한 회원 여부를 검사하는 건지만 다르고 나머지는 흐름이 동일하다.

++ 프로젝트 끝나고 gif 추가하러옴

아래처럼 회원가입 하자마자 로그인이 되도록 구현해놓았다.

2. 자동 로그인

App.tsx에서 useEffect를 사용하여 페이지가 전환될 때마다 getMe 함수 (유저 정보 get 요청)를 호출하는 방식이다.

++ 프로젝트 끝나고 gif 추가하러옴

창을 껐다가 다시 들어가도 로그인이 되어 있도록 구현해놓았다.

자동 로그인에서는 고려할 케이스가 2가지이다. 이 2가지 케이스를 getMe 함수에 구현해놓았다.

위의 회원가입과 로그인에서는 access token을 발급 받자마자 getMe 함수를 호출한다고 했으므로 무조건 1번 case에 해당한다고 볼 수 있다.

case 1. refresh token이 아직 만료되지 않았을 때

이 때는 response.data에 담겨있는 유저 정보를 store에 잘 저장해놓아야 한다.

// 유저 정보 호출하는 getMe 함수이다. 커스텀 훅(useGetMe)으로 만들었다.
  const getMe = async (): Promise<IUserInfo | null | undefined> => {
    console.log('getMe 호출');
    const encryptedAccessToken: string | null =
      localStorage.getItem(ACCESS_TOKEN);

    if (!encryptedAccessToken) {
      return null;
    }

    const accessToken: string = decryptToken(encryptedAccessToken);
    console.log('마지막으로 찍혀야할 요주의 accessToken', accessToken);

    const headers = {
      'ngrok-skip-browser-warning': 'true',
      Authorization: `Bearer ${accessToken}`,
    };

    // 복호화된 accessToken으로 getMe 호출
    try {
      const response = await axios.get(MembershipUrl.GetMe, {
        headers,
        withCredentials: true,
      });

      const userData = response.data;
      console.log('getMe 호출 후 받아온 userData', typeof userData, userData);

      // 유저 정보 store에 저장
      dispatch(createUserInfo(userData));

      if (!userData || !response?.headers.authorization) {
        return null;
      }

      //   accessToken 재발급
      const newAccessToken: string =
        response.headers.authorization.split(' ')[1];
      if (newAccessToken) {
        localStorage.removeItem(ACCESS_TOKEN);
        localStorage.setItem(ACCESS_TOKEN, encryptToken(newAccessToken));
      }

      return userData;

그리고 access token과 refresh token 모두 재발급 된다. refresh token은 http only 쿠키로 저장되어 있을테니 따로 처리해줄 작업이 없었지만, access token의 경우 local storage에 있는 기존 access token을 삭제하고 새로 발급받은 access token으로 교체해주어야 한다.

근데 처음에 나랑 준기님이랑 서로 공부해온 내용이 좀 달랐던 부분은 refresh token은 만료되지 않았지만 access token은 만료되었을 때이다.

  • 준기님 의견
    access token의 만료일과 무관하게 서버로 올 때마다 재발급해주는 것으로 알고 있습니다.
  • 나의 의견
    access token이 만료가 아직 안되었으면 굳이 재발급해줄 필요 없는 것 아닌가요?!

찾아보니 두 가지 다 유효한 접근 방식이라고 한다. 이 부분에서는 준기님의 방식을 따르기로 했다. access token의 유효기간이 더 짧아지기 때문에 보안 측면에서 더 나은 방식이라고 한다.

case 2. refresh token이 만료된 경우

local storage에서 access token을 지워버리고 로그아웃 상태가 되도록 한다.

    } catch (error: any) {
      // refresh token 만료 시 로그아웃 처리
      if (error?.response?.status === 401) {
        localStorage.removeItem(ACCESS_TOKEN);
        dispatch(deleteUserInfo());

3. 로그아웃

초간단하다. local storage에 있는 access token만 지워주면 끝난다.

  const handleLogout = () => {
    localStorage.removeItem(ACCESS_TOKEN);
    dispatch(deleteUserInfo());
    navigate('/');
  };

이 함수를 log out 버튼에 onClick 연결해주면 끝!

4. 회원 탈퇴

역시 초간단하다. 로그아웃과 달리 서버에 delete 요청을 보내야 하는 점만 다르다.

  const handleWithdrawal = async () => {
    localStorage.removeItem(ACCESS_TOKEN);
    await axios.delete(MembershipUrl.Withdrawal + `/${memberId}`);
    dispatch(deleteUserInfo());
    navigate('/');
  };

이 함수도 역시 버튼에 onClick 연결해주면 끝!

이렇게 보니까 무슨 엄청 쉽게 로그인을 척척 구현한 것처럼 써놨지만, 당연히 크고 작은 어려움들이 있었다. 그 중 가장 끈질기게 내 발목을 잡은 것은 sign up 구현할 때 3번이나 등장했던 CORS 에러였다. 그래서 다음 포스팅에서는 아예 CORS 에러 특집으로 기록을 남겨보려고 한다. 씨유쑨~

profile
기록에 진심인 개발자 🌿

0개의 댓글

Powered by GraphCDN, the GraphQL CDN