🚥

ブロッキング関数を使ってメールアドレス未確認時のサインインをブロックする

2022/09/06に公開

はじめに

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 に記載されています。
https://firebase.blog/posts/2022/07/new-firebase-auth-features

ブロッキング関数とは

平たく言うと、認証(アカウント作成/サインイン)処理の前段に IP アドレスやドメイン判定のロジックを挟むことで、意図したユーザー以外の認証を制限できる Cloud Functions のトリガー関数です。英語では「Blocking Functions」でこちらの方が関連記事は多くヒットします。
冒頭に述べたアップデートにより、Cloud Functions for Firebase(Firebase の Cloud Functions)でこの関数がサポートされたということになります。関数内のコード実行が終了した後に認証を完了できる(=想定しないアクセスやドメインをブロックする)ため、ブロッキング関数という名称のようです。

Firebase のドキュメントはこちらです(Identity Platform のドキュメントは こちら です)。

https://firebase.google.com/docs/auth/extend-with-blocking-functions?hl=ja

関数はアカウント作成前とサインイン前でそれぞれ、beforeCreatebeforeSignIn という関数名でトリガーされる形となります。それぞれ、以下のフローで処理されます。

アカウント作成前(beforeCreate)

functions.auth.user().beforeCreate((user, context) => {
  // ...
})
  1. クライアントで認証処理を呼び出す
  2. ブロッキング関数が呼ばれる
  3. 関数内のロジック判定を通過(=throw させない)するとそのまま認証処理が走る
  4. ユーザーデータを Firebase Auth 側のデータベースに保存しクライアントへトークンを返却

サインイン前(beforeSignIn)

functions.auth.user().beforeSignIn((user, context) => {
  // ...
})
  1. クライアントで認証処理を呼び出す
  2. ユーザーの認証が確認される
    • MFA 有効ユーザーの場合はその認証も必要
  3. 関数内のロジック判定を通過(=throw させない)するとそのまま通過
  4. クライアントにトークンを返却

2. で記載していますが MFA 有効ユーザーの場合は、MFA を通過した後にブロッキング関数がトリガーされる形になります。ブロッキング関数はテナント単位での設定に対し、MFA はユーザー単位に設定されるので、ユーザー単位での認証処理がすべて完了後にトリガーされる形ということですね。

ロジックを介在させる

とくに難しいことはなく、ブロッキング関数の中で例外を throw すると処理をブロックできます。逆に throw しない場合はそのまま通過します。

throw new functions.auth.HttpsError('permission-denied')

また、加工する場合は単にオブジェクトを返却すれば済みます。たとえば、アカウント作成前に emailVerifiedtrue にする場合は、以下のように書けます。

functions.auth.user().beforeCreate((user, context) => {
  return { emailVerified: true }
})

ユースケース

名前の通り、ユーザーが特定の基準を満たしていない場合には認証されないようにする(ブロックする)用途はメインだとは思いますが、前段に独自の処理を行うことができるので、クライアントへ返却する前にカスタムクレームを付与したりユーザー情報を更新するなどといったことも可能です。

たとえば、以下などの利用が挙げられます。

  • オフィス内の IP アドレスのホワイトリストを作っておき、オフィス外からの認証を制限する
    • 似た例としてリモートワークとも相性が良く、チームメンバー宅の IP アドレスを管理してメンバー以外の認証を制限できそうですね。
  • MFA(multiFactor)が設定されていないユーザーの登録を制限する
  • アプリケーション内の特定の条件を満たしたユーザーに対して、認証時に独自のカスタムクレームを付与する。
  • ユーザー表示名が4文字未満または正規表現を満たさないユーザーの登録を制限する
    • Firestore のセキュリティルール側でもフィールドに write されない用なルールを記述することで不正な値が登録されることも防ぐことができますが、より前段の登録段階でそれを制限する動きができるようになります。

まだ他にもたくさんのユースケースがありそうですね。公式ドキュメントにもユースケースがいくつか挙げられておりますので、ご自身の状況に合ったものが見つかるかもしれません。
https://firebase.google.com/docs/auth/extend-with-blocking-functions?hl=ja#common-scenarios

上記に挙げられている以外にも、UserRecord class  |  Firebase Admin SDK で取得できるプロパティについては、独自に制御可能です。

