firebaseのセキュリティールールを設定してみる

プライベートで作成したfirebaseのアプリのセキュリティールールを設定したので紹介します。

## firebase.jsonにルールを設定

``` firebase.json
{
  "hosting": {
    ......
  },
  "firestore": {
    "rules": "firestore.rules"
  }
}
```

## ルールを記述

デフォルトで`firestore.rules`ファイルが存在しなかったので、自分で生成しました。

[Firebase入門 フリマアプリを作りながら、認証・Firestore・Cloud Functionsの使い方を学ぼう！](https://employment.en-japan.com/engineerhub/entry/2019/06/07/103000)を参考に以下のルールを設定しました。

``` firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /users/{userId} {
      allow get: if true;
      allow list: if request.query.limit <= 20;
      allow create: if isUserAuthenticated(userId);
    }

    match /questions/{questionId} {
      allow get: if true;
      allow list: if request.query.limit <= 30;
      allow create: if isAuthenticated() && validateQuestion(request.resource.data);
    }
  }

  function validateQuestion(question) {
    return question.size() <= 8 &&
      question.answers.size() >= 2 &&
      question.category is number &&
      question.deadline is timestamp &&
      question.difficulty is number &&
      question.title is string &&
      question.title.size() >= 2 &&
      question.user is map &&
      question.userId is string &&
      question.userId == request.auth.uid;
  }

  function isAuthenticated() {
    return request.auth != null;
  }

  function isUserAuthenticated(userId) {
    return request.auth.uid == userId;
  }
  
}
```

### オペレーションについて

[Cloud Firestore 詳細なオペレーション](https://firebase.google.com/docs/firestore/security/rules-structure?hl=ja#granular_operations)

> readルールはgetとlistに分割でき、writeルールはcreate、update、deleteに分割できます。


|オペレーション |説明  |
|---|---|
|get  |単一のドキュメントの読み取り|
|list  |クエリーによる取得|
|create  |作成|
|update  |更新|
|delete  |削除|

### はまった箇所

以下のように30件以下の質問取得する場合であってもlimitをつける必要があります。

```
// DBに入っている値が10件でもlimitを指定してないのでエラー
questionRef.get();
// 30件以下のクエリーのためOK
questionRef.limit(10).get();
```

## ルールをデプロイ

```
firebase deploy --only firestore:rules
```

記述したルールはデプロイが完了するまで有効化されません。

ルールのデプロイは30秒ほどかかるので細かいミスをして修正 -> デプロイ -> 修正 -> デプロイ .. などしてるとすぐに1時間くらい経過してしまいます。

## ルールのテスト

デプロイの時間を節約するのと、正しくルールが設定できるか保証するためにテストを追加します。

### エミュレーターの起動

ローカル環境でテストを行うためにエミュレーターを起動します。

```
firebase emulators:start --only firestore
```

エミュレーターを起動しようと思ったら、Javaがないよといわれたのでインストールしました。

```
brew cask install java
```

### テストの記述

今回はvue-cliで生成したプロジェクトなのでもともとあるtests以下に`firestore.spec.ts`を追加しました。 

``` tests/firestore.spec.ts
import * as firebase from '@firebase/testing';
const fs = require('fs');

const projectId = 'firestore-local-emulator-test'
const rulesFilePath = 'firestore.rules'

// 認証付きのFreistore appを作成する
const createAuthApp = (auth?: any): firebase.firestore.Firestore => {
  return firebase
    .initializeTestApp({ projectId, auth })
    .firestore();
};

// 管理者権限で操作できるFreistore appを作成する
const createAdminApp = (): firebase.firestore.Firestore => {
  return firebase.initializeAdminApp({ projectId }).firestore();
};

// user情報への参照を作る
const usersRef = (db: firebase.firestore.Firestore) => db.collection("users");

describe("Firestoreセキュリティルール", () => {
  // はじめに１度ルールを読み込ませる
  beforeAll(async () => {
    await firebase.loadFirestoreRules({
      projectId: projectId,
      rules: fs.readFileSync(rulesFilePath, 'utf8')
    })
  })

  // test毎にデータをクリアする
  afterEach(async () => {
    await firebase.clearFirestoreData({ projectId })
  })

  // 全テスト終了後に作成したアプリを全消去
  afterAll(async () => {
    await Promise.all(firebase.apps().map(app => app.delete()))
  })

  test("usersの読みが可能", async () => {
    const db = createAuthApp(null);
    const user = usersRef(db).doc("test");
    await firebase.assertSucceeds(user.get());
  });

  test("認証がない場合はusersへ書き込みが失敗", async () => {
    const db = createAuthApp();
    const user = db.collection("users").doc("meta");
    await firebase.assertFails(
      user.set({text: "test"})
    );
  });

  test("認証がある場合はusersへ書き込みが成功", async () => {
    const db = createAuthApp({ uid: "meta" });
    const user = db.collection("users").doc("meta");
    await firebase.assertSucceeds(
      user.set({text: "test"})
    );
  });

  test("認証がない場合、questionsへ書き込みが失敗", async () => {
    const db = createAuthApp();
    const question = db.collection("questions").doc();
    await firebase.assertFails(
      question.set({
        answers: [1, 2],
        category: 1,
        deadline: firebase.firestore.Timestamp.now(),
        difficulty: 1,
        title: "test",
        user: {},
        userId: "1234"
      })
    );
  });

  test("認証があっても、バリデーションが不正な場合はquestionsへ書き込みが失敗", async () => {
    const db = createAuthApp({ uid: "meta" });
    const question = db.collection("questions").doc();
    await firebase.assertFails(
      question.set({
        answers: [1],
      })
    );
  });

  test("認証があり、バリデーションが正しい場合はquestionsへ書き込みが成功", async () => {
    const db = createAuthApp({ uid: "meta" });
    const question = db.collection("questions").doc();
    await firebase.assertSucceeds(
      question.set({
        answers: [1, 2],
        category: 1,
        deadline: firebase.firestore.Timestamp.now(),
        difficulty: 1,
        title: "test",
        user: {},
        userId: "meta"
      })
    );
  });

});
```

### isを使用して確認できるデータ型

|オペレーション |説明  |
|---|---|
|string|文字列|
|int|数値|
|float|浮動小数点数|
|bool|真理値|
|null|null|
|timestamp|firebase.firestore.Timestamp|
|list|配列|
|map|マップ|

## Cloud Functions for Firebaseの設定


Cloud Functionsも使ってみたので一部コードを紹介します。
`onUpdateQuestion`は`/questions/{questionId}`に変更があった際に動く関数になります。

``` functions/src/index.ts
import config from './config';
import * as functions from 'firebase-functions';
const admin = require("firebase-admin");
const FieldValue = require('firebase-admin').firestore.FieldValue;
admin.initializeApp({
  credential: admin.credential.cert(config.credential),
  databaseURL: config.url
});

export const onUpdateQuestion = functions.firestore.document('/questions/{questionId}')
.onUpdate(async(change, context) => {
    const newValue = change.after.data();
    const questionId = context.params.questionId;
    if (!newValue) {
      return;
    }
    const correctAnswers = newValue.answers.map((answer: any) => {
      if (answer.isCorrect) {
        return answer.id
      }
    });
    
    const voteRef = admin.firestore().collection('votes');
    const userRef = admin.firestore().collection('users');
    voteRef.where('questionId', '==', questionId).get().then((querySnapshot: any) => {
      querySnapshot.forEach(async (vote: any)  => {
        // voteの質問を更新
        await voteRef.doc(vote.id).update({
          question: newValue,
        });
        // 経験値
        const exp = newValue.difficulty * 100;
        // 正解したユーザーの処理
        if( correctAnswers.includes(vote.data().answerId) ) {
          await userRef.doc(vote.data().userId).update({
            answerCount: FieldValue.increment(1),
            correctAnswerCount: FieldValue.increment(1),
            exp: FieldValue.increment(exp),
          });
        } else {
          await userRef.doc(vote.data().userId).update({
            answerCount: FieldValue.increment(1),
          });
        }
      });
    });
});
```

questionドキュメント更新
↓
questionIdが一致するvotesのquestionを更新 
↓
正解であればusersのanswerCount・correctAnswerCount・expを加算、間違いの場合answerCountのみ加算

## さいごに

Firebaseを使えばフロント：バック=8:2くらいの作業感だったので、フロント好きの私にとってはFirebaseは最高のサービスでした。（今まで使ってこなかったことを後悔）