Firebaseだけで招待コード機能を実装する

Firebaseで招待コード機能を今後実装したいと思った人は一定数いると思っています。
なので今回、Firebaseだけで完結できるような実装を共有しようと思います。

※本記事はメールとパスワードのユーザー登録を前提とします。

# 使用するFirebaseのサービス

- Firebase Authentication
    - ユーザーの管理に使用
- Cloud Functions for Firebase
    - 招待コードの有効性の検証に使用
- Cloud Firestore
    - 招待コードの管理に使用

の三つです。全部Firebaseだけで完結できる形で紹介します。

# ユーザー登録のフロー
ユーザー登録までの流れは以下のような感じでおこなっていきます。

1. 招待コードを入力・チェック
2. 有効だった場合はユーザー登録画面に遷移
3. ユーザー登録時にもう一度招待コードのチェック
4. OKなら登録
5. メールを送信

# Firebase Authenticationの準備
FirebaseのコンソールからAuthenticationを有効にします。
<img width="1440" alt="Screen Shot 2019-12-25 at 1.30.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213538/3d003f92-3bc8-8402-0140-faf9bfc1f71b.png">
これにより、ユーザー登録時にメールが飛び、ユーザーはそこから登録が可能になります。

# Cloud Firestoreの設定
次にFirestoreの設定をしていきましょう。Firestoreは招待コードの管理をします。
招待コードの構造は、`invitationCodes`コレクションが存在し、そのドキュメントのIDが招待コードとしています。
また、ドキュメントに`remainingCount`を持たせることで、残りの使用回数を保持しています。

<img width="1164" alt="Screen Shot 2019-12-25 at 1.36.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213538/d479d91a-5284-5c5a-4bfe-8c1a48d130a4.png">

`invitationCodes`コレクションはユーザーから見えないようにしなければならないので、Firestoreのルールでは、読み取りも書取りも禁止するようにしておきましょう。

また今回、招待コードを利用して登録されたユーザーは、`invitationCodeVerified`をAuthenticationの`CustomClaims`に持たせるので、ルール側で`request.auth.token.invitationCodeVerified`を呼びだし、確実に招待コードで登録されたユーザーのみ、データを操作できるよう、Firestoreのルールを設定します。

例えばこんな感じな関数をルールに用意しておくと便利でしょう。

```js
// Authority validations
function isAuthenticated(userID) {
  return isAuthenticatedWithoutEmailVerified(userID)
    && request.auth.token.email_verified == true
    ;
}

function isAuthenticatedWithoutEmailVerified(userID) {
  return request.auth != null
    && request.auth.uid == userID
    && request.auth.token.invitationCodeVerified == true
    ;
}
```

# Cloud Functions for Firebaseの実装
Functionsには2つエンドポイントを生やします。1つは招待コードの有効性を確認するエンドポイント、もう一つがsignup用のエンドポイントです。

```ts
/*
  Path: checkInvitationCode
  From: User
  data:
    {
      invitationCode: string,
    }
 */
const checkInvitationCode = functions.https.onCall(async (data, _) => {
  if (!data.invitationCode) {
    throw new functions.https.HttpsError(
      'invalid-argument',
      'auth/operation-not-allowed'
    );
  }

  const invitationCodeRef = await firestore
    .collection('invitationCodes')
    .doc(data.invitationCode)
    .get();

  if (!invitationCodeRef.exists) {
    throw new functions.https.HttpsError(
      'invalid-argument',
      'auth/invalid-code'
    );
  }

  const invitationCodeRefData = invitationCodeRef.data();
  if (!invitationCodeRefData) {
    throw new functions.https.HttpsError(
      'invalid-argument',
      'auth/invalid-code'
    );
  }

  const remainingCount: number = invitationCodeRefData.remainingCount;
  if (remainingCount <= 0) {
    throw new functions.https.HttpsError(
      'invalid-argument',
      'auth/code-already-in-use'
    );
  }

  return { status: 'ok' };
});
```

`checkInvitationCode`エンドポイントでは、以下の順序で処理が進んでいきます。

1. `invitationCode`が渡ってきているかどうかを判定する
2. 渡ってきた`invitationCode`をドキュメントIDとし、それに該当するFirestoreの`invitationCodes`コレクションの存在確認をする
3. 存在が確認できたら`remainingCount`が0以上かどうかを確認する
4. 0以上であるなら`{ status: 'ok' }`を返却します

このように`signup`の前に`checkInvitationCode`を挟むことで、ユーザーは始めに招待コードを入力し、そこで有効性を確認できます。
しかし、ユーザー登録時には既に残り使用可能回数が０になっている可能性があるので、`signup`時にもう一度確認します。

次に`signup`のエンドポイントを実装していきましょう。

