[Twitter Clone] v0.0.0 Devlog - JWT Authentication (Client Side)

Sierra·2023년 1월 14일
0

Twitter Clone Project

목록 보기
4/4
post-thumbnail

Intro

주의! 필자의 주 영역이 프론트엔드가 아니다보니 코드에 문제가 상당히 많을 수 있습니다. 또한 코드 수정이 매우 자주 일어나고 있으므로 포스팅 내용이 자주 수정될 수 있습니다.

이번 주제는 예고했듯 클라이언트 사이드에서의 JWT Authentication 구현이다. 유즈케이스의 워크플로를 따라가며 구현 했던 JWT 인증 과정들을 살펴보도록 하겠다.

로그인 & 로그아웃

로그인을 한다는 것은 웹 브라우저 상에 아무런 데이터가 존재하지 않는다는 의미다. 브라우저는 현재 누가 접속중이라는 사실을 알지 못한다.

로그아웃 API 요청이 완료 된 이후에는 브라우저 상에서 저장 해 두었던 localStorage 값 등을 삭제하도록 처리 해 두었다. 서버사이드에서는 인증 관련 된 Cookie 값 또한 삭제한다.

로그인 과정부터 살펴보자.

이메일 정보와 패스워드 정보가 입력되어 Sign in 버튼을 클릭하는 유즈케이스를 살펴보자.
로그인 화면 전체 코드는 아래와 같다.

const Auth = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const navigate = useNavigate();
  const dispatch = useDispatch();
  const onChangeInput = (e: React.FormEvent<HTMLInputElement>) => {
    const target = e.currentTarget;

    if (target.name === "email") {
      setEmail(target.value);
    } else if (target.name === "password") {
      setPassword(target.value);
    }
  };

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    requestLogin({
      userEmail: email,
      password: password,
    }).then((res: LoginResponse) => {
      if (res.status === "OK") {
        dispatch(login_success(res));
        alert("로그인 되었습니다.");
        navigate("/");
      } else {
        alert("로그인 에러!");
        dispatch(login_fail());
      }
    });
  };

  return (
    <div>
      <form onSubmit={onSubmit} className="container">
        <input
          name="email"
          type="text"
          placeholder="Email"
          required
          pattern="[a-z0-9]+@[a-z]+.[a-z]{2,3}"
          onChange={onChangeInput}
          className="authInput"
          value={email}
        />
        <input
          name="password"
          type="password"
          placeholder="Password"
          required
          onChange={onChangeInput}
          className="authInput"
          value={password}
        />
        <div>
          <input type="submit" className="formBtn confirmBtn" value="Sign In" />
          <Link to="/signup">
            <button className="formBtn confirmBtn">Sign up</button>
          </Link>
        </div>
      </form>
    </div>
  );
};

onSubmit이 실행되고 requestLogin 의 실행 결과에 따라 Promise 객체의 처리가 진행된다. (Catch 가 왜 처리되지 않았느냐에 대해선...개선하겠다!!) 결과가 제대로 받아졌다면 useDispatch Hook을 통해 로그인 성공 처리, 전달받은 데이터가 에러 데이터라면(지난 포스팅에서 Custom Exception 처리를 통해 서버 상에 에러가 발생했을 시 에러가 났다는 의미의 Response 가 전달된다고 언급했었다.) 실패 처리를 한다.

requestLogin 함수를 살펴보기 전에 CustomAxios 에 대해 한번 언급하고 넘어가겠다. 우리는 JWT Authentication을 사용하기 때문에 기존의 Axios 를 쓰는것도 좋지만, 커스터마이징을 하는 편이 편할 수 있다.

export default axios.create({
  baseURL: process.env.REACT_APP_GCP_API_SERVER,
  headers: {
    "Content-Type": "application/json",
  },
  withCredentials: true,
});

axios 객체를 커스터마이징 해서 export 하였다. CORS 에러 처리를 위해 withCredentials 속성을 true로 처리하였고 응답과 요청간에 데이터를 통일하기 위해 Content-Typeapplicaiton/json으로 설정 해 두었다.

export async function requestLogin(loginForm: loginForm) {
  const response: LoginResponse = await CustomAxios.post(
    "/api/auth/v1/login",
    loginForm
  ).then((res) => res.data);
  return response;
}

CustomAxios 객체를 통해 post가 전달되고 Axios Response 데이터에서 data 부분만 추출하여 리턴한다. 해당 data는 미리 정의 해 둔 Response 객체 형태와 같다.

그럼 로그인 요청이 전달되고 응답을 받는 과정까진 살펴보았다. 응답은 다음과 같다.

{
    "status": "OK",
    "data": {
      	"userInfo" : {
        	"userID" : "userID",
          	"email" : "email",
          	"userName" : "userName",
          	"userRole" : "userRole",
          	"provider" : "provider",
          	"userStatus" : "userStatus",
          	"lastLogin" : "lastLogin"
        }
      	,
        "tokenInfo": {
            "accessToken": "access token",
            "refreshToken": "refresh token"
        }
    },
    "message": "SUCCESS"
}

refreshToken은 굳이 안 넣어도 될 것 같긴 하다...추후 패치를 통해 제외하든 하겠다.

해당 정보들을 LocalStorage에 저장해야하는데 여기서 Redux를 사용하였다. reduxjs를 통해 Reducer와 그에 할당 된 액션들을 생성하였고 login_success나 login_fail 등은 로그인 성공 및 실패에 따라 state를 갱신하고 localStorage 데이터를 갱신하는 역할을 한다.

