Firebase storageのセキュリティルールについて

この記事は、[株式会社エイチームフィナジー](https://finergy.a-tm.co.jp/) の [Advent Calendar 2019 17日目](https://qiita.com/advent-calendar/2019/ateam-finergy)です。

## はじめに
今回エイチームフィナジーのAdvent Calendar 2019 17日目を担当します @okkuyama です。
最近はめっきり寒くなり外に出かけることが億劫で、もっぱら休日は自宅でお菓子作りなどをやっています。この前は米粉でドーナッツを作ったのですが、小麦とはちがいもっちりサクサクでなかなかいける味になりました。意外に簡単につくれるのでぜひ試してほしいです。

----

今回はReactでログイン機能があるWebアプリケーションを作る想定で、ログインしたユーザーが各々プライベートファイルをクラウドにアップする機能を実装する場合のセキュリティルールの設定方法と、注意すべき点をまとめました。

## Firebase storageとは
その前のFirebase storageについて紹介。
正式には`Cloud Storage for Firebase`と呼びGoogleが提供するクラウドストレージサービスで、AWSのS3に相当するサービスと言えばわかりやすいかもしれません。

価格も東京（日本）リージョンでS3と比較しても、

| サービス | 月額費用 <br>(1GB/月) | 参考URL |
| ------- | ------- | ------- |
| Firebase storage | $0.023 | https://aws.amazon.com/jp/s3/pricing/ |
| Amazon S3 | $0.025 | https://cloud.google.com/storage/pricing |

以上のように、ほぼ同等費用で利用可能なのでFrebaseでアプリケーションのバックエンドを組む場合は、必須のストレージサービスといえるでしょう。


## Firebase storageでやりたいこと
今回クラウドストレージでのファイル管理でやりたかったのは、以下のようなセキュリティルールを敷いて、利用ユーザーがアップロードするファイルをセキュアに保ちたいと考えています。
![スクリーンショット 2019-12-16 2.47.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316898/3133effb-e2f6-939c-cff6-2351097e9aa5.png)


## Firebase storage rulesでセキュリティルール設定
上記のセキュリティを実現するためには、以下のようなセキュリティルールを設定します。

※ セキュリティルールはWEBコンソール上からもできますが`firebase-tools`を使って、`storage.rules`ファイルで設定出来るようにしておくとgit管理下にできるので便利です。

```
service firebase.storage {
  match /b/{bucket}/o {
    match /public/{allPaths=**} {
      allow read, write: if request.auth!=null;
    }
    match /users/common/{allPaths=**} {
      allow read, write: if request.auth!=null;
    }
    match /users/{userId}/{allPaths=**} {
      allow read, write: if request.auth.uid == userId;
    }
  }
}
```


## 実際に構築して確認してみる

### Firebaseプロジェクト作成
- 以下のURLからGoogleアカウントでサインインしてプロジェクトの作成を行います。
    - https://firebase.google.com/?hl=ja
    - プロジェクト名を入力後、規約チェックしてプロジェクト作成します。
- Webアプリを作成
    - プロジェクト作成後の画面で`</>`アイコンをクリックしてWebアプリを作成します。
    - Webアプリ作成後に表示される`firebaseConfig`の設定項目をメモしておきます。
- Authenticationを有効化
    - コンソールメニューの`Authentication`を選択して有効化を行ってください。
    - その後、ログイン方法で`Google`を選択して有効にしてください。
- Storageを有効化
    - コンソールメニューの`Storage`を選択して有効化を行ってください。
    - Rulesの設定を聞かれるので、上記のrulesを設定してください。
- Hostingを有効化
    - コンソールメニューの`Hosting`を選択して有効化を行ってください。


### Firebase CLIの利用
Firebaseの設定はWEBコンソールで完結できますが、設定内容をGitで管理したいのと、コマンドラインから簡単にデプロイしたいため`Firebase CLI`をインストールしておきます。

- CLIインストール
`firebase-tools`をグローバルでインストールする

```bash
$ yarn global add firebase-tools
```

- コンソールからFirebaseにログイン

```bash
$ firebase login
```
実行するとブラウザ起動後ログイン画面が表示されるのでFirebaseプロジェクトを作成したアカウントでログインを行う。

### Reactアプリケーション作成

#### アプリ初期構築
- Reactの構築は`react-create-apps`を利用する。

```bash
$ npx create-react-app react-firebase-storage-rules-test
```

##### firebaseの初期化
- 先程インストールした`firebase-tools`で初期化する

```bash
$ firebase init
```
コマンド実行後、以下のような対話式で質問されるので次のように設定する。

- Which Firebase CLI features do you want to setup for this folder
    - 以下の項目を上下カーソルで選択してスペースキーで選択してリターン
        - Hosting: Configure and deploy Firebase Hosting sites
        - Storage: Deploy Cloud Storage security rules
- Select a default Firebase project for this directory
    - 先程作成したプロジェクト名を選択してリターン
- What do you want to use as your public directory?
    - デフォルト`public`を使用、そのままリターン
- Configure as a single-page app (rewrite all urls to /index.html)
    - `Y`を選択
- File public/index.html already exists. Overwrite
    - `n`を選択

##### Firebaseモジュールのインストール

```bash
$ yarn add firebase
```

##### 動作確認
取り急ぎ初期設定が完了したので`build`して`deploy`を実施してみます。

- ビルド

```bash
$ yarn build
```
※ ビルドは`build`フォルダに一式保存されるため、`firebase.json`の`public`項目値を`build`に変更しておきます。

```json:firebase.json
{
  "hosting": {
    "public": "build",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ]
  },
  "storage": {
    "rules": "storage.rules"
  }
}
```

- デプロイ

```bash
$ firebase deploy
```
デプロイが成功すると、コンソールにURLが表示されるのでそのURLにアクセスしてみます。

- 以下のような画面がFirebaseのhosting先から閲覧できたらOKです。

![スクリーンショット 2019-12-16 1.23.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316898/c9005102-bbee-c1f2-0e0f-9626190f441e.png)


### Firebase初期化
アプリを初期化できたのでいよいよFirebaseの実装に入ります。
最初にFirebaseをアプリから利用できるように初期化をしていきます。
先程FirebaseコンソールでWebアプリ作成時に取得した`firebaseConfig`情報を使用してFirebaseの初期化を行います。

- `src/firebase.js`を新規作成して、以下のように設定。

```react:src/firebase.js
import firebase from 'firebase/app'
import 'firebase/auth'     // 認証機能利用のためimport
import 'firebase/storage'  // storage利用のためimport

const firebaseConfig = {
  apiKey: "******",
  authDomain: "******.firebaseapp.com",
  projectId: "******",
  storageBucket: "******",
  appId: "******",
}

firebase.initializeApp(firebaseConfig)

export default firebase
```

※ 今回は簡易的な実装のため直接記載していますが、config情報は環境変数で持つようにして、設定値はGitリポジトリに載せないように注意してください。


### 認証機能実装
今回の実装ではログイン済みの認証ユーザーでstorageへのプライベートアクセスを行うため認証機能を先に実装していきます。
認証方法はGoogleアカウントで行うようにしています。
※事前にFirebaseコンソールで`Authentication`を有効にして、ログイン方法で`Google`を有効にしておく必要があります。

```react:src/App.js
import React, {useState, useEffect} from 'react'
import firebase from './firebase'

const App = () => {
  const [user, setUser] = useState(null)

  // initial
  useEffect(() => {
    firebase.auth().onAuthStateChanged(user => {
      setUser(user)
    })
  }, [])

  // ログイン処理
  const handleLogin = () => {
    const provider = new firebase.auth.GoogleAuthProvider()
    firebase.auth().signInWithRedirect(provider)
  }
  // ログアウト処理
  const handleLogout = () => {
    firebase.auth().signOut()
  }

  // render
  return (
    <div>
      <p>UID: {user && user.uid}</p>
      {user ? (
        <button onClick={handleLogout}>Logout</button>
      ) : (
        <button onClick={handleLogin}>Login</button>
      )}
    </div>
  )
}

export default App
```

### storageアップロード、ダウンロード実装
以下の要件でアップロード、ダウンロード機能を実装する。

- HTMLのformよりファイルをFirebase storageにアップロードする。
- storage側で保存するファイル名を指定できるようにする。
- 指定した保存ファイル名で参照ダウンロードができる。

```react:src/Form.js
import React, {useState} from 'react'
import firebase from './firebase'

const Form = (props) => {
  const [file, setFile] = useState(null)
  const [fileName, setFileName] = useState('sample')
  const [fileUrl, setFileUrl] = useState('')

  // storage保存ファイル名を取得
  const handleSetFileName = (e) => {
    setFileName(e.target.value)
  }
  // アップロードファイル選択
  const handleSetFile = (e) => {
    setFile(e.target.files[0])
  }
  // ファイルアップロード処理
  const handleUpload = () => {
    const storageRef = firebase.storage().ref(props.storageRef).child(`${fileName}.png`)
    storageRef.put(file)
    .then((snapshopt) => {
      console.log('file uploaded')
    })
  }
  // ファイルダウンロード処理
  const handleDownload = () => {
    const storageRef = firebase.storage().ref(props.storageRef).child(`${fileName}.png`)
    storageRef.getDownloadURL()
    .then((url) => {
      setFileUrl(url)
    })
  }

  // render
  return (
    <div>
      <div>
        <label>保存ファイル名指定</label>
        <input type="text" onChange={handleSetFileName} />
      </div>
      <div>
        <label>アップロードファイル選択</label>
        <input type="file" onChange={handleSetFile} />
        <button onClick={handleUpload}>画像アップロード</button>
      </div>
      <div>
        <label>アップロード画像表示（保存時のファイル名指定が必要）</label>
        <button onClick={handleDownload}>画像表示</button>
        <div>
          {fileUrl && <img src={fileUrl} alt="" />}
        </div>
      </div>
    </div>
  )
}

export default Form
```

### 認証パターン別にファイルアップロード・ダウンロードを検証
先程作成した`Form`コンポーネントを利用して`App.js`を以下のように修正します。

※ `<Form storageRef="users/A****" />`の`A****`の箇所は実際のログインユーザーのUIDを指定が必要です。

```react:src/App.js
import React, {useState, useEffect} from 'react'
import firebase from './firebase'
import Form from './Form'

const App = () => {
  const [user, setUser] = useState(null)

  // initial
  useEffect(() => {
    firebase.auth().onAuthStateChanged(user => {
      setUser(user)
    })
  }, [])

  // ログイン処理
  const handleLogin = () => {
    const provider = new firebase.auth.GoogleAuthProvider()
    firebase.auth().signInWithRedirect(provider)
  }
  // ログアウト処理
  const handleLogout = () => {
    firebase.auth().signOut()
  }

  // render
  return (
    <div>
      <p>UID: {user && user.uid}</p>
      {user ? (
        <button onClick={handleLogout}>Google Logout</button>
      ) : (
        <button onClick={handleLogin}>Google Login</button>
      )}
      <hr />
      <h3>公開フォルダ</h3>
      <Form storageRef="public" />
      <hr />
      <h3>認証済ユーザー共通フォルダ</h3>
      <Form storageRef="users/common" />
      <hr />
      <h3>認証済ユーザーA プライベートフォルダ</h3>
      <Form storageRef="users/A****" />
      <hr />
      <h3>認証済ユーザーB プライベートフォルダ</h3>
      <Form storageRef="users/B****" />
      <hr />
    </div>
  )
}

export default App
```

#### UI画面
`yarn start`実行で、以下のようなサンプル画面が表示されます。
![スクリーンショット 2019-12-16 1.21.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316898/540f6343-11b9-f316-9b10-6156c0f7a2f0.png)


### 実行結果
実行した結果はrulesで設定したセキュリティルール通りの動きになりました。

| フォルダ | 未認証ユーザー | 認証済ユーザーA | 認証済ユーザーB |
| --- | --- | --- | --- |
| 公開フォルダ | ○ | ○ | ○ |
| 認証済ユーザー共通フォルダ| ✗ | ○ | ○ |
| 認証済ユーザーA<br>プライベートフォルダ | ✗ | ○ | ✗ |
| 認証済ユーザーB<br>プライベートフォルダ | ✗ | ✗ | ○ |

### 注意点！ 
#### token付きURLの扱いに注意する。
Firebase storageでは`getDownloadURL`を利用して参照可能なファイルurlの取得に認証などでセキュリティをかけれますが、取得したurl情報には**アクセストークン情報**（`token=ab******`）が付与されており、このアクセストークン付きURL情報が流出してしまうと、認証状態関係なく誰でも閲覧できてしまう恐れがあります。

```:以下のように末尾にtoken情報が付与される
/test.png?alt=media&token=ab******
```
一度流出してしまった場合は、Webコンソールから`アクセストークンを取り消す`を押してアクセストークンの再発行を行うと以前のトークン情報は無効となりアクセスを拒否することができます。

### NGルール設定
上で設定したルールを見たとき`users`配下は認証が必要なので以下のようにまとめて設定できそうですが、この設定はNGとなります。

```
service firebase.storage {
  match /b/{bucket}/o {
    match /public/{allPaths=**} {
      allow read, write: if request.auth!=null;
    }
    match /users/{allPaths=**} {
      allow read, write: if request.auth!=null;
    }
    match /users/{userId}/{allPaths=**} {
      allow read, write: if request.auth.uid == userId;
    }
  }
}
```
一見要件通り動作しそうですが、Firebase のrulesは制約がゆるいほうが優先されるらしく、この設定だと`users`配下は認証済であれば読み書きできるようになってしまいます。
つまりユーザーAの人がユーザーBのプライベートフォルダに対して読み書きが自由にできてしまうことになります。
従って多少面倒ですがrules設定は上位側でゆるくかけずに、なるべく末端に対して設定をしていく必要があります。


### まとめ
Firebaseを利用することで、バックエンドをまるっと任せることができるので、アプリケーションの開発をフロントエンド側でほぼ作成していくことが可能となります。
今回紹介したstorageも手軽に扱えて、かつセキュリティルールの設定も簡単に行えるので積極的に使っていきたいサービスだと思います。
ただ、注意点でも説明したとおり、ファイル単位に付与されるアクセストークンの管理を気をつけないと、セキュリティをかけたつもりが誰でも見れるファイルになる危険性があることを認識する必要があります。
WEBアプリケーションの場合はブラウザの開発者ツールやソースコードから簡単にファイルのURL情報を取得することができるので、トークン付きURLの取り扱いは注意が必要と言えます。

-----

[株式会社エイチームフィナジー](https://finergy.a-tm.co.jp/)では、「世の中からお金の不安を解消し、より人生が豊かになる社会を実現する」ことをミッションに、金融領域を中心とした様々なサービスを運営しています。