FIRST ARIT, 인증/인가 프로세스

profile
FE Developer - 죠지
June 04, 2023
GPT 요약
Thumbnail

이집트인들은 육신이 죽는다고 모든 것이 끝이라고 생각하지 않았다. 오히려 사후세계를 내세에서 즐거운 삶을 누리기 위해 반드시 통과해야 할 관문이라 여겼다. 하지만 사후세계로 가는 길은 너무나도 험난했는데, 초자연적인 문지기들이 겹겹이 둘러싼 관문과 수로들을 수도 없이 통과해야만 했다. 문지기들은 이름도 하나같이 '뱀을 먹고 사는 자', '피 속에서 춤추는 자' 등 기괴하기 짝이 없었다. 이집트인들은 죽은 영혼이 이들을 무사히 통과하기 위해선 사자의 서에 적힌 주문들을 올바르게 암송해 이 문지기들을 진정시켜야만 한다고 믿었다.

Thumbnail

안녕하세요.
덴티움의 FE Developer 죠지입니다.

오늘의 주제가 인증/인가 프로세스인 만큼 이집트의 사후세계에 대한 이야기로 글을 시작했습니다. 조금 더 덧붙이자면, 사후세계로 가면서 마주치는 7개의 관문을 아리트(Arit)라고 하고, 이를 모두 통과하면 아누비스에 의해 심장의 무게가 재어진 후, 결과에 따라 오시리스의 심판을 받게 된다고 합니다.

현대를 살아가는 우리에게는 허무맹랑한 이야기로 들리지만, 한 가지 주목하고 넘어가야 할 부분이 있습니다. 바로 사자의 서에 적힌 주문들을 올바르게 암송해야만 한다는 점입니다. 이는 우리가 인증/인가 프로세스를 구현할 때, 로그인하는 과정에서 사용자의 정보를 올바르게 전달해야만 한다는 의미로 이해할 수 있습니다.

Authentication & Authorization

AuthenticationAuthorization에 대해 짚고 넘어가겠습니다. Authentication과 Authorization의 단어는 비슷하지만, 의미는 다릅니다. Authentication은 인증으로서 흔히 로그인을 의미하고 , Authorization 즉 인가는 로그인한 사용자가 특정 자원에 접근할 수 있는 권한을 가지고 있는지를 확인하는 과정입니다.

AuthenticationAuthorization
의미인증인가
Talk

저 같은 경우는 두 단어가 헷갈리는 경우가 있어 좀 더 쉽게 이해할 수 있도록 저만의 구분법을 만들어 활용했습니다. Authorization의 경우 발음이 '어서 와'와 비슷하다고 생각해서 신원이 확인된 사람에게만 '어서 와'라고 인사를 하는 것으로 이해했습니다. 그렇기 때문에 인증이 먼저 이루어져야만 인가가 가능하다는 순서도 이해할 수 있고, 두 단어의 의미를 헷갈리지 않고 기억할 수 있었습니다.

First Arit

어떤 서비스든 로그인은 필수입니다. 신원이 확인된 사용자만 서비스에 접근할 수 있도록 하기 위해서입니다. 그리고 이러한 인증 단계는 개발자가 가장 먼저 마주치는 아리트(Arit)이기도 합니다.

Talk

최근에는 OAuth를 활용한 소셜 로그인 방식이 많이 사용되고 있습니다. 이 방식은 사용자가 입력한 아이디와 비밀번호를 서버에서 확인하는 것이 아니라, 소셜 서비스에서 발급한 토큰을 서버에서 확인하는 방식입니다. 이 방식은 서버에 부담이 적다는 장점이 있습니다.

결론부터 이야기하자면 IT팀에서는 로그인을 구현할 때, jwt를 활용했습니다. 세션이나 쿠키를 활용한 방식도 있지만, jwt를 활용한 방식이 더 효율적이라고 판단했기 때문입니다. 또한 jwt를 access token과 refresh token으로 구분하고 2차 로그인 프로세스를 위한 execute token을 추가했습니다.

각 토큰에 대한 내용과 프로세스는 뒤에서 더 자세히 다루도록 하겠습니다.

Hot Debate

jwt를 활용한 로그인 방식에서 token의 저장 위치는 항상 고민이 됩니다. 그리고 아직까지도 포털이나 각종 커뮤니티에서 이에 대한 엇갈린 의견이 있습니다.

로그인을 구현해 보신 분들이라면 알 수 있습니다. 로그인을 구현할 때 가장 먼저 마주치는 문제는 방식 자체를 결정하는 일이 될 수도 있지만, 어디에 토큰을 저장할 것인가도 큰 고민거리입니다. 이와 같은 고민을 IT팀에서도 했고, 그 결과 cookie를 활용하기로 결정했습니다.

