본문 바로가기
Projects,Activity/TaskStock(RN)

[TaskStock] 인증/인가 로직 설계하기 with JWT

by 그냥하는거지뭐~ 2024. 3. 28.

프롤로그

프로젝트  TaskStock
기간  기획, 개발 : 2023.11.7~
베타테스트 : 2024.2.22~3.14
정식 출시 : 2024.3
플랫폼 iOS, Android
언어 FE: TypeScript / BE: JavaScript
프레임워크  FE: React Native / BE: Node.js Express
라이브러리 Redux Toolkit

 
TaskStock은 실제로 사용자를 받을 것을 염두하고 만든 서비스다 보니 보안에 신경을 많이 썼다. 우리가 JWT를 쓰면서 어떤 고민을 거쳐 어떤 로직을 설계했는지 소개해보려고 한다. 


로직 설계 과정

1. 일반로그인 로직 설계 

이메일, 사용자 이름, 비밀번호를 받고 이메일은 unique하다. 토큰에는 유저 id가 포함되고, 개인정보와 관련된 것들은 최대한 토큰에 포함시키지 않았다. 로그인 or 회원가입을 하면 AT(accesstoken)와 RT(refreshtoken) 토큰을 발급받게 되는데, 문제는 여기서 발생한다. AT가 만료되면 RT 유효성 검사 후 재발급을 받으면 되지만, RT가 만료됐을 경우에는 재로그인을 시켜야 한다. 재로그인을 시키는 방법은 두 가지가 있는데, 사용자가 직접 다시 로그인을 하도록 첫 화면으로 보내거나, 사용자 몰래 백그라운드에서 재로그인을 시키는 방법이 있다. 사용자 경험을 고려하면 당연히 후자를 선택해야 한다. 
후자는 로그인 시 입력해야 하는 정보(이메일, 비밀번호)를 로컬 저장소에 저장해 둔 후에 RT가 만료되면 그 정보를 가지고 로그인 api 요청을 보낸다. 그런데 이메일과 비밀번호는 굉장히 민감한 정보인데 async storage에 저장하는 것이 보안적으로 문제가 되지 않을까? 하는 우려가 있었고, 우리는 react-native-keychain이나 react-native-secure-storage와 같은 라이브러리를 사용해 암호화해서 저장하는 방법으로 해결했다. 

import * as SecureStore from "expo-secure-store";
import { getAPIHost } from "../getAPIHost";

// 사용자의 로그인 정보를 안전하게 저장하는 함수
export const saveCredentials = async (email: string, password: string) => {
  try {
    await SecureStore.setItemAsync(
      "userCredentials",
      JSON.stringify({ email, password })
    );
  } catch (error) {
    console.error("Error saving credentials: ", error);
    throw error;
  }
};

// 저장된 로그인 정보를 가져오는 함수
export const getSavedCredentials = async () => {
  try {
    const credentialsString = await SecureStore.getItemAsync("userCredentials");
    return credentialsString ? JSON.parse(credentialsString) : null;
  } catch (error) {
    console.error("Error retrieving credentials", error);
    return null;
  }
};

// 로그인 함수
export const loginWithCredentials = async (email: string, password: string) => {
  // 서버에 로그인 요청을 보내고, 토큰을 반환 받는 로직
  try {
    const SERVER_URL = getAPIHost();
    const response = await fetch(`${SERVER_URL}account/login/email`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email, password }),
    });
    console.log("=====이메일 자동로그인 성공=====");
    const data = await response.json();
    return data;
  } catch (error) {
    console.error("자동로그인 에러 ", error);
  }
};

 

2. 소셜로그인과 로직을 통일하며 생긴 문제

TaskStock은 구글, 카카오, 애플로그인을 지원한다. 소셜로그인은 client에서 라이브러리(구글의 경우 @react-native-google-signin/google-signin)를 사용해서 회원 정보를 받는다. 각 회사에서 제공받은 회원 정보를 서버에 전송하면 서버는 자체 토큰을 만들고 그 후는 일반로그인 프로세스와 동일하게 진행한다. 

그런데 생각해니까.. 소셜로그인은 이메일과 비밀번호를 따로 입력하는게 아니니까 백그라운드에서 자동로그인을 시킬 수 없는데..? 

 
알아보니 자동로그인을 구현하려면 라이브러리 자체에서 제공하는 방법을 써야한다. (구글의 경우 signInSilently()가 있음) 즉, 서버는 토큰 관리를 따로 하지 않고 클라이언트에서 구글에 직접 토큰 갱신을 요청하는 것이다. 이 말은 일반로그인과 소셜로그인의 로직을 분리해야 한다는 뜻이다. 하지만 우리는 회원 관리나 토큰 재발급 로직을 일반로그인과 통일하고 싶어서 소셜로그인 유저도 자체 토큰으로 관리하기로 했다. 

