React NativeでExpo Secure Storeを使う
目次
React Nativeでは安全にデータを保管する方法がありません。保存したものはコードベースで保管されるため、バンドルを解析するとプレーンテキストでアクセスができてしまいます。つまりトークンなど機密情報の保管ができない。一方、プラットフォームは以下のようなセキュアな保管手段を用意しています。
- iOS: keychain service
- Android: Secure Shared Prefernces
これらをReact Nativeで扱うにはreact-native-keychain、Expo環境の場合はexpo-secure-storeを使うことが多いようです。前者は生体認証の取扱いやセキュリティレベル・ストレージの暗号タイプも指定できます。その代わりプラットフォームごとにAPIが異なります(共通も有)。両者とも単純な用途ならば大体似たような構文になります。
今回、ExpoプラットフォームではないReact NativeでExpo SDKのライブラリを使用するということを試してみたかったので、expo-secure-storeを利用してみました。
準備
Expoを導入せずExpo SDKのライブラリを使用したい場合は、unimodulesをインストールします。
yarn add react-native-unimodules expo-secure-store
今回利用しているパッケージのバージョンは以下です。
- react-native: 0.64.1
- expo-secure-store: 10.1.0
- react-native-unimodules: 0.13.3
以下、いくつかファイルを編集する必要があります。長いので今回はAndroid版のみ記述します。
android/build.gradle
buildscript {
ext {
// update
buildToolsVersion = "29.0.3"
minSdkVersion = 21
compileSdkVersion = 30
targetSdkVersion = 30
昨今、ビルド時にTask :expo-permissions:compileDebugKotlin FAILED
が表示されることがありますが、そういった場合はAndroid SDK Managerを使用して最新のBuild Toolsに更新すると解決されることがあります。SDK Build ToolsはAndroid/Sdk/build-tools
以下にあります。
もしくは、上記エラーならばreact-native-unimodules
のバージョンを下げると大体解決できます。
android/app.build.gradle
apply plugin: "com.android.application"
// add
apply from: '../../node_modules/react-native-unimodules/gradle.groovy'
...
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "com.facebook.react:react-native:+"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
// add
addUnimodulesDependencies()
android/settings.gradle
rootProject.name = 'ex508Sample'
// add
apply from: '../node_modules/react-native-unimodules/gradle.groovy'; includeUnimodulesProjects()
android/app/src/main/java/com/<projectname>/MainApplication.java
package com.ex508sample;
// add (com.myprojectxは自身のパッケージ名に合わせる)
import com.myprojectx.generated.BasePackageList;
import android.app.Application;
import android.content.Context;
import com.facebook.react.PackageList;
import com.facebook.react.ReactApplication;
import com.horcrux.svg.SvgPackage;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.soloader.SoLoader;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
// add
import java.util.Arrays;
import org.unimodules.adapters.react.ModuleRegistryAdapter;
import org.unimodules.adapters.react.ReactModuleRegistryProvider;
import org.unimodules.core.interfaces.SingletonModule;
public class MainApplication extends Application implements ReactApplication {
// add
private final ReactModuleRegistryProvider mModuleRegistryProvider =
new ReactModuleRegistryProvider(new BasePackageList().getPackageList(), null);
...
private final ReactNativeHost mReactNativeHost =
...
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
// add
List<ReactPackage> unimodules = Arrays.<ReactPackage>asList(
new ModuleRegistryAdapter(mModuleRegistryProvider)
);
packages.addAll(unimodules);
return packages;
}
既にプロジェクトをビルドしている場合は、以下のコマンドを実行してgradleをリセットします。
cd android
./gradlew clean
実際の例
フォームからキーと値を保管して、あとから取得できるかを確認できる簡易な例を試します。
キーと値を入力する画面をHome、取得する画面をVaultとして、Stackナビゲーションを作成します。
// App.js
import React from 'react';
import {NavigationContainer} from '@react-navigation/native';
import {createStackNavigator} from '@react-navigation/stack';
import Home from './screens/Home';
import Vault from './screens/Vault';
const Stack = createStackNavigator();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen name="Home" component={Home} />
<Stack.Screen name="Vault" component={Vault} />
</Stack.Navigator>
</NavigationContainer>
);
}
各スクリーンをラップするコンポーネントを作成します。これはコンテナとなる全体のレイアウトです。また、デバイスがSecure Storeが使えるかの判断をisAvailableAsync()
で行っています。true
が返れば使用可。
// components/WrapContainer.js
import React, {useState, useEffect} from 'react';
import {StyleSheet, View} from 'react-native';
import * as SecureStore from 'expo-secure-store';
import {Alert} from 'react-native';
const WrapContainer = ({children}) => {
const [flag, setFlag] = useState();
useEffect(() => {
async function check() {
// trueなら使用できる
const f = await SecureStore.isAvailableAsync();
setFlag(f);
}
check();
}, []);
if (flag === false) {
Alert.alert('Secure Storeが使用できません');
}
return <View style={styles.container}>{children}</View>;
};
const styles = StyleSheet.create({
container: {
flex: 1,
alignContent: 'center',
margin: 16,
},
});
export default WrapContainer;
HomeではExpo Secure StoreのsetItemAsync(key, name)
でキー・バリューを保存します。それ以外は単なるフォームです。また、レイアウトにはreact-native-elementsを利用していますが、React Nativeデフォルトのコンポーネントを利用しても問題ありません。
// srreens/Home.js
import React, {useState} from 'react';
import {Text, Button, Input} from 'react-native-elements';
import * as SecureStore from 'expo-secure-store';
import WrapContainer from '../components/WrapContainer';
async function save(key, value) {
await SecureStore.setItemAsync(key, value);
}
export default function Home({navigation}) {
const [key, onChangeKey] = useState('');
const [value, onChangeValue] = useState('');
return (
<WrapContainer>
<Text>キーペアを保存します</Text>
<Input
clearTextOnFocus
onChangeText={text => onChangeKey(text)}
value={key}
placeholder="key"
/>
<Input
clearTextOnFocus
onChangeText={text => onChangeValue(text)}
value={value}
placeholder="value"
/>
<Button
title="Save"
onPress={() => {
save(key, value);
onChangeKey('');
onChangeValue('');
}}
disabled={key.longth === 0 || value.length === 0 ? true : false}
/>
<Button
title="Vault"
onPress={() => navigation.navigate('Vault')}
containerStyle={{marginTop: 10}}
/>
</WrapContainer>
);
}
最後はキーから値を取得するVaultスクリーンです。キーを受けつけるフォームを用意し、入力後にEnterを押したらSecure StoreのgetItemAsync(key)
で指定したキーの値を取得します。ここはreact-native-keychainと異なります。
また、削除用のフォームも設けています。削除はdeleteItemAsync(key)
です。
// srcreens/Vault.js
import React from 'react';
import {Text, Input} from 'react-native-elements';
import {Alert} from 'react-native';
import * as SecureStore from 'expo-secure-store';
import WrapContainer from '../components/WrapContainer';
async function getValueFor(key) {
if (key.length === 0) {
return;
}
let result = await SecureStore.getItemAsync(key);
if (result) {
Alert.alert('あなたの値 \n' + result);
} else {
Alert.alert('そのキーは保存されていません');
}
}
async function removeKey(key) {
if (key.length === 0) {
return;
}
await SecureStore.deleteItemAsync(key);
Alert.alert('削除完了');
}
export default function Vault() {
return (
<WrapContainer>
<Text>取得したいキーを入力後、Enterを押してください</Text>
<Input
onSubmitEditing={event => {
getValueFor(event.nativeEvent.text);
}}
placeholder="Your Key"
/>
<Text>削除したいキーを入力後、Enterを押してください</Text>
<Input
onSubmitEditing={event => {
removeKey(event.nativeEvent.text);
}}
placeholder="Your Key"
/>
</WrapContainer>
);
}
以上です。例外処理などは記述していませんが、単純な構文だけで基本的な実装ができました。実際のアプリケーションではトークンなどの保存で利用することになりそうです。
今回利用した3つのメソッドは、プラットフォームに対応したキーチェーン用のオプションが用意されています。詳しくはドキュメントにて。