```ts
/*
  Path: signup
  From: User
  data:
    {
      name: string,
      email: string,
      password: string,
      invitationCode: string,
    }
 */
const signup = functions.https.onCall(async (data, _) => {
  // 期待する値が含まれていなかったらエラー
  if (!data.email || !data.password || !data.name || !data.invitationCode) {
    throw new functions.https.HttpsError(
      'invalid-argument',
      'auth/operation-not-allowed'
    );
  }

  try {
    const invitationCodeDocRef = firestore
      .collection('invitationCodes')
      .doc(data.invitationCode);

    const user = await admin.firestore().runTransaction(async transaction => {
      const invitationCodeRef = await transaction.get(invitationCodeDocRef);
      if (!invitationCodeRef.exists) {
        throw {
          code: 'auth/invalid-code',
          message: 'Invalid code'
        };
      }
      const invitationCodeRefData = invitationCodeRef.data();
      if (!invitationCodeRefData) {
        throw {
          code: 'auth/invalid-code',
          message: 'Invalid code'
        };
      }
      const remainingCount: number = invitationCodeRefData.remainingCount;
      if (remainingCount <= 0) {
        throw {
          code: 'auth/code-already-in-use',
          message: 'Code already in use'
        };
      }
      transaction.update(invitationCodeDocRef, {
        remainingCount: admin.firestore.FieldValue.increment(-1)
      });
      return await admin.auth().createUser({
        email: data.email,
        emailVerified: false,
        password: data.password,
        displayName: data.name
      });
    });

    // Front経由で作られてしまったユーザを弾くために `invitationCodeVerified` を付与
    if (user) {
      await admin
        .auth()
        .setCustomUserClaims(user.uid, { invitationCodeVerified: true });
    }

    // どのuserがどのinvitationCodeを使ったかをログに出しておく
    console.log({ userUid: user.uid, invitationCode: data.invitationCode });

    return { status: 'ok' };
  } catch (e) {
    throw new functions.https.HttpsError('invalid-argument', e.code);
  }
});
```

`signup`の処理の流れは以下のようになります。

1. ユーザー登録に必要な項目が存在しているかの確認
2. Firestoreのトランザクション機能を使い、複数のオペレーションを1つのトランザクションにまとめ、招待コードの有効性を担保する
3. 渡ってきた`invitationCode`をドキュメントIDとし、それに該当するFirestoreの`invitationCodes`コレクションの存在確認をする
4. 存在が確認できたら`remainingCount`が0以上かどうかを確認する
5. 招待コードの残り使用回数を-1する（`admin.firestore.FieldValue.increment(-1)`）
6. ユーザーを作成する
7. 作成したユーザーの`CustomUserClaims`に`invitationCodeVerified: true`を付与する

特に重要な点は`admin.firestore().runTransaction`の部分です。ここの部分のおかげで、Functionsは常に整合性のあるFirestoreの最新データに対してトランザクションが実行されます。
要するに、招待コードの使用回数が残り1回のものに対して、同時に登録されたことによって、2回使用されてしまうことを防ぎます。

また、Firestoreにあらかじめ用意されている`FieldValue.increment`メソッドを用いて、簡単に`remainingCount`を減算しています。（自前実装するとここはかなり辛いです）

# アプリ側から呼びだす
ここまで準備できたら、あとは呼び出すだけです。ここでは、TypeScriptを例にサンプルコードを載っけておきます。

```ts
const email = 'sample@example.com';
const password = 'p@ssw0rd';
const name = 'oliver';
const invitationCode = "XU6EowcP7krEc585ytQZ";
const checkInvitationCode = firebase.functions().httpsCallable('checkInvitationCode');
const result = await checkInvitationCode({ invitationCode });

if (result.data.status !== 'ok') {
  const signup = firebase.functions().httpsCallable('signup');
  const result = await signup({ email, password, name, invitationCode });
  if (result.data.status !== 'ok') return; 
  const userCredential = await firebase.auth().signInWithEmailAndPassword(email, password);
  const user = userCredential.user;
  if (!user) return;
  await user.sendEmailVerification();
  await firebase.auth().signOut();
}
```

処理の流れは以下の通りです。
1. Functionsの`checkInvitationCode`エンドポイントを呼び出す
2. `status`が`ok`なら`signup`エンドポイントを呼び出す
3. `status`が`ok`ならログインした上で`sendEmailVerification`でユーザーにメールを送信
4. ログアウトする

この実装により、仮に悪意あるユーザーが、直接フロントエンド側からユーザー登録を行った場合、Functionsを経由していないため、`CustomUserClaims`に本来セットされるべき`invitationCodeVerified`が存在しないため、弾くことが可能です。

このように、一見、Firebase Authenticationだけでは自由度が低いと思われがちですが、Functionsを組み合わせることで、自由度は一気に跳ね上がると思っています。

# 終わりに
Firebaseは様々なサービスが展開されており、今回の招待コード機能も、Firebase上の様々なサービスを組み合わせれば、簡単に実装できる内容になっています。そこがFirebaseの良いところであり、今後も積極的に使っていきたいサービスですね。