ブロッキング関数を使ってメールアドレス未確認時のサインインをブロックする
はじめに
2022年の7月下旬に Firebase Authentication with Identity Platform と呼称されるアップデートが行われ、Firebase Auth の機能が強化されました。「機能が強化された」をもう少し噛み砕くと、裏側の API として提供されている Google Cloud の Identity Platform でこれまで提供されていた機能が、Firebase Auth でも使えるようになった(=Identity Platform との境界が薄くなった)ということですね。「with Identity Platform」という名称からも分かりますね。
具体的には、本記事で取り扱うブロッキング関数以外にも、MFA、OIDC サポート、監査ログ対応など元々 Identity Platform で提供されていた機能が Firebase で扱えるようになりました。
私たちフロントエンドの開発者にとって、Google Cloud よりも Firebase を扱うことが圧倒的に多いと思うので、裏側の処理を気にせずともアプリケーションコードに集中できるので嬉しいですね。詳細は以下の The Firebase Blog に記載されています。
ブロッキング関数とは
平たく言うと、認証(アカウント作成/サインイン)処理の前段に IP アドレスやドメイン判定のロジックを挟むことで、意図したユーザー以外の認証を制限できる Cloud Functions のトリガー関数です。英語では「Blocking Functions」でこちらの方が関連記事は多くヒットします。
冒頭に述べたアップデートにより、Cloud Functions for Firebase(Firebase の Cloud Functions)でこの関数がサポートされたということになります。関数内のコード実行が終了した後に認証を完了できる(=想定しないアクセスやドメインをブロックする)ため、ブロッキング関数という名称のようです。
Firebase のドキュメントはこちらです(Identity Platform のドキュメントは こちら です)。
関数はアカウント作成前とサインイン前でそれぞれ、beforeCreate
・beforeSignIn
という関数名でトリガーされる形となります。それぞれ、以下のフローで処理されます。
アカウント作成前(beforeCreate)
functions.auth.user().beforeCreate((user, context) => {
// ...
})
- クライアントで認証処理を呼び出す
- ブロッキング関数が呼ばれる
- 関数内のロジック判定を通過(=throw させない)するとそのまま認証処理が走る
- ユーザーデータを Firebase Auth 側のデータベースに保存しクライアントへトークンを返却
サインイン前(beforeSignIn)
functions.auth.user().beforeSignIn((user, context) => {
// ...
})
- クライアントで認証処理を呼び出す
- ユーザーの認証が確認される
- MFA 有効ユーザーの場合はその認証も必要
- 関数内のロジック判定を通過(=throw させない)するとそのまま通過
- クライアントにトークンを返却
※2.
で記載していますが MFA 有効ユーザーの場合は、MFA を通過した後にブロッキング関数がトリガーされる形になります。ブロッキング関数はテナント単位での設定に対し、MFA はユーザー単位に設定されるので、ユーザー単位での認証処理がすべて完了後にトリガーされる形ということですね。
ロジックを介在させる
とくに難しいことはなく、ブロッキング関数の中で例外を throw すると処理をブロックできます。逆に throw しない場合はそのまま通過します。
throw new functions.auth.HttpsError('permission-denied')
また、加工する場合は単にオブジェクトを返却すれば済みます。たとえば、アカウント作成前に emailVerified
を true
にする場合は、以下のように書けます。
functions.auth.user().beforeCreate((user, context) => {
return { emailVerified: true }
})
ユースケース
名前の通り、ユーザーが特定の基準を満たしていない場合には認証されないようにする(ブロックする)用途はメインだとは思いますが、前段に独自の処理を行うことができるので、クライアントへ返却する前にカスタムクレームを付与したりユーザー情報を更新するなどといったことも可能です。
たとえば、以下などの利用が挙げられます。
- オフィス内の IP アドレスのホワイトリストを作っておき、オフィス外からの認証を制限する
- 似た例としてリモートワークとも相性が良く、チームメンバー宅の IP アドレスを管理してメンバー以外の認証を制限できそうですね。
- MFA(
multiFactor
)が設定されていないユーザーの登録を制限する - アプリケーション内の特定の条件を満たしたユーザーに対して、認証時に独自のカスタムクレームを付与する。
- ユーザー表示名が4文字未満または正規表現を満たさないユーザーの登録を制限する
- Firestore のセキュリティルール側でもフィールドに write されない用なルールを記述することで不正な値が登録されることも防ぐことができますが、より前段の登録段階でそれを制限する動きができるようになります。
まだ他にもたくさんのユースケースがありそうですね。公式ドキュメントにもユースケースがいくつか挙げられておりますので、ご自身の状況に合ったものが見つかるかもしれません。
上記に挙げられている以外にも、UserRecord class | Firebase Admin SDK で取得できるプロパティについては、独自に制御可能です。
コンソールでブロッキング関数を選択
「Firebase コンソールのサイドバー > Authentication > Settings タブ」よりブロッキング関数のアカウント作成・サインイン時にトリガーとなる関数をプルダウンで指定します。また任意ですが、チェックボックスをオンにするだけで、Cloud Functions に追加の認証情報(ID トークンなど)を付与できます。
Cloud Functions の Authentication トリガー
beforeCreate
と beforeSignIn
はいずれも Cloud Functions の Authentication トリガーに分類されますが、ここで Authentication トリガーについて既存の関数と合わせて整理します。ドキュメントはこちらです。
トリガー | 実行タイミング |
---|---|
beforeCreate |
ユーザーが登録される前 (ブロッキング関数) |
onCreate |
ユーザーが登録された直後 |
beforeSignIn |
ユーザーがサインインされる前 (ブロッキング関数) |
onDelete |
ユーザーが削除された直後 |
元々提供されていた onCreate
と onDelete
に加えて、今回のブロッキング関数が追加されたことでより細かい Auth のタイミングでのハンドリングができるようになりましたね。
さらに onCreate
と onDelete
については、次で紹介する ユーザーセルフサービス の通り、デプロイ自体をせずに済ませることもできるようになりました。
ユーザーセルフサービス
Firebase コンソールに追加されていて気づいたのですが、おそらく今回のアップデートのタイミングで「ユーザーアクション」という項目も追加されました。これは 「ユーザーによるアカウント作成」 「ユーザーによる削除」 を制御する項目で、チェックボックスのみで設定を切り替えることができます。
元々、Auth の作成・削除は前述の通り onCreate
・onDelete
の Authentication トリガーで提供されていましたが、これら2つの処理に関して一律した作成・削除であれば Cloud Functions をデプロイすること無く制御できるようになりました。たとえば、登録・削除は一律 Admin SDK 経由に統一してユーザーからの操作は禁止するといった、特定のケースでは便利になりそうですね。対して、「登録時に特定ユーザーのみ制限したい」というケースでは従来同様に Cloud Functions でのデプロイが必要です。
これまで、Firestore のドキュメントはセキュリティルールで比較的簡単に操作を制限できた一方、Auth では Cloud Functions のデプロイが必要で若干面倒な側面もありましたが、チェックボックス1つで制御できる形となったので楽になりましたね。
確認済みのメールアドレス
メールアドレス確認の観点では、Firebase Auth のプロバイダには 「信頼できるプロバイダ」 と 「信頼できないプロバイダ」 の大きく2種類に分類されます。この点を抑えておかないと、プロバイダによってユーザー登録時の値が異なって混乱してしまうので注意が必要です。
信頼できるプロバイダ
- Google(@gmail.com のアドレス)
- Yahoo(@yahoo.com のアドレス)
- Microsoft(@outlook.com と @hotmail.com のアドレス)
- Apple(アカウントは常に検証され、多要素認証されるため、常に確認済み)
信頼できないプロバイダ
- Twitter
- 私の観測上ですが、メールアドレス登録されているアカウントは Twitter 側での検証が必須のため「信頼できる」扱いになり、メールアドレスではなく電話番号のみで登録されているアカウントについては「信頼できない扱い」となります。
- GitHub
- ID プロバイダによって発行されていないドメインに対する Google、Yahoo、Microsoft
- メール確認なしのメールアドレス / パスワード
emailVerified プロパティ
確認済みのメールアドレスか否かについては emailVerified
というプロパティが状態を持っています。emailVerified
は UserRecord クラスのプロパティ で boolean です。
ユーザー登録時において、前述した「信頼できるプロバイダ」では true
となり、逆に「信頼できないプロバイダ」では false
となります。
信頼できないプロバイダの場合は、verifyEmail
メソッドを別途呼び、メールリンク認証を通過すると true
にできます。
メールアドレス未確認時のサインインをブロックする
簡単ですが、実装に入ります。今回は例として、公式ドキュメントのユースケースとしても挙げられている、登録時に電子メールによる確認が必要(メールアドレス未確認時のサインインをブロックする) を実装します。
クライアント側は Flutter で作っていますが、本記事の主旨ではないので最低限に留めています。
ブロッキング関数を作る
まず、beforeCreate
と beforeSignIn
のトリガーとする関数の処理を書きます。sendCustomVerificationEmail
については、独自の実装が必要となります(関数の中身は 確認メールを送信する実装が必要 にて記載しています)。
コードの通りですが、たとえば beforeSignIn
では emailVerified
が false
の時に例外を throw して、処理をブロックしていることがわかります。
// アカウント作成前にトリガー:
// `emailVerified`が`false`の場合はメールアドレス確認メールを送信する
// throwはしないのでそのままアカウント作成
export const beforeCreate = functions.auth
.user()
.beforeCreate(async (user, context) => {
const locale = context.locale
const email = user.email
if (email && !user.emailVerified) {
const link = await admin.auth().generateEmailVerificationLink(email)
// 別途メール送信の処理を独自で用意する必要があります
return sendCustomVerificationEmail({ user, link, locale })
}
})
// サインイン前にトリガー:
// メールアドレス未認証の場合はサインインをブロックする
export const beforeSignIn = functions.auth.user().beforeSignIn((user, _) => {
if (user.email && !user.emailVerified) {
throw new functions.auth.HttpsError(
'invalid-argument',
`"${user.email}" needs to be verified before access is granted.`
)
}
})
第一引数と第ニ引数にはそれぞれ UserRecord と EventContext が入ります。詳細はこちらに記載されています。beforeCreate
時に実際に渡ってきたオブジェクトを以下に記載します。
第一引数(UserRecord)
{
'uid': 'KoLDnV6xUEWLdGhrUoe7FeB8o2w1',
'email': 'xxx@gmail.com',
'emailVerified': false,
'displayName': 'undefined',
'photoURL': 'undefined',
'phoneNumber': 'undefined',
'disabled': false,
'metadata':
{
'creationTime': 'Sat, 24 Jun 54648 21:13:18 GMT',
'lastSignInTime': 'Sat, 24 Jun 54648 21:13:18 GMT',
},
'providerData':
[
{
'uid': 'xxx',
'displayName': 'undefined',
'email': 'xxx@gmail.com',
'photoURL': 'undefined',
'providerId': 'password',
'phoneNumber': 'undefined',
},
],
'passwordHash': 'undefined',
'passwordSalt': 'undefined',
'customClaims': 'undefined',
'tenantId': 'undefined',
'tokensValidAfterTime': null,
'multiFactor': null,
}
第ニ引数(EventContext)
{
'locale': 'und',
'ipAddress': 'xxx',
'userAgent': 'Dalvik/2.1.0 (Linux; U; Android 13; Pixel 6 Build/TP1A.220624.021),gzip(gfe),gzip(gfe)',
'eventId': 'rnSSA3ceXh73-zpT2OpRDg',
'eventType': 'providers/cloud.auth/eventTypes/user.beforeCreate:password',
'authType': 'USER',
'resource':
{
'service': 'identitytoolkit.googleapis.com',
'name': 'projects/playground-c8a87',
},
'timestamp': 'Mon, 05 Sep 2022 10:04:37 GMT',
'additionalUserInfo':
{
'providerId': 'password',
'profile': 'undefined',
'username': 'undefined',
'isNewUser': true,
},
'credential': null,
'params': {},
}
ローカル Emulator で挙動を確認する
firebase-tools - npm は執筆時点最新の 11.8.0
で動かしておりましたが、Emulator Suite にブロッキング関数の設定自体は無く、実行方法も見つけられませんでした 🧐
Authentication トリガーの中でも onCreate
や onDelete
関数は、Emulator Suite のダッシュボードなどから適当にユーザー登録・削除すると呼ばれるのですが、ブロッキング関数は呼ばれませんでした。
ただ、コンソールに以下の形式でエンドポイントが出力されるので、これを適切に叩くと関数内の処理をデバッグできそうです。
functions[us-central1-beforeCreate]: providers/cloud.auth/eventTypes/user.beforeCreate function initialized (http://localhost:5001/[PROJECT_ID]/us-central1/beforeCreate).
functions[us-central1-beforeSignIn]: providers/cloud.auth/eventTypes/user.beforeSignIn function initialized (http://localhost:5001/[PROJECT_ID]/us-central1/beforeSignIn).
ちなみに適当に GET で叩いてみたら以下の400エラーが返ってきてしまってトリガー関数は呼ばれませんでした。POST かつ body のフォーマットがありそうですが、ドキュメントを見つけられず解決できませんでした 🧐
{ 'error': { 'message': 'Bad Request', 'status': 'INVALID_ARGUMENT' } }
ざっとイシューがないか探してみても「beforeSignIn
単独での実行が機能しない」といった以下のイシュー程度しか無く、普通に実行する分に困っている人がいなさそうで、私の環境の問題かもしれません。ご知見のある方コメントにて教えて頂けると助かります 🙇♂️
挙動が確認できればデプロイします。
firebase deploy --only functions
確認メールを送信する実装が必要
アカウント作成後であれば、Firebase SDK の sendEmailVerification
メソッドを使って 簡単に確認メールを送信できますが、アカウント作成前(beforeCreate
)の場合にはユーザーがまだ作成されていないのでこのメソッドは利用できません。
独自に SMTP サーバーを用意してメール送信の機構を作る必要があるので、この点は「ユーザー登録後に任意でメールアドレス確認を促すケース」などと比べると手間になってしまいますね。今回は以下の記事を参考に nodemailer - npm を使い、SMTP サーバー周りは Gmail(Google Workspace の無料枠)を使って済ませました。
ちなみに Google Workspace の 試用アカウントでは 1 日の送信数上限は 500 通まで となっていますが、検証に使うには十分ですね。その他、注意点がありますが本題から逸れてきてしまうので、折りたたんで記載する形に留めておきます。
Gmail でメールを送信する際の注意
nodemailer だと createTransport
メソッドを使って認証情報のパスワードを指定しますが、このパスワード、SecretManager や環境変数経由で参照したとしても平文では弾かれてしまいます。
const transporter = createTransport({
service: 'gmail',
auth: {
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_PASS,
},
})
結論、以下の通り「アプリパスワード」という形で別途パスワードを発行する必要があります。こちらのパスワードを使えば無事にメールを送信できます。
Log in to your Google account Go to My Account > Sign-in & Security > App Passwords (Sign in again to confirm it's you) Scroll down to Select App (in the Password & sign-in method box) and choose Other (custom name) Give this app password a name, e.g. "nodemailer" Choose Generate Copy the long generated password and paste it into your Node.js script instead of your actual Gmail password.
https://stackoverflow.com/a/60718806
認証情報などの機密情報は Secret Manager に
実装の際に Gmail のユーザーやパスワード情報が必要となりますが、認証情報など機密情報を取り扱う場合は、環境変数ではなく Secret Manager | Google Cloud を使うことが推奨されています。Secret Manager API を有効化したら以下のコマンドで簡単に使えるので積極的に利用しましょう。
# セット
$ firebase functions:secrets:set SECRET_NAME
# トリガー関数で以下の用に記載すれば間接的にその関数が呼ばれていれば
# `Process.env.SECRET_NAME`で取り出せます。
functions.runWith({ secrets: ['GMAIL_USER', 'GMAIL_PASS'] }).auth.user()...
sendCustomVerificationEmail
以上を踏まえて beforeCreate
で呼ばれる sendCustomVerificationEmail
メソッドは以下のものを準備しました。基本的には Firebase デフォルトで備わっている認証メールと同じような仕様や文言に合わせております。
sendCustomVerificationEmail
async function sendCustomVerificationEmail(param: {
user: AuthUserRecord
link: string
locale: string | undefined
}): Promise<SentMessageInfo> {
// TODO(tsuruoka): 本来はlocaleに応じて表示言語を切り替えるだが省略
const mail: Mail = {
to: param.user.email!,
subject: 'メールアドレスの確認',
text: `${param.user.displayName} 様
メールアドレスを確認するには、次のリンクをクリックしてください。
${param.link}
このアドレスの確認を依頼していない場合は、このメールを無視してください。
よろしくお願いいたします。
`,
}
return await MailSender.instance.send(mail)
}
class MailSender {
static get instance() {
return (this._instance ??= new MailSender())
}
private static _instance: MailSender | undefined
async send(mail: Mail): Promise<SentMessageInfo> {
const transporter = createTransport({
service: 'gmail',
auth: {
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_PASS,
},
})
const options: SendMailOptions = {
from: 'noreply@gmail.com',
to: mail.to,
subject: mail.subject,
text: mail.text,
}
const response = await transporter.sendMail(options)
functions.logger.info(`${response.messageId}: ${response.response}`)
return response
}
}
interface Mail {
readonly to: string
readonly subject: string
readonly text: string
}
コンソールのプルダウンより関数を選択
前述の関数を Cloud Functinos にデプロイすると、コンソールでブロッキング関数を選択 のプルダウンで選択できるようになりますので、それぞれ該当する関数を選択します。
クライアントからサインイン処理をする
クライアントから呼び出してみます。Flutter(Dart)で書かれていますが、必要に応じて読み替えてください。今回は 信頼できないプロバイダ のメールアドレス/パスワード認証で、signInWithEmailAndPassword
を利用します。アカウント作成後のメールアドレス/パスワード認証のアカウントでは、emailVerified
が false
となるのでブロックされるはずです。
try {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
} on FirebaseAuthException catch (e) {
// ブロッキング関数が上手く機能していると`FirebaseAuthException`でcatchできます。
}
Flutter の Firebase Auth プラグインでは、ブロッキング関数で弾かれた場合は FirebaseAuthException
で catch できました。ただ、Cloud Functions 側で HttpsError
を throw しているにもかかわらずプロパティの code
は undefined
になってしまい、すべて message
に文字列として入ってしまうので注意が必要です。SDK 側の問題で今後改善されるものなのか、もしくはブロッキング関数の性質上仕方がないことなのかはよく分からずです 🤔
code: undefined
message: com.google.firebase.FirebaseException: An internal error has occurred.
[
BLOCKING_FUNCTION_ERROR_RESPONSE:HTTP Cloud Function returned an error:
{
"error":{
"message":"[throwする際に指定したmessage]",
"status":"[throwする際に指定したcode]"
}
}
]
一応ブロッキング関数によってブロックされた場合は BLOCKING_FUNCTION_ERROR_RESPONSE
の文字列が message
に入ってくるので、以下のようにするとブロックされたか否かを判定できます(ただ仕様が変わる可能性もあるのでやはり code
で判定できるようになるのがベター)。
final isBlocking = e.message?.contains('BLOCKING_FUNCTION_ERROR_RESPONSE') ?? false;
emailVerified が false なユーザーでのサインイン |
---|
以上で、クライアントからのサインイン時に、emailVerified
が false
な場合のみに、ブロックされる挙動を確認できました。
こちらが該当のソースコードです。firebase
ディレクトリの index.ts
にブロッキング関数に関する記述があります(当初「アカウント作成時のメールアドレス確認を必須にする」方針で作っていた都合でリポジトリ名が不適切です…)。
まとめ
今回は、Firebase のブロッキング関数(Blocking Functions)を使って、メールアドレス未確認ユーザーのサインインをブロックする実装をしてみました。ブロッキング関数のセットアップ自体もとくに難しいポイントは無く、導入は比較的スムーズにできる気がしました。Firebase Auth が益々使いやすくなって嬉しいですね。
今回の実装サンプルで、「アカウント作成時にメールアドレス確認を要求する」場合に、確認メールを送信する実装が必要 で記載した通り、beforeCreate
時に独自にメール送信機構を作るのは若干面倒に感じました。
サーバ側で認証メールを送信する場合はこうするしかなさそうですが、サインイン後にクライアントから sendEmailVerification
メソッドを呼ぶ形であれば、SMTP サーバの用意は不要ですしそれが許されるのであれば後者の方が楽だと思いました。また、ドキュメント通りに beforeCreate
関数で実装しましたが、「アカウント作成時にメールアドレス確認を要求する」であれば普通に onCreate
関数でも良い気がしました。その場合、EventContext
から locale
が取得できないので、多言語対応ができないという問題はありそうですが 🧐
今回の「with Identity Platform」のアップデートでは、MFA 対応も目玉機能だと思いますので、以下の問題点が解消されたら別途記事化しようと思います。
参考
- クラウド機能をブロックして Firebase 認証を拡張する | Firebase Authentication
- ブロッキング関数を使用した認証フローのカスタマイズ | Identity Platform のドキュメント | Google Cloud
- Firebase Authentication トリガー | Cloud Functions for Firebase
- [GCP] Identity Platform で有効期限付きパスワードの認証機能を実装してみるよ - Qiita
- Firebase Functions から Secret Manager を使う
- Firebase Cloud Functions を使って Gmail 送信機能を作る - Qiita
Discussion