본문 바로가기

Front-End

[React Native] react-native-keychain 사용하기

React Native로 작업하고 있는 프로젝트에서 토큰 저장방식을 바꿔보려고 한다. 

사실 앱 제작은 처음이라 웹에서 구현했던 것 처럼 토큰을 관리할 때 local storage를 사용하면 되는 줄 알았다.

그래서 local storage와 같은 역할을 할 수 있는 async-storage 라이브러리를 사용해 userName, token등 필요한 데이터들을 저장해서 관리하고 있었다.

 

그러다가 웹과 마찬가지로 interceptor를 만들어 토큰 갱신로직을 추가하려고 앱의 토큰 관리 방법을 찾아보다보니 토큰은 async-storage에 저장하는 것을 지양하고 좀 더 안전한 저장소를 써야 한다는 글들을 많이 보게 되었다.

 

우선 React Native의 공식문서를 읽어보자

https://reactnative.dev/docs/security

 

Security · React Native

Security is often overlooked when building apps. It is true that it is impossible to build software that is completely impenetrable—we’ve yet to invent a completely impenetrable lock (bank vaults do, after all, still get broken into). However, the prob

reactnative.dev

 

 

요약해보면...

Async Storage

  • web의 Local Storage와 같다
  • 비동기적이며 암호화되지 않은 키-값 저장소를 제공한다.
  • Async Storage는 앱 간에 공유되지 않으며, 각 앱은 자체의 격리된 환경을 가지며 다른 앱의 데이터에 액세스할 수 없다.

사용해야 할 때:

  • 앱 실행 간에 민감하지 않은 데이터를 지속시킬 때
  • Redux 상태를 지속시킬 때
  • GraphQL 상태를 지속시킬 때
  • 전역적인 앱 변수를 저장할 때

사용하면 안 되는 경우:

  • 토큰 저장
  • 비밀 정보 저장

 

Secure Storage

React Native는 민감한 데이터를 저장하는 방법을 기본적으로 제공하지 않지만 iOS 및 Android 플랫폼에 사전에 구축된 솔루션을 사용할 수 있다.

 

iOS 키체인 서비스 또는 Android 안전한 공유 환경설정을 사용하려면 직접 브릿지를 작성하거나 래핑하여 통합 API를 제공하는 라이브러리를 사용해야 합니다. 고려해볼 수 있는 몇 가지 라이브러리는 다음과 같습니다:

  • expo-secure-store
  • react-native-encrypted-storage - iOS에서는 키체인을 사용하고 Android에서는 EncryptedSharedPreferences를 사용합니다.
  • react-native-keychain
  • react-native-sensitive-info - iOS에서는 안전하며 Android에서는 기본적으로 안전하지 않은 공유 환경설정을 사용합니다. 그러나 Android Keystore를 사용하는 브랜치가 있습니다.
  • redux-persist-sensitive-storage - Redux용으로 react-native-sensitive-info를 래핑합니다.

 

라는 것...!

 

 

결론

: Async Storage는 key-value값을 암호화없이 plain text로 가지고 있기 때문에, 루팅된(jailbroken) 기기처럼 물리적 접근 권한이 있는 상태라면 데이터에 쉽게 접근할 수 있다. 토큰을 저장하는 것은 위험하다. 암호화된 Secure Storage 사용을 고려해보자.

 

 

npm trend에서 위에 제시된 라이브러리들의 다운로드 현황을 검색해봤다.

react-native-keychain이 가장 인기가 많아보인다.

Unpacked Size도 205 kB로 작은 편이고 최근 publish도 2달 전으로  최근까지 업데이트가 되어있다.

 

더 찾아보니 배민 커넥트앱에서도 react-native-keychain을 쓴다는 글이..!

 

이 라이브러리가 기본적으로 각 디바이스에서 지원하는 가장 높은 수준의 보안을 자동으로 선택해 사용한다고 한다.

 

