Firebaseを業務システムで使う場合の認証設計

# 背景と目的

Firebaseでは、認証についても自分でカスタマイズすることで、一般的なシステムで使われるような認証を実装することもできます。

しかし、Firebaseのデフォルトの認証方式は、一般的なシステムの認証方式とは大きく異なっています。たとえば、ユーザが自由にアカウントを作成し、ログインできることが想定されており、これと異なる認証方式を実現するためには、カスタマイズが必要です。しかし、このカスタマイズの方法自体も多くがあり、どの方法を取るか迷うことになってしまいます。本稿では、Firebaseで業務システムで一般的にで使われるような認証方式で利用するために必要な設計パターンを紹介したいと思います。

Firebaseで使えるさまざまな認証方式(メールアドレス、Google認証等）については触れません。（どの認証方式にも成り立つ話です）

# デフォルトの認証戦略

Firebaseでは既存の機能を使うことで、非常に高機能な認証を実現します。これには以下のような特徴があります。

1. ユーザは自分のユーザアカウントを作ることができる
2. ユーザが作るアカウントのUIDをあらかじめ知ることはできない。

この認証戦略は、ECサイトを作ったり、サブスクリプションサービスを提供したり、コミュニティサイトを作ったり、さまざまな用途でこのまま使うことができます。一方で、企業で従業員のアカウントを管理したり、学校で学生のアカウントを管理したりするのには、そのまま使うことができません。

# UID固定戦略

ユーザの初回ログイン前に、UIDを固定させたいことがあります。たとえば、あるユーザが社員であり、ログイン前に権限を指定したり、特定のチャットグループに参加させたい場合、デフォルトの認証戦略では、事前にUIDを知ることができないため、現実的ではありません（メールアドレスをUID代わりに使う方法はあるとは思いますが・・・・）。

UIDを事前に固定するためには、何らかの形で、admin APIで、ユーザを作成する必要があります。この認証情報の管理方式として、以下のようなものが考えられるでしょう。

1. admin APIでメタデータも含めて管理する
2. メタデータはFirestoreで管理する

## Firestoreでの実装

1は検索等で不便なので、通常は2になるのではないかと思います。この場合、Cloud Firestore Triggered Functionを使うことができます。

たとえば、新規作成時、管理者が/users collectionに追加できるようにし、この変更をCloud Firestore-Triggered Functionで検知して、admin APIを叩きます。この場合、フロント側で、uidを決定してしまい、ユーザ作成時にこのuidを指定すると、実装が単純になります。

ほぼ同じことを/users collectionへのアクセスをAPI経由に限定ことでも実現できます。フロント側の実装が複雑になる代わりに、トランザクション処理を実装する場合に、処理を若干単純化できます。この場合、uidの作成を、firestoreにまかせることができます。

# メールアドレス固定戦略

さて、Firebaseでは、認証用のメールアドレスはユーザが変更することができます。変更するためのUIを提供しないことはできますが、その場合も、変更用のAPIは準備されてしまうため、セキュリティ上問題になるケースがあるでしょう。

これは、Firebaseで直接的に実装することはできませんが、emailを変えてもFirebaseのAPI上はログインできるが、セキュリティルールで各機能を使えないようにすることで、同等のことを実現することができます。

1 auth.email方式

firestore上に、uidをキーとするcollection(/authとする)を作成し、セキュリティルールでrequest.auth.token.emailと一致しなければ不許可とする。

```js
function isAdmin() {
 return 'admin' in get(/databases/$(database)/documents/auth/$(request.auth.uid)).data.types;
}
function isMember() {
  return isAdmin() || get(/databases/$(database)/documents/auth/$(request.auth.uid)).data.email == request.auth.token.email;
}
```


2 authEmail方式

firestore上に、emailをキーとするcollection(/authEmailとする)を作成し、セキュリティルールでrequest.auth.token.emailと一致しなければ不許可とする。

```js
function isAdmin() {
 return 'admin' in  get(/databases/$(database)/documents/authEmail/$(request.auth.token.email)).data.types;
}
function isMember() {
  return isAdmin() || exists(/databases/$(database)/documents/authEmail/$(request.auth.token.email));
}
```

3 Custom Claim方式

admin APIからのユーザ作成時に、Custom Claimでメールアドレスを設定しておき、セキュリティルールでrequest.auth.token.emailと一致しなければ不許可とする。

```js
function isAdmin() {
  return 'admin' in request.auth.token.types;
}
function isMember() {
  return isAdmin() || request.auth.token.requiredEmail == request.auth.token.email;
}
```

## Firestoreでの実装

クライアント側だけで処理したい場合は、1, 2での実装になります。3は、Cloud Firestore-Triggered Functionが必須です。

ただし、UID固定戦略と併用する場合は、いずれにせよ、バックエンドの実装が必要で、ここでいずれにせよトランザクション処理が必要になります。この場合は、3の実装が一番単純になります。

# Cloud Firestore-Triggered Functionのサンプルコード

以下にCustom Claim方式の場合のCloud Firestore-Triggered Functionサンプルコードを掲載します。


```js

const functions = require("firebase-functions");
const admin = require("firebase-admin");

admin.initializeApp();

console.log("initialized");

exports.updateUser = functions.firestore
  .document("users/{userId}")
  .onWrite(async (change, context) => {
    const uid = context.params.userId;
    await updateUserHandler(uid);
  });

const updateUserHandler = async uid => {
  const firestore = admin.firestore();
  const userRef = firestore.collection("users").doc(uid);
  // ロックの取得
  const response = await firestore.runTransaction(async transaction => {
    const user = await transaction.get(userRef);
    const userData = user.data();
    if (!userData) {
      return {
        userData,
        status: "delete",
      };
    } else if (
      userData.transactionId &&
      (!userData.committedTransactionId ||
        userData.transactionId > userData.committedTransactionId) &&
      !userData.transactionStartsAt
    ) {
      transaction.set(
        userRef,
        {
          transactionStartsAt: admin.firestore.FieldValue.serverTimestamp(),
          lastTransactionStartsAt: admin.firestore.FieldValue.serverTimestamp(),
        },
        { merge: true }
      );
      return {
        userData,
        status: userData.committedTransactionId ? "update" : "create",
      };
    } else {
      // ロック取得失敗
      return null;
    }
  });
  if (response) {
    const { userData, status } = response;
    console.log("#status", status, userData);
    if (status === "delete") {
      await admin.auth().deleteUser(uid);
    } else if (status === "create") {
      await admin
        .auth()
        .createUser({ uid, email: userData.email, emailVerified: true });
    } else if (status === "update") {
      await admin
        .auth()
        .updateUser(uid, { email: userData.email, emailVerified: true });
    }
    if (status !== "delete") {
      await admin.auth().setCustomUserClaims(uid, {
        types: userData.types || null,
        email: userData.email || null,
      });
      await userRef.update({
        transactionStartsAt: admin.firestore.FieldValue.delete(),
        committedTransactionId: userData.transactionId,
      });
      // ロックされていた場合の遅延実行
      await updateUserHandler(uid);
      return;
    }
  } else {
    // ロック取得に失敗した場合は何もしない。ロック中のプロセスが遅延実行してくれる。
  }
  console.log("#finished");
};
```