Firebaseで招待コード機能を今後実装したいと思った人は一定数いると思っています。
なので今回、Firebaseだけで完結できるような実装を共有しようと思います。
※本記事はメールとパスワードのユーザー登録を前提とします。
使用するFirebaseのサービス
- Firebase Authentication
- ユーザーの管理に使用
- Cloud Functions for Firebase
- 招待コードの有効性の検証に使用
- Cloud Firestore
- 招待コードの管理に使用
の三つです。全部Firebaseだけで完結できる形で紹介します。
ユーザー登録のフロー
ユーザー登録までの流れは以下のような感じでおこなっていきます。
- 招待コードを入力・チェック
- 有効だった場合はユーザー登録画面に遷移
- ユーザー登録時にもう一度招待コードのチェック
- OKなら登録
- メールを送信
Firebase Authenticationの準備
FirebaseのコンソールからAuthenticationを有効にします。
これにより、ユーザー登録時にメールが飛び、ユーザーはそこから登録が可能になります。
Cloud Firestoreの設定
次にFirestoreの設定をしていきましょう。Firestoreは招待コードの管理をします。
招待コードの構造は、invitationCodes
コレクションが存在し、そのドキュメントのIDが招待コードとしています。
また、ドキュメントにremainingCount
を持たせることで、残りの使用回数を保持しています。
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
エンドポイントでは、以下の順序で処理が進んでいきます。
-
invitationCode
が渡ってきているかどうかを判定する - 渡ってきた
invitationCode
をドキュメントIDとし、それに該当するFirestoreのinvitationCodes
コレクションの存在確認をする - 存在が確認できたら
remainingCount
が0以上かどうかを確認する - 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
の処理の流れは以下のようになります。
- ユーザー登録に必要な項目が存在しているかの確認
- Firestoreのトランザクション機能を使い、複数のオペレーションを1つのトランザクションにまとめ、招待コードの有効性を担保する
- 渡ってきた
invitationCode
をドキュメントIDとし、それに該当するFirestoreのinvitationCodes
コレクションの存在確認をする - 存在が確認できたら
remainingCount
が0以上かどうかを確認する - 招待コードの残り使用回数を-1する(
admin.firestore.FieldValue.increment(-1)
) - ユーザーを作成する
- 作成したユーザーの
CustomUserClaims
にinvitationCodeVerified: 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();
}
処理の流れは以下の通りです。
- Functionsの
checkInvitationCode
エンドポイントを呼び出す -
status
がok
ならsignup
エンドポイントを呼び出す -
status
がok
ならログインした上でsendEmailVerification
でユーザーにメールを送信 - ログアウトする
この実装により、仮に悪意あるユーザーが、直接フロントエンド側からユーザー登録を行った場合、Functionsを経由していないため、CustomUserClaims
に本来セットされるべきinvitationCodeVerified
が存在しないため、弾くことが可能です。
このように、一見、Firebase Authenticationだけでは自由度が低いと思われがちですが、Functionsを組み合わせることで、自由度は一気に跳ね上がると思っています。
終わりに
Firebaseは様々なサービスが展開されており、今回の招待コード機能も、Firebase上の様々なサービスを組み合わせれば、簡単に実装できる内容になっています。そこがFirebaseの良いところであり、今後も積極的に使っていきたいサービスですね。