이유는 다음과 같이 정리할 수 있습니다.

  • localStorage는 javascript로 접근이 가능해 XSS 공격에 취약한데 반해, cookie는 httpOnly와 secure 속성을 추가해 해결할 수 있습니다.
  • localStorage는 서버에 요청을 보낼 때마다 토큰을 함께 보내야 하지만, cookie는 브라우저가 알아서 처리해주기 때문에 편리합니다.
  • localStorage는 토큰을 저장하기에는 적합하지만, cookie는 토큰 뿐만 아니라 다른 정보도 함께 저장할 수 있어서 더 효율적입니다.
  • cookie에 대한 CSRF 공격은, SameSite 속성을 추가해 해결할 수 있습니다.
Tip

CSRF는 Cross-Site Request Forgery의 약자로, 사용자가 의도하지 않은 요청을 통해 공격자가 원하는 행위를 하도록 유도하는 공격입니다. 특히 사용자가 웹사이트에 로그인한 상태에서 공격자가 원하는 행위를 하도록 유도합니다.

Tip

XSS는 Cross-Site Scripting의 약자로, 공격자가 웹사이트에 악성 스크립트를 삽입하여 공격하는 것입니다. 이 공격은 사용자가 웹사이트에 접속했을 때, 악성 스크립트가 실행되도록 합니다.

The Musketeers(삼총사)

IT팀은 인증/인가 프로세스에 3 가지 토큰을 운용합니다.

  • access token
  • refresh token
  • execute token

앞서 말씀드렸듯이 access token과 refresh token은 1차 로그인, execute token은 2차 로그인을 위해 사용됩니다.

Life Cycle

토큰의 생명주기는 다음과 같습니다.

  • access token: 1시간
  • refresh token: 1주일 ~ 1개월
  • execute token: 24시간

Status Decision

3 가지 토큰을 운용 할 때, 서버는 토큰의 상태를 확인해야 합니다. 그리고 각 토큰의 상태에 따라 다음과 같은 처리를 해야 합니다.

access tokenrefresh tokenexecute tokenserverstatus
OOO-2차 로그인, 앱 실행
OOX-1차 로그인, 앱 미실행
OXO-2차 로그인, 앱 실행
OXX-1차 로그인, 앱 미실행
XOOaccess token 재발급2차 로그인, 앱 실행
XOXaccess token 재발급1차 로그인, 앱 미실행
XXOexecute token 삭제로그아웃, 앱 미실행
XXX-로그아웃, 앱 미실행

복잡하다고 생각하실 수도 있지만, 8가지 경우의 수를 모두 고려해야만 합니다. 각각의 상태에 따라 서버가 처리해야하는 내용이 다르기 때문입니다.

아래는 간단하게 각 경우의 수에 대해 설명드리겠습니다.

첫번째 경우, access token과 refresh token, execute token이 모두 존재하기 때문에 서버는 아무런 처리를 하지 않고, 클라이언트는 2차 로그인을 한 상태에서 앱을 실행합니다.

두번째 경우, access token과 refresh token은 존재하지만, execute token은 존재하지 않습니다. 서버는 아무런 처리를 하지 않고, 클라이언트는 1차 로그인을 유지합니다.

세번째 경우, access token과 execute token은 존재하지만, refresh token은 존재하지 않습니다. 서버는 아무런 처리를 하지 않고, 클라이언트는 2차 로그인을 한 상태에서 앱을 실행합니다.

네번째 경우, access token은 존재하지만, refresh token과 execute token은 존재하지 않습니다. 서버는 아무런 처리를 하지 않고, 클라이언트는 1차 로그인을 유지합니다.

다섯번째 경우, refresh token과 execute token은 존재하지만, access token은 존재하지 않는 경우입니다. 이 경우에는 access token을 재발급 받아야 합니다. 서버는 토큰의 상태를 확인하고, access token을 재발급합니다. 또한 클라이언트는 2차 로그인을 한 상태에서 앱을 실행합니다.

여섯번째 경우, refresh token은 존재하지만, access token과 execute token은 존재하지 않는 경우입니다. 이 경우에는 access token을 재발급 받아야 합니다. 서버는 토큰의 상태를 확인하고, access token을 재발급합니다. 또한 클라이언트는 1차 로그인을 유지합니다.

일곱번째 경우, execute token은 존재하지만, access token과 refresh token은 존재하지 않는 경우입니다. 이 경우에는 execute token을 삭제해야 합니다. 서버는 토큰의 상태를 확인하고, execute token을 삭제합니다. 또한 클라이언트는 로그아웃을 상태를 유지합니다.

여덟번째 경우, access token과 refresh token, execute token이 모두 존재하지 않는 경우입니다. 이 경우에는 토큰의 상태를 확인할 필요가 없습니다. 서버는 아무런 처리를 하지 않고, 클라이언트는 로그아웃을 상태를 유지합니다.

Flow Chart