コンソールでブロッキング関数を選択

「Firebase コンソールのサイドバー > Authentication > Settings タブ」よりブロッキング関数のアカウント作成・サインイン時にトリガーとなる関数をプルダウンで指定します。また任意ですが、チェックボックスをオンにするだけで、Cloud Functions に追加の認証情報(ID トークンなど)を付与できます。

Cloud Functions の Authentication トリガー

beforeCreatebeforeSignIn はいずれも Cloud Functions の Authentication トリガーに分類されますが、ここで Authentication トリガーについて既存の関数と合わせて整理します。ドキュメントはこちらです。

https://firebase.google.com/docs/functions/auth-events?hl=ja

トリガー 実行タイミング
beforeCreate ユーザーが登録される前 (ブロッキング関数)
onCreate ユーザーが登録された直後
beforeSignIn ユーザーがサインインされる前 (ブロッキング関数)
onDelete ユーザーが削除された直後

元々提供されていた onCreateonDelete に加えて、今回のブロッキング関数が追加されたことでより細かい Auth のタイミングでのハンドリングができるようになりましたね。

さらに onCreateonDelete については、次で紹介する ユーザーセルフサービス の通り、デプロイ自体をせずに済ませることもできるようになりました。

ユーザーセルフサービス

Firebase コンソールに追加されていて気づいたのですが、おそらく今回のアップデートのタイミングで「ユーザーアクション」という項目も追加されました。これは 「ユーザーによるアカウント作成」 「ユーザーによる削除」 を制御する項目で、チェックボックスのみで設定を切り替えることができます。
https://firebase.google.com/docs/auth/users?authuser=0&hl=ja#user-actions

元々、Auth の作成・削除は前述の通り onCreateonDelete の Authentication トリガーで提供されていましたが、これら2つの処理に関して一律した作成・削除であれば Cloud Functions をデプロイすること無く制御できるようになりました。たとえば、登録・削除は一律 Admin SDK 経由に統一してユーザーからの操作は禁止するといった、特定のケースでは便利になりそうですね。対して、「登録時に特定ユーザーのみ制限したい」というケースでは従来同様に Cloud Functions でのデプロイが必要です。

これまで、Firestore のドキュメントはセキュリティルールで比較的簡単に操作を制限できた一方、Auth では Cloud Functions のデプロイが必要で若干面倒な側面もありましたが、チェックボックス1つで制御できる形となったので楽になりましたね。

確認済みのメールアドレス

メールアドレス確認の観点では、Firebase Auth のプロバイダには 「信頼できるプロバイダ」「信頼できないプロバイダ」 の大きく2種類に分類されます。この点を抑えておかないと、プロバイダによってユーザー登録時の値が異なって混乱してしまうので注意が必要です。

https://firebase.google.com/docs/auth/users?authuser=0&hl=ja#verified_email_addresses

信頼できるプロバイダ

  • Google(@gmail.com のアドレス)
  • Yahoo(@yahoo.com のアドレス)
  • Microsoft(@outlook.com と @hotmail.com のアドレス)
  • Apple(アカウントは常に検証され、多要素認証されるため、常に確認済み)

信頼できないプロバイダ

  • Facebook
  • Twitter
    • 私の観測上ですが、メールアドレス登録されているアカウントは Twitter 側での検証が必須のため「信頼できる」扱いになり、メールアドレスではなく電話番号のみで登録されているアカウントについては「信頼できない扱い」となります。
  • GitHub
  • ID プロバイダによって発行されていないドメインに対する Google、Yahoo、Microsoft
  • メール確認なしのメールアドレス / パスワード

emailVerified プロパティ

確認済みのメールアドレスか否かについては emailVerified というプロパティが状態を持っています。emailVerified は UserRecord クラスのプロパティ で boolean です。

ユーザー登録時において、前述した「信頼できるプロバイダ」では true となり、逆に「信頼できないプロバイダ」では false となります。
信頼できないプロバイダの場合は、verifyEmail メソッドを別途呼び、メールリンク認証を通過すると true にできます。

メールアドレス未確認時のサインインをブロックする

簡単ですが、実装に入ります。今回は例として、公式ドキュメントのユースケースとしても挙げられている、登録時に電子メールによる確認が必要(メールアドレス未確認時のサインインをブロックする) を実装します。
クライアント側は Flutter で作っていますが、本記事の主旨ではないので最低限に留めています。

