20
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

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

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

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

使用するFirebaseのサービス

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

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

ユーザー登録のフロー

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

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

Firebase Authenticationの準備

FirebaseのコンソールからAuthenticationを有効にします。
Screen Shot 2019-12-25 at 1.30.28.png
これにより、ユーザー登録時にメールが飛び、ユーザーはそこから登録が可能になります。

Cloud Firestoreの設定

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

Screen Shot 2019-12-25 at 1.36.55.png

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

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

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

// 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用のエンドポイントです。

/*
  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を挟むことで、ユーザーは始めに招待コードを入力し、そこで有効性を確認できます。
しかし、ユーザー登録時には既に残り使用可能回数が0になっている可能性があるので、signup時にもう一度確認します。

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

/*
  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. 作成したユーザーのCustomUserClaimsinvitationCodeVerified: trueを付与する

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

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

アプリ側から呼びだす

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

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. statusokならsignupエンドポイントを呼び出す
3. statusokならログインした上でsendEmailVerificationでユーザーにメールを送信
4. ログアウトする

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

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

終わりに

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
20
Help us understand the problem. What are the problem?