후,, 그렇다면 react-native-keychain을 새로 설치하고 코드를 뜯어 고쳐보자

설치는

npm install react-native-keychain

이후에 pod install, npm link도 해줘야 했다.

 


이전 코드

 

react-native-async-storage만 사용해 USER라는 key로 사용자의 정보를 저장했다.

import AsyncStorage from '@react-native-async-storage/async-storage';
import { User } from './types';

const USER_LOCAL_STORAGE_KEY = 'USER';

const useUserAsyncStorage = {
  async get() {
    try {
      const user = await AsyncStorage.getItem(USER_LOCAL_STORAGE_KEY);
      return user ? JSON.parse(user) : null;
    } catch (error) {
      console.error('Error retrieving user:', error);
      return null;
    }
  },

  async set(user: User) {
    try {
      await AsyncStorage.setItem(USER_LOCAL_STORAGE_KEY, JSON.stringify(user));
    } catch (error) {
      console.error('Error saving user:', error);
    }
  },

  async remove() {
    try {
      await AsyncStorage.removeItem(USER_LOCAL_STORAGE_KEY);
    } catch (error) {
      console.error('Error removing user:', error);
    }
  },
};

export default useUserAsyncStorage;

 

그리고 React Query를 이용해 이 USER 데이터를 전역상태로 관리했다.

import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import useUserAsyncStorage from './useUserAsyncStorage';

const useUser = () => {
  const { data: user, isLoading } = useQuery({
    queryKey: ['user'],
    queryFn: () => useUserAsyncStorage.get(),
    staleTime: 300000,
    gcTime: 0,
  });

  useEffect(() => {
    const updateUserAsyncStorage = async () => {
      try {
        if (user) {
          await useUserAsyncStorage.set(user);
        } else {
          await useUserAsyncStorage.remove();
        }
      } catch (error) {
        console.error('Error updating user in AsyncStorage:', error);
      }
    };

    if (!isLoading) {
      updateUserAsyncStorage();
    }
  }, [user, isLoading]);

  return {
    user: user || null,
  };
};

export default useUser;

 

로그인 부분에서 user 데이터를 이런 식으로 set해주면 useUser에서 쓴 코드대로 데이터가 async-storage로 저장될 것이다.

import { useMutation, useQueryClient } from '@tanstack/react-query';
import userAPI, { loginPayload } from '../../../apis/userAPI';

const useSignIn = () => {
  const queryClient = useQueryClient();
  const signInMutation = useMutation({
    mutationFn: (payload: loginPayload) => {
      return userAPI.login(payload);
    },
    onSuccess: (data) => {
      queryClient.setQueryData(['user'], {
        username: data.user.username,
        token: data.token.access,
        // 기타 필요한 데이터
      });
    },
  });
  return { signInMutation };
};

export default useSignIn;

 

flipper로 스토리지를 까보면

USER라는 키의 값으로 username, token이 들어와 있는 것을 볼 수 있다.

 

username은 일단 두고 토큰을 분리해 keychain 저장소로 옮기자...

 


 

keychain을 적용한 수정 코드

 

'user' 로 쓰던 키는 'user_name'으로 바꿔서 username만 Async Storage로 저장하고

accessKey와 refreshKey는 keychain으로 각각 저장했다.

전에는 user로 한번에 묶어서 스토리지에 저장했지만, 이제는 user name, access key, refresh key 각각을 불러오는 함수로 useUser에서 값을 가져오게 했다.

import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Keychain from 'react-native-keychain';
import { User } from './types';

const USER_NAME_STORAGE_KEY = 'USER_NAME';
const ACCESS_TOKEN = 'ACCESS_TOKEN';
const REFRESH_TOKEN = 'REFRESH_TOKEN';