3. 보안 vs 사용자 경험 

정리해보자. AT(accesstoken)가 만료되면, 클라이언트의 RT(refreshToken)와 서버의 RT를 비교해서 일치하면 새 AT를 발급한다. RT가 만료됐을 경우, 새 토큰을 발급받아야 하기 때문에 재로그인을 시켜야 하지만, 사용자 경험을 해치기 때문에 일반로그인의 경우 keyChain에 이메일과 비밀번호를 암호화해서 저장해서 사용자 몰래 로그인을 한다. 하지만 소셜로그인의 경우 키체인에 이메일/비밀번호를 저장해 자동로그인을 시킬 수 없다.
찾아보니 현업에서의 선택지는 위에서 설명한 로그인 유도 or 백그라운드 자동로그인(몰래)인 것 같고, 보안을 위해 로그인 유도를 선택하는 것 같다. 하지만 일반적으로 서비스되는 앱에서 재로그인을 시키는 경우를 거의 보지 못해서 RT가 만료됐을 때 사용자 몰래 재로그인을 시킬 수 있는 다른 방법이 있는지 궁금했다.
소셜로그인 시 몰래 재로그인이 어렵다면 선택지는 다음과 같다.

  • CASE1: RT의 만료기간을 길게 가져간다. 이 경우 사용자는 어쩔 수 없이 언젠간 재로그인을 해야한다.
  • CASE2: RT의 만료기간을 아예 없앤다. 이 경우 토큰이 탈취되면 망하는건데 보안 걱정 없이 쓸 수 있는 다른 방법이 있는지, 현업에서도 이 선택지를 고려하는지 궁금했다.

팀원들끼리 고민하다가 현업에 계신 선배님께 직접 여쭤보는 게 확실할 것 같아서 도움을 요청했다. 

소중한 조언을 토대로 우리는 RT의 만료 기간을 1년 정도로 길게 가져가는 CASE1을 선택했다. 시간 상 구현하지는 못했지만, 선배님 말씀대로 모니터링 시스템을 잘 구축하여 비정상적인 접근이 관측시, 해당 유저의 RT를 즉시 만료 시키는 방식으로 자동화하는 작업도 필요해 보인다.

4. client에서 토큰을 어디에 저장해야 할까? 로컬저장소? Redux? 

access token 저장 위치
accessToken은 여러 컴포넌트에서 사용한다. asyncStorage에서 그때그때 가져와 쓰는 것도 가능하지만, asyncStorage는 디스크 기반의 비동기 저장소이므로 데이터를 읽는 데 시간이 걸릴 수 있다. 이로 인해 성능 저하가 발생할 수 있으므로 민감한 정보이지만 redux에도 저장했다. 
 
refresh token 저장 위치
AT 재발급을 진행할 때 DB에 저장된 RT와 client의 RT를 비교해서 유효성 검사를 한다. 그러면 클라이언트 측에서도 RT를 저장해야 한다는 뜻인데, asyncStorage에 RT를 저장하는게 과연 안전할까?
다른 방법이 있나 생각해보자.

Client에서 RT를 직접 저장하지 않고 userId나 다른 식별 정보를 서버에 보내 RT를 검증하는 방식

 
이건 토큰 기반 인증 시스템의 기본 원칙과 다르게 작동한다. 장점으로는 client측에서 RT를 저장하지 않으므로 장치가 탈취되거나 해킹당했을 경우 RT가 노출될 위험이 줄어든다. 하지만 userId와 같은 식별 정보만을 기반으로 토큰을 갱신하는 것은 RT의 본질적인 보안 이점을 감소시킨다. 또한 해커가 userId를 알아낸 경우, 시스템 보안이 쉽게 뚫릴 수 있다. 즉, 전반적인 보안과 시스템의 복잡성 면에서는 기존의 방식이 더 안전하고 효율적일 수 있겠다는 결론에 다다랐다. 

asyncStorage에 저장하는게 일반적이지만, 암호화되지 않고 저장되는게 정 불안하다면

 
RT를 로컬 저장소에 저장 할 때 react-native-secure-storage나 react-native-keychain과 같은 라이브러리를 사용해 암호화한 값을 저장한다. 또한, RT는 앱 내에서 필요한 경우에만 접근해서 사용해야 한다. 즉, 불필요하게 넓은 범위에서 접근 가능하게 만들지 않아야 하므로, RT는 전역(redux)에서 관리하지 않고 asyncStorage에만 저장하는 방식을 선택했다. 
 