ブロッキング関数を作る

まず、beforeCreatebeforeSignIn のトリガーとする関数の処理を書きます。sendCustomVerificationEmail については、独自の実装が必要となります(関数の中身は 確認メールを送信する実装が必要 にて記載しています)。
コードの通りですが、たとえば beforeSignIn では emailVerifiedfalse の時に例外を 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 が入ります。詳細はこちらに記載されています。
https://firebase.google.com/docs/auth/extend-with-blocking-functions#getting_user_and_context_information
参考までに 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 トリガーの中でも onCreateonDelete 関数は、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 単独での実行が機能しない」といった以下のイシュー程度しか無く、普通に実行する分に困っている人がいなさそうで、私の環境の問題かもしれません。ご知見のある方コメントにて教えて頂けると助かります 🙇‍♂️
https://github.com/firebase/firebase-tools/issues/4617

挙動が確認できればデプロイします。

firebase deploy --only functions

確認メールを送信する実装が必要

アカウント作成後であれば、Firebase SDK の sendEmailVerification メソッドを使って 簡単に確認メールを送信できますが、アカウント作成前(beforeCreate)の場合にはユーザーがまだ作成されていないのでこのメソッドは利用できません。
独自に SMTP サーバーを用意してメール送信の機構を作る必要があるので、この点は「ユーザー登録後に任意でメールアドレス確認を促すケース」などと比べると手間になってしまいますね。今回は以下の記事を参考に nodemailer - npm を使い、SMTP サーバー周りは Gmail(Google Workspace の無料枠)を使って済ませました。

https://qiita.com/r-knm/items/8701de4a836719c0653d

ちなみに 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()...

https://firebase.google.com/docs/functions/config-env?hl=ja#secret-manager

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 を利用します。アカウント作成後のメールアドレス/パスワード認証のアカウントでは、emailVerifiedfalse となるのでブロックされるはずです。

try {
  await FirebaseAuth.instance.signInWithEmailAndPassword(
    email: email,
    password: password,
  );
} on FirebaseAuthException catch (e) {
  // ブロッキング関数が上手く機能していると`FirebaseAuthException`でcatchできます。
}

Flutter の Firebase Auth プラグインでは、ブロッキング関数で弾かれた場合は FirebaseAuthException で catch できました。ただ、Cloud Functions 側で HttpsError を throw しているにもかかわらずプロパティの codeundefined になってしまい、すべて 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 なユーザーでのサインイン

以上で、クライアントからのサインイン時に、emailVerifiedfalse な場合のみに、ブロックされる挙動を確認できました。
こちらが該当のソースコードです。firebase ディレクトリの index.ts にブロッキング関数に関する記述があります(当初「アカウント作成時のメールアドレス確認を必須にする」方針で作っていた都合でリポジトリ名が不適切です…)。

https://github.com/htsuruo/firebase_force_email_verification

まとめ

今回は、Firebase のブロッキング関数(Blocking Functions)を使って、メールアドレス未確認ユーザーのサインインをブロックする実装をしてみました。ブロッキング関数のセットアップ自体もとくに難しいポイントは無く、導入は比較的スムーズにできる気がしました。Firebase Auth が益々使いやすくなって嬉しいですね。

今回の実装サンプルで、「アカウント作成時にメールアドレス確認を要求する」場合に、確認メールを送信する実装が必要 で記載した通り、beforeCreate 時に独自にメール送信機構を作るのは若干面倒に感じました。
サーバ側で認証メールを送信する場合はこうするしかなさそうですが、サインイン後にクライアントから sendEmailVerification メソッドを呼ぶ形であれば、SMTP サーバの用意は不要ですしそれが許されるのであれば後者の方が楽だと思いました。また、ドキュメント通りに beforeCreate 関数で実装しましたが、「アカウント作成時にメールアドレス確認を要求する」であれば普通に onCreate 関数でも良い気がしました。その場合、EventContext から locale が取得できないので、多言語対応ができないという問題はありそうですが 🧐

今回の「with Identity Platform」のアップデートでは、MFA 対応も目玉機能だと思いますので、以下の問題点が解消されたら別途記事化しようと思います。

https://twitter.com/h_tsuruo/status/1562069278383538176

参考

Discussion