const userData = localStorage.getItem("USER_INFO");
const user: TwitterUserDTO | null = userData ? JSON.parse(userData) : null;
const initialState: userState = user
  ? {
      isLoggedIn: true,
      user: user,
    }
  : {
      isLoggedIn: false,
      user: null,
    };

const authReducer = createSlice({
  name: "authReducer",
  initialState: initialState,
  reducers: {
    login_success: (state: userState, action: PayloadAction<LoginResponse>) => {
      state.isLoggedIn = true;
      state.user = action.payload.data.userInfo;
      localStorage.setItem(
        "ACCESS_TOKEN",
        action.payload.data.tokenInfo.accessToken
      );
      localStorage.setItem(
        "USER_INFO",
        JSON.stringify(action.payload.data.userInfo)
      );
    },
    login_fail: (state: userState) => {
      state.isLoggedIn = false;
      state.user = null;
    },
    logout: (state: userState) => {
      state.isLoggedIn = false;
      state.user = null;
      localStorage.removeItem("USER_INFO");
      localStorage.removeItem("ACCESS_TOKEN");
    },
    change_username: (
      state: userState,
      action: PayloadAction<EditProfileRes>
    ) => {
      state.user = action.payload.data.userInfo;
      localStorage.setItem("USER_INFO", JSON.stringify(state.user));
    },
  },
});

export const { login_success, login_fail, logout, change_username } =
  authReducer.actions;
export default authReducer.reducer;

자연스럽게 로그아웃 과정 또한 정리가 된다. 로그아웃 시에 사용되는 코드는 다음과 같다.

export async function requestLogout() {
  const response: LogoutResponse = await CustomAxios
    .post("/api/auth/v1/logout")
    .then((res) => res.data);
  return response;
}

토큰 재발급

코드가 영 이상한건 잘 알고있다...일단 돌아는 가게 만들자는 생각으로 코딩하였다. 부족한 프론트엔드 분야 공부가 채워지는대로 다음 패치에 보완하겠다.

CustomAxios.interceptors.request.use((config) => {
  const token = localStorage.getItem("ACCESS_TOKEN");
  if (config.headers !== undefined) {
    config.headers.Authorization = "Bearer " + token;
  }
  return config;
});

CustomAxios.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const responseData: FailureResponse | any = error.response?.data;

    if (responseData.status === "UNAUTHORIZED") {
      if (responseData.data === "Token Expired") {
        const originRequest = error.config;
        await refreshToken().then(() => {
          if (originRequest !== undefined) {
            window.location.reload();
            const result = CustomAxios(originRequest);
            return Promise.resolve(result);
          }
        });
      }
    } else if (responseData.status === "FORBIDDEN") {
      if (responseData.data === "리프레쉬 토큰이 만료되었습니다.") {
        localStorage.removeItem("ACCESS_TOKEN");
        localStorage.removeItem("USER_INFO");
        window.location.replace("/");
      }
    }

    return Promise.resolve(error);
  }
);

axios interceptor 를 통해 request, response 과정에서 axios 객체가 어떤 행동을 해야하는 지 정해줄 수 있다. Token이 Refresh 되어서 변경 되는 상황이 생길 수 있다. refreshToken 함수를 통해 localStorage에 갱신 된 Access Token 데이터를 Request 상황에서 사용할 수 있도록 처리하였고 Response를 받았을 때 실패 상황에서의 Response 형태에 따라 어떤 Status를 가졌는 지 확인 후 에러 메시지를 확인한다. Access Token이 만료되어서 UNAUTHORIZED 에러가 발생하였다면 그에 맞게 refreshToken 함수를 실행하고 다시 기존의 요청을 전달하게 된다.
그게 아니라 리프레쉬 토큰마저도 만료 된 상황이라면 서버 상에서는 사용자의 토큰과 관련 된 정보는 아무것도 없기 때문에 브라우저 상에서 존재하는 유저의 모든 정보를 삭제하고 메인 화면으로 돌아가도록 처리하였다.

export async function refreshToken() {
  await CustomAxios
    .post("/api/auth/v1/reissue")
    .then((res) => {
      const response: ReissueResponse = res.data;
      const newAccessToken = response.data.accessToken;
      localStorage.removeItem("ACCESS_TOKEN");
      localStorage.setItem("ACCESS_TOKEN", newAccessToken);
    })
    .catch((ex) => {
      console.log("Token Reissue Fail : " + ex);
    });
}

reissue API 에 대해서는 따로 설명하지 않겠다.

Outro

Typescript가 아직 안익숙하다는 걸 느낀 초기버전 개발이었다. 중간중간 Devlog를 작성하며 코드 상에 이상한점도 많이 발견하였다. 확실히 개발 할 때와 테스트할 때, 배포하고 회고록 쓸때 보이는게 달라지는 거 보니 아직 갈 길이 먼것 같다.

현재 초기버전에서 UI와 기능들을 개선한, 새로운 기능을 추가한 v1.0.0 개발에 착수하였다. 코드에 대한 건 v1.0.0 가 마무리되는대로 깔끔한 코드로 리뷰 해 볼 예정이다.

다음 포스팅의 주제는 인프라 구축이다. 곧 돌아오겠다.

profile
블로그 이전합니다 : https://swj-techblog.vercel.app/

0개의 댓글