accessExp, refreshExp 저장 위치
만료 시간은 비교적 덜 민감한 정보이므로 redux에 저장해도 무방하다
 
정리

accessToken => asyncStorage, redux에 저장
refreshToken => asyncStorage에 저장
accessExp, refreshExp => redux에 저장

 

5. 여러 기기 대응

에뮬레이터를 여러 개 키고 작업하다가 에러가 자꾸 나서 여러 기기 대응까지 해줘야 된다는 사실을 알게 되었다. 그러면 기기별로 RT를 만들어야 하고 그렇게 되면 한 유저가 여러 개의 RT를 가지게 된다. 우리 나름대로 로직을 짰지만, 현업에서 하는 방식도 궁금해 선배님께 한 번 더 확인했다. 

찾아보니 react-native-device-info라는 라이브러리로 기기별 고유 id를 받아올 수 있어서 이걸로 기기를 식별했다. 

import DeviceInfo from "react-native-device-info";

const getDeviceId = async () => {
  const deviceId = await DeviceInfo.getUniqueId();
  return deviceId;
};

export default getDeviceId;


6. 그래서 완성된 로직 

- 이메일은 unique

- 이미 회원가입이 된 이메일로 회원가입을 시도한다면 막기 (회원가입 시도하는 이메일로 DB 쿼리해서 있다면 거부)

- 토큰에 포함되는 정보: 유저 id, device id (여러기기 대응)

- 클라이언트에서 fetch 보내기 전 access token 의 만료시간을 체크해서 15분 이하로 남아있으면(만료 포함) 서버에 재발급 요청 후 다시 fetch

 

회원가입 (이메일)

[client]
- 사용자이름, 이메일, 비밀번호, device id POST

[server]
- JWT 생성
- {accessToken, refreshToken, accessExp, refreshExp} client 에 전송 
- {기기 id, refreshToken}은 또 따로 db에 저장 (유효성 검사 시 사용) => user_id를 참조하는 일대다 관계로 Token 테이블 형성

[client]
- 받은 accessToken, refreshToken을 asyncStorage에 저장, 만료시간들은 따로 기록

 

소셜로그인

[client]
- 앱에서 구글(카카오, 애플) 로그인
- 로그인 성공 시 구글(카카오, 애플)이 유저정보 제공
- 서버로 유저정보 전송

[server]
- client에서 받은 유저정보를 저장 저장하기 전 같은 이메일로 가입된 계정 있는지 확인
- 자체 accessToken과 refreshToken을 생성, accessExp, refreshExp와 같이 ⇒ client에 전송

[client]
- 받은 accessToken, refreshToken을 asyncStorage에 저장, 만료시간들은 따로 기록

다른점: 소셜로그인은 구글에서 제공한 회원정보를 서버에 전송한다. 서버에서 자체 토큰을 만들고, 그 후는 일반로그인과 프로세스 동일
참고사항: 애플은 처음 1회만 회원정보(이메일, 이름 등등)를 준다. 따라서 user identifier(user)를 email로 사용 - 기능 중 유저의 이메일을 반드시 알아야 하는게 있다면 다른 방식 고려 필요 (별도로 저장?)
 

로그인 (이메일)

[client]
- 이메일, 비밀번호, 기기 id POST

[server]
- db에서 해당 이메일을 가진 사용자를 찾고, 저장된 해시된 비밀번호와 비교, 일치하면 JWT 생성
- {accessToken, refreshToken, accessExp, refreshExp} client 에 전송
- {기기 id, refreshToken}은 또 따로 db에 저장 (유효성 검사 시 사용)

[client]
- 받은 accessToken, refreshToken을 asyncStorage에 업데이트, 만료시간들은 따로 기록
- 이후의 모든 요청에서 저장된 JWT를 HTTP 요청의 Authorization 헤더에 포함시켜 서버에 전송

 

토큰 만료 및 갱신

만료기간
- accessToken: 1시간 ⇒ 만료 15분 전 재요청 (한 유저가 사이트에 머무르는 시간의 평균 + a)
- refreshToken: 1년 ⇒ 만료 7일 전 재요청 (일반적으론 2주~4주이지만, 사용자 경험을 위해 최대한 늘림)
 
accessToken 갱신

[server]
- JWT를 생성할 때 exp 클레임을 설정하여 토큰의 만료 시간을 지정
exp는 UTC 기준 Unix 시간스탬프 형식 (ex.1623074400과 같은 숫자로 표현)

[client]
- 만료시간 기록, 만료시간이 다가오면 서버에 새 토큰 요청
- 저장된 refreshToken 전송

[server]
- client에서 받은 refreshToken과 db에 저장된 refreshToken 비교 (유효성검사)
- 일치하면 and 유효하면(secret key로 decode) 새 accessToken 발급
- client에 {accessToken, refreshToken, accessExp, refreshExp} 업데이트해서 전송

[client]
- 받은 accessToken을 asyncStorage, redux에 업데이트, accessExp redux에 업데이트

 
refreshToken 갱신
다시 로그인시킴
 
어플리케이션이 비활성화인 상태에서 accessToken만 만료되었다면?
- 어플리케이션 재활성화 시 저장된 accessToken의 만료 상태를 확인
- refreshToken이 여전히 유효한지 확인. 유효하다면, 이를 사용하여 새로운 accessToken을 요청
 
어플리케이션이 비활성화인 상태에서 accessToken, refreshToken이 모두 만료되었다면?
로그인 화면으로 보낸다 ⇒ 보안상 좋음. 대신 refreshToken 만료기간을 길게 늘린다.
 
 

7. 사용자 경험을 위한 추가적인 노력

최근에 로그인한 기록(구글로 로그인했는지, 이메일로 로그인했는지..)을 보여줌으로써 사용자 경험을 높였다. 처음 회원가입 시 어떤 방법으로 회원가입했는지 strategy를 asyncStorage에 저장했고, 이후에 로그아웃을 통해 첫 화면에 들어오면 asyncStorage에서 값을 읽어와 값이 있으면 해당 기록을 화면에 보여주는 방식으로 구현했다. 
strategy는 db에도 저장해서 예를 들어 누군가가 example@gmail.com으로 '이메일로 계속하기' 방법으로 회원가입한 기록이 있는데, 해당 이메일로 가입된 구글 계정으로 '구글로 계속하기'를 클릭한다면, "해당 이메일은 이미 '이메일로 계속하기' 방식으로 가입된 계정입니다" 라는 안내 문구를 띄웠다. 
 



구현 도중 발생한 문제

client에서 구현 도중 발생한 문제에 대해 몇 가지 소개해 보려고 한다. 

1. 토큰 유효성 검사 함수와 api utils의 분리

우리 프로젝트에서는 api 호출 함수를 따로 utils 파일로 만들어 사용하고 있다. client.post(~~), client.get(~~) 이런식으로 간단하게 쓸 수 있는데, 어차피 모든 api 요청을 보내기 전에 client측에서 AT, RT가 유효한지 검사를 해야 하니 api utils 함수 안에서 checkAndRenewTokens 함수를 실행시키면 안될까? 하는 생각이 들어 코드를 짜고 실행시켜봤다. 에러가 났다. 
 
문제는 checkAndRenewTokens 함수가 redux toolkit의 createAsyncThunk를 사용하여 정의되었고,이 함수를 실행하기 위해서는 dispatch가 필요하다. dispatch를 사용하려면 useDispatch 훅을 호출해야 하는데, 훅은 React 함수 내부에서만 사용해야 하고, React 함수 안에서는 최상위(at the top level)에서만 사용할 수 있다는 규칙이 있다. 따라서 utils 함수 안에서 직접 사용하는 것이 불가능하다. 
useClient라는 훅을 만들어서 해결했다. 이 훅은 컴포넌트 안에서만 사용 가능하다는 단점이 있다. (thunk 함수에서는 사용불가)

import { client } from "../services/api";
import { checkAndRenewTokens } from "../utils/authUtils/tokenUtils";

interface IClient {
  body?: any;
  accessToken?: string;
  [key: string]: any;
}

export const useClient = (dispatch) => {
  const wrappedClient = async (
    endpoint,
    { body, accessToken, ...customConfig }: IClient = {},
    method = undefined
  ) => {
    const tokenResult = await dispatch(checkAndRenewTokens());
    console.log("[useClient] tokenResult: ", tokenResult.payload);

    const AT = tokenResult.payload?.accessToken
      ? tokenResult.payload.accessToken
      : accessToken;

    const config = method ? { ...customConfig, method } : customConfig;
    return client(endpoint, { body, accessToken: AT, ...config });
  };

  const clientMethods = {
    get: (endpoint, customConfig: { [key: string]: any } = {}) =>
      wrappedClient(endpoint, { ...customConfig, method: "GET" }),

    post: (endpoint, body, customConfig: { [key: string]: any } = {}) =>
      wrappedClient(endpoint, { ...customConfig, body, method: "POST" }),

    delete: (endpoint, body, customConfig: { [key: string]: any } = {}) =>
      wrappedClient(endpoint, { ...customConfig, body, method: "DELETE" }),

    patch: (endpoint, body, customConfig: { [key: string]: any } = {}) =>
      wrappedClient(endpoint, { ...customConfig, body, method: "PATCH" }),

    put: (endpoint, body, customConfig: { [key: string]: any } = {}) =>
      wrappedClient(endpoint, { ...customConfig, body, method: "PUT" }),
  };

  return clientMethods;
};

 
컴포넌트 안에서는 다음과 같이 사용했고, 

import { useClient } from "./useClient";

...
const dispatch = useAppDispatch();
const client = useClient(dispatch);
...

 
createAsyncThunk로 정의한 함수에서는 토큰 검사 함수를 직접 dispatch하는 방식으로 분리했다. 

import { createAsyncThunk } from "@reduxjs/toolkit";
import { client } from "../../services/api";
import { TRootState } from "../../store/configureStore";
import { checkAndRenewTokens } from "../authUtils/tokenUtils";

export const setPrivateThunk = createAsyncThunk(
  "user/setPrivateThunk",
  async (isPrivate: boolean, { rejectWithValue, getState, dispatch }) => {
    await dispatch(checkAndRenewTokens());
    const rootState = getState() as TRootState;
    const { accessToken } = rootState.auth;

    try {
      const data = await client.patch(
        "sns/private",
        {
          private: isPrivate,
        },
        {
          accessToken: accessToken,
        }
      );
     ...
    } catch (error) {
      ...
    }
  }
);

 
이 방법의 문제점은 두 가지가 있다. 첫째, 각 thunk 함수 내에서 토큰 검사 함수를 잊지 않고 실행시켜줘야 한다. 유지 보수 차원에서 부담이 있을 수 있을 것 같다. 둘째, utils 함수와 useClient라는 커스텀 훅을 동시에 사용하면서 코드가 과도하게 복잡해지는 경향이 있다. 프로젝트 구조 파악이 어렵고 가독성이 떨어지지는 않을까 걱정이 된다. 
 
더 좋은 방법이 있을 것 같은데 조금 더 고민해봐야겠다. 
 

2. 애플로그인의 변수

애플은 처음 1회만 회원정보(이메일, 이름 등등)를 준다. (애플놈들..) 그래서 애플 유저들은 user identifier (appleAuthRequestResponse.user)를 email 대신 식별자로 사용했다. 기능 중 유저의 이메일을 반드시 알아야 하는게 있다면 다른 방식을 고려해야할 듯 하다. 
 


마치면서

AT와 RT를 재발급받는 시점, 유효기간을 검사하는 시점 등등 프로젝트가 우선시하는 사용자 경험이 무엇인지에 따라 설계해야하는 로직이 다 다르다. 선택의 기로에 놓일 때마다 우리 프로젝트에 맞는 길을 선택하기 위해서 팀원들과 머리를 맞대어 고민을 정말 많이 했고, 선배님께 조언도 구하면서 우리 나름대로 최선의 로직을 설계했다고 생각한다. 그 과정에서 "모든 기술에는 장단점이 존재하고, 단점은 다양한 방법으로 극복해야 한다"는 것을 가장 크게 느꼈다. 
우리 프로젝트에서 설계한 로직이 완벽하다고는 할 수 없다. 비정상적인 접근에 대한 모니터링 시스템이 구축되지 않았고, 1년 후에 refresh token이 만료되면 재로그인을 해야 하는 등의 보완해야 할 문제가 많다. 우리가 느낀 jwt의 단점이나 로직상의 빈틈을 극복하기 위해서 계속 공부해봐야겠다. 


 

‎TaskStock

‎주식 그래프로 보는 나의 가치 - 할 일을 완료하면 나의 가치가 상승하며, 즉각적으로 그래프에 기록됩니다. - 정산은 매일 자정에 이루어집니다. - 상승하는 그래프를 보며 성취감은 덤 - 직관

apps.apple.com

 

TaskStock - Google Play 앱

미라클 모닝 2000원, 독서 2시간 5000원, 달성할 수록 올라가는 나의 가치! 직접 시장에 상장되어 스스로의 가치를 올려보세요!

play.google.com

 

'Projects,Activity > TaskStock(RN)' 카테고리의 다른 글

[TaskStock] Atomic Design Pattern 도입기  (1) 2024.03.27