토큰의 상태를 확인하는 과정을 flow chart로 표현하면 다음과 같습니다.

Thumbnail

간단하게 설명을 덧붙이자면 apollo client는 access token만 감시하면서 true & false를 반환합니다. 이는 전역으로 관리되어 어디서든 접근이 가능합니다. 만약 처음 상태 이 후에 토큰의 상태가 변한다면, apollo client는 이를 감지하고, 다시 true & false를 반환합니다. 그리고 해당 토큰 상태를 defendency로 관리하는 AuthObserver에서 Auth_Me를 호출하여 서버에서 토큰 상태를 확인합니다.

Auth Observer

이제부터는 토큰의 상태를 확인하는 과정을 코드로 구현해 보겠습니다.

먼저 위치입니다. 최상위 Apollo Provider 바로 아래에서 토큰의 상태를 감시하는 Auth Observer를 작성합니다.

<ApolloProvider client={apolloClient}>
      <AuthObserver>
             {....}
             <Component {...pageProps} />
             {....}
      </AuthObserver>
</ApolloProvider>

이미 토큰이 존재하는 경우의 수를 모두 살펴 보아서 알고 계시겠지만, 클라이언트는 access token의 존재만 알고 있으면 됩니다. 나머지는 서버에서 처리해주기 때문입니다. 그리고 access token을 쿠키에서 확인하여 makeVar()로 관리합니다. 이 부분은 apollo client가 담당하고 있습니다.


// AuthObserver.tsx

const AuthObserver = ({ children }) => {
  //isLoggedVarValue가 true인 경우에는 로그인한 상태이고,
  //false인 경우에는 로그아웃한 상태입니다.
  const isLoggedVarValue = isLoggedInVar();

  // authReducer는 넘겨받은 상태에 따라 다음과 같은 처리를 합니다.
  // 2차 로그인, 앱 실행
  // 1차 로그인, 앱 미실행
  // 로그아웃, 앱 미실행
  const authReducer = (status: string) => {
    switch (status) {
      ...
      break
    }
  };

  // Auth_MeMutation은 서버에 상태 진단을 요청합니다.
  const [Auth_MeMutation, { loading }] = useMutation<Auth_MeMutation, Auth_MeMutationVariables>(AUTH_ME_MUTATION, {
    onCompleted: (data) => {
      const Auth_Me = data?.Auth_Me;
      if (Auth_Me.ok) {
        authReducer(Auth_Me.status);
      } else {
        isLoggedInVar(false);
      }
    },
  });
  // isLoggedVarValue가 변하는 것만 확인하면 되기 때문에
  // useEffect의 dependency에는 isLoggedVarValue만 추가합니다.
  useEffect(() => {
    Auth_MeMutation();
  }, [Auth_MeMutation, isLoggedVarValue]);

  return (
    <div>
      {loading ? (
        <Screen>
          <h1>페이지를 이동하지 마세요.</h1>
        </Screen>
      ) : (
        children
      )}
    </div>
  );
};

export default AuthObserver;

실제 코드가 동작하는 과정을 살펴보면 다음과 같습니다.

  1. AuthObserver가 렌더링됩니다.
  2. isLoggedInVar()를 통해 access token의 존재 여부를 확인합니다.
  3. Auth_MeMutation을 호출하고 서버에 상태 진단을 요청합니다.
  4. 서버에서 토큰의 상태를 확인합니다.
  5. 서버에서 반환한 상태를 authReducer에 전달합니다.
  6. authReducer는 상태에 따라 다음과 같은 처리를 합니다.
    • 2차 로그인, 앱 실행
    • 1차 로그인, 앱 미실행
    • 로그아웃, 앱 미실행
  7. useEffect의 dependency로 isLoggedInVar()를 감시합니다.
  8. 변경이 감지되면 2번부터 다시 반복합니다.

앞으로의 과제

이번 글에서는 인증/인가 프로세스를 구현하는 과정에서 고민한 내용을 공유했습니다. 하지만 토큰 3개를 운용하는 과정에서도 여전히 해결해야하는 과제가 있습니다.

만약에 사용자의 수가 만명을 넘어간다면, 토큰의 수도 만개가 넘어갈 것입니다. 이는 서버의 부하를 증가시킬 수 있습니다. 그렇기 때문에 레디스를 활용해 refresh token 관리하거나 최대한 부하를 분산시킬 수 있는 방법을 고민해야 합니다.

Reference

Authentication on the We이라는 영상입니다. 기본적인 인증/인가 프로세스에 대해 설명하고 있습니다. 관심있는 분들은 한번쯤 보시는 것도 좋을 것 같습니다.

jwt에 대해 좀 더 자세히 다룬 영상입니다. jwt를 활용한 로그인 방식을 구현할 때, 참고하시면 좋을 것 같습니다.

profile
안녕하세요 👏
FE Developer 죠지입니다.
githubgithubgithub