const useUserStorage = {
  async getUserName() {
    try {
      const username = await AsyncStorage.getItem(USER_NAME_STORAGE_KEY);
      return username || '';
    } catch (error) {
      console.error('Error retrieving user:', error);
      return '';
    }
  },

  async getAccessToken() {
    try {
      const credentials = await Keychain.getGenericPassword({ service: ACCESS_TOKEN });
      console.log('credentials  :', credentials);
      return credentials ? credentials.password : '';
    } catch (error) {
      console.error('Error retrieving access token:', error);
      return '';
    }
  },

  async getRefreshToken() {
    try {
      const credentials = await Keychain.getGenericPassword({ service: REFRESH_TOKEN });

      return credentials ? credentials.password : '';
    } catch (error) {
      console.error('Error retrieving refresh token:', error);
      return '';
    }
  },

  async set(user: User) {
    try {
      await AsyncStorage.setItem(USER_NAME_STORAGE_KEY, user.username);
      await Keychain.setGenericPassword(ACCESS_TOKEN, user.token.access, {
        service: ACCESS_TOKEN,
      });
      await Keychain.setGenericPassword(REFRESH_TOKEN, user.token.refresh, {
        service: REFRESH_TOKEN,
      });
    } catch (error) {
      console.error('Error saving user:', error);
    }
  },

  async remove() {
    try {
      await AsyncStorage.removeItem(USER_NAME_STORAGE_KEY);
      await Keychain.resetGenericPassword({ service: ACCESS_TOKEN });
      await Keychain.resetGenericPassword({ service: REFRESH_TOKEN });
    } catch (error) {
      console.error('Error removing user:', error);
    }
  },
};

export default useUserStorage;

 

import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import useUserStorage from './useUserStorage';

interface Token {
  access: string;
  refresh: string;
}

interface UserData {
  username: string;
  token: Token;
}

interface User {
  userData: UserData | null;
  isLoading: boolean;
}

const useUserQuery = () => {
  return useQuery<UserData | undefined>({
    queryKey: ['user'],
    queryFn: async () => {
      const username = await useUserStorage.getUserName();
      const accessToken = await useUserStorage.getAccessToken();
      const refreshToken = await useUserStorage.getRefreshToken();
      return {
        username,
        token: { access: accessToken, refresh: refreshToken },
      };
    },
    staleTime: 300000,
    gcTime: 0,
  });
};

const useUser = (): User => {
  const { data: userData, isLoading } = useUserQuery();

  return {
    userData: userData || null,
    isLoading,
  };
};

export default useUser;

 

로그인 코드는 이렇게 바뀌었다.

const useSignIn = () => {
  const queryClient = useQueryClient();
  const signInMutation = useMutation({
    mutationFn: (payload: loginPayload) => {
      return userAPI.login(payload);
    },
    onSuccess: (data) => {
      const user = {
        username: data.user.username,
        token: {
          access: data.token.access,
          refresh: data.token.refresh,
        },
      };
      useUserStorage.set(user);
      queryClient.invalidateQueries({ queryKey: ['user'] });
    },
  });
  return { signInMutation };
};

 

이전에는 query data를 먼저 set하고 그걸 useEffect로 감지해서 스토리지의 데이터를 업데이트했다면, 지금은 스토리지의 데이터를 먼저 set하고 query data를 업데이트 하는 방식으로 바꾸었다. 뭐가 더 나은거지... 사실 모르겠다.

 

이제 토큰들은 async storage에서 접근할 수 없게 되었다! 다음에는 access token 만료시에 refresh token으로 token을 갱신받아오는 코드를 작성해야한다. ㅎ

 

 

 

 

지금 고민되는 것은 이럴 바에 그냥 user name과 기타 데이터들(위 코드에는 없음)을 user data로 묶어서 keychain으로 저장하는 게 나은가 싶긴한데(근데 그럼 토큰 갱신을 받아올 때  user data를 꺼내서 일부분만 수정하고 다시 저장해야함) 뭐가 맞는 방법인지 잘 모르겠다... 지나가다 아시는 분이 있다면 help~