🔒

Identity Platformを使ってFlutterアプリを多要素認証(MFA)対応させる

2021/11/20に公開

CHANGELOG
  • 2023.01.02
    • firebase_auth プラグインが MFA 対応したことによる注意文を追記

このような挙動です。サインイン時に SMS の認証コードが要求されます。gif は1回目で誤った SMS 認証コードを入力し、2回目で正しいコードを入力しています。

はじめに

アプリケーション開発においる認証部分の実装において「Firebase Authentication」(以下、Firebase Auth)は非常に強力です。とくに、サーバサイドの開発無しにセッションやトークンを意識すること無く認証実装を完結できる点は、フロントエンドエンジニアにとって最適な選択肢の1つであり、デファクトスタンダードにもなりつつあると思います。Firebase と親和性の高い Flutter の開発においてもその選択をとる開発者は多く、私もその恩恵を受けてきた一人です。

ただ、その簡単さゆえに痒いところへ手が届かない面もあります。本記事で扱う多要素認証(MFA)もその1つで、Firebase Auth 単体では現在巷で標準となりつつある「2段階認証」や「2要素認証」を実装できません(2021年11月現在)。Google などの外部認証プロバイダではプロバイダ側で MFA 設定していればまだしも、とくにメールアドレス/パスワードによる認証ではセキュリティ面でどうしても甘くなってしまいます。
そこで本記事では、Firebase Auth と Google Cloud の Identity Platform の API を活用して、Flutter アプリを MFA 対応します。※厳密には、SMS による認証で2要素認証(Two-Factor Authentication)となります。

FlutterFireにおけるMFA対応の動き

  • 一般の Firebase SDK としての MFA 対応は Firebase Summit 2021 で期待していましたが、アナウンスはありませんでしたね。
  • FlutterFire においても MFA に対する Discussions や Issue がありますが、具体的な動きはまだ見られません(もちろん、Firebase SDK が出れば本記事の手続きは不要になります)。

https://github.com/FirebaseExtended/flutterfire/discussions/2386
https://github.com/FirebaseExtended/flutterfire/issues/7290

Hi All - this is on the roadmap, but currently we are prioritising releasing the remaining plugins. Once they're complete, this will be worked on.
https://github.com/FirebaseExtended/flutterfire/issues/7290#issuecomment-972807314

ロードマップには乗っているものの、App Check などの FlutterFire で未実装となっている残りのプラグイン開発の対応が優先されるとのことで、対応時期はもう少し先になりそうですね。

実装の方針

現在 FlutterFire では MFA 対応がなされていないため、下記いずれかの方法で実装する必要があります。

  • iOS/Android のネイティブコードを Platform Channels で呼び出す方法
  • Identity Platform の API エンドポイントを叩く方法

前者は、ネイティブコードのSDKでは既にMFAのメソッドが用意されている ためこちらを間接的に呼び出す方法ですが、結合が大変(SDK を作ることと同義)なのと何よりネイティブコードは書かずに完結したいので、後者で実現しています(前者も結局はエンドポイントを叩いているだけなのでやっている事自体は一緒です)。

対象となる読者

  • Firebase Auth を利用したことがある方
    • MFA にフォーカスするため基本的な Firebase Auth の記述はありません。
  • 比較的楽に実装できる多要素認証(MFA)の流れを知りたい方
  • 現行の Firebase Auth での認証ではセキュリティ面で心配な方

※ちなみに、Google によると MFA の効果が研究結果として出ているそうです。

Google は最近ニューヨーク大学とカリフォルニア大学サンディエゴ校の研究者と協力して、アカウントの乗っ取りを防ぐうえで基本的な予防策がどれくらい効果的なのかを調査しました。その結果、Google アカウントに第2の認証要素としてSMSを追加するだけで、調査中に発生した最大 100% の自動ボット、96% の一括フィッシング攻撃、76% の標的型攻撃をブロックできることが示されました。

https://cloud.google.com/blog/ja/products/identity-security/protect-users-in-your-apps-with-multi-factor-authentication

Firebase AuthとIdentity Platform

Firebase Auth と Identity Platform の違いについては公式ドキュメントにわかりやすい比較があります。

Identity Platform と Firebase Authentication は、似たような機能を提供しています。どちらの場合も、バックエンド サービス、SDK、UI ライブラリを提供することで、簡単にアプリにログインできるようにしています。ただし、Identity Platform では、企業顧客向けの次のような追加機能が提供されています。

https://cloud.google.com/identity-platform/docs/product-comparison

互いの関係性を平たく述べると、Fireabse は Google Cloud のモバイルに特化したインタフェースであり、Firebase Auth も Identity Platform のインタフェースです(歴史的には Firebase Auth が先で Google Cloud に統合されたはずですが自信はないです)。つまり、Firebase での設定がそのまま Identity Platform に適用され、逆も同様です。また、後者は OIDC プロバイダを追加できるなど Firebase よりやや機能が充実しています。

実装する

イメージしやすいように「登録」と「ログイン」で手順を分けました。やや長くなってしまいましたが、要点は下記の API を要所で叩いているというだけです。順に見ていきます。

MFA操作 エンドポイント
登録要求 https://identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start
登録 https://identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:finalize
要求(チャレンジ) https://identitytoolkit.googleapis.com/v2/accounts/mfaSignIn:start
認証(サインイン) https://identitytoolkit.googleapis.com/v2/accounts/mfaSignIn:finalize

手順.1 - MFAを登録する

[1-1]. Identity PlatformでMFAの設定する

まずは、Google Cloud のコンソール上で設定します(請求アカウントが必要です)。コンソール上で「有効化」を行うことでMFA設定を必要に応じて利用できるようになります。勘違いしやすい点としては、利用できるようになっただけで相応の実装をしない限り何も適用されません。つまり、すでにサービスを公開しておりユーザーが定着している場合でも、有効化したからといって既存ユーザーに影響が出る心配はありません。

この後実装を見ていきますが、アプリケーションの裁量次第でユーザー単位での適用対象を選択できます。
※通常の Firebase Auth セットアップ同様、利用する認証プロバイダのトグルもオンにしておいてください。

テスト用の電話番号を設定する(任意)

実際に回線が手元になくとも、ダミーの電話番号で検証ができます。電話番号と認証コードをあらかじめ登録しておき、実装時には登録した認証コードで検証します(テスト用電話番号では SMS は送信されません)。

コンソールで MFA を有効にしなかった場合のレスポンス: 400

MFA 登録のリクエストを投げたタイミングで仮に MFA の有効手続きを忘れていた場合でも、下記のレスポンスが返ってくるので、ある程度気づきやすいと思います。

{
  "error": {
    "code": 400,
    "message": "OPERATION_NOT_ALLOWED : SMS based MFA not enabled.",
    "status": "INVALID_ARGUMENT"
  }
}

[1-2]. MFAを要求する範囲を決める

  • サービスの裁量に応じてどのスコープまで MFA を適用するかは異なります。今回は例として「ユーザー登録後、任意にMFAの登録ができる」状況を想定して進めます。
  • ドキュメントには主に4パターンのユースケースが記載されており、繰り返しになりますが、MFA の設定をスキップできるようにすることも全ユーザーに必須な形にも可能です。

https://cloud.google.com/identity-platform/docs/web/mfa#choosing_an_enrollment_pattern

[1-3]. Firebase SDKを使って普通にユーザー登録する

今回は「メールアドレス/パスワード」での認証を例に進めます。詳細は割愛します。

await FirebaseAuth.instance.createUserWithEmailAndPassword(
  email: email,
  password: password,
);

[1-4]. MFAを登録要求する

「登録要求」という言葉が正しいかは自信がありませんが、MFA 登録に必要な sessionInfo を受け取るための処理です。

API Endpoint
POST https://identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start

上記エンドポイントを叩きます(コードは一部抜粋)。phoneEnrollmentInfoStartMfaPhoneRequestInfo  |  Identity Platform Documentation を参考に指定します。今回は phoneNumber のみを指定します。

gcloud_api_client.dart
Future<void> startMFAEnrollment() async {
  const method = 'mfaEnrollment:start';
  final body = <String, dynamic>{
    'idToken': await _read(authRepository).getIdToken(),
    'phoneEnrollmentInfo': <String, String>{
      'phoneNumber': '+11231231234',
    },
  };
  await _post(method: method, body: body);
}

成功すると下記のレスポンスが返ってきます。この sessionInfo を次に使います。

response
{
  "phoneSessionInfo": {
    "sessionInfo": "AJOnW4Ryf1_ZbpgP25KLE3nUnhS1-P0wD3PD0WE0pdyn3GeFSXhSnoEmk-REDwPL_ycaNHHR-8cOGynOok0si1_2i68XF5fE8kOXwf8PysJbyh1YSHxLfmOs0yu-oB2bpKe9FIJZNtgzlgGeTibD6XYX2zzgkZkmbQ"
  }
}
MFA 登録時にメール未検証の場合のレスポンス: 400

Google や Apple による認証では登録時に email_vetified が true となりますが、メールアドレス/パスワードによる認証では、登録時には email_vetified が false なため先に検証処理を済ませる必要があります。

response
{
  "error": {
    "code": 400,
    "message": "UNVERIFIED_EMAIL : Need to verify email first before enrolling second factors.",
    "status": "INVALID_ARGUMENT"
  }
}
クレデンシャルが古い場合のレスポンス: 400

MFA 登録だけではなく、アカウント削除などセンシティブな操作する場合に、古いクレデンシャルを利用していると処理が行われません。上記のようなセンシティブな操作前に再認証を挟む UI にしておくと親切かもしれませんね。

response
{
  "error": {
    "code": 400,
    "message": "CREDENTIAL_TOO_OLD_LOGIN_AGAIN",
    "status": "INVALID_ARGUMENT"
  }
}
MISSING_CLIENT_IDENTIFIER でハマル: 400

テスト用の電話番号であれば問題ないですが、実際に SMS を受信するよう試そうとすると、MISSING_CLIENT_IDENTIFIER が返ってきてしまい、ハマってしまいました(検証は iOS シミュレーターおよび iOS 実機で行っておりました)。

response
{
  "error": {
    "code": 400,
    "message": "MISSING_CLIENT_IDENTIFIER",
    "status": "INVALID_ARGUMENT"
  }
}

結論
iOS で検証していたため、iosReceiptiosSecret というものを指定する必要がありました。
https://cloud.google.com/identity-platform/docs/reference/rest/v2/StartMfaPhoneRequestInfo

原因は分かりましたが、iosReceipt を取得するために verifyIosClient エンドポイントを叩く必要があり、その他 SMS を実機に送信するためには APNs キーの生成&アップロードなど手続きが多いため、検証用途であれば Android で試すのがオススメです。
https://cloud.google.com/identity-platform/docs/reference/rest/v1/accounts/verifyIosClient

※途中まで iOS で検証していましたが、この時点で Android での検証に切り替えました。

テスト用電話番号ではなく実機で検証する場合(任意)

通常の SMS 認証コード料金が発生するのに加え、電話番号での認証必要になるため各プラットフォームでのセットアップが必要です。とはいえ、検証したい理由もあったのでセットアップしました。

iOS の場合

iOS: In Xcode, enable push notifications for your project & ensure your APNs authentication key is configured with Firebase Cloud Messaging (FCM).
https://firebase.flutter.dev/docs/auth/phone/#setup > https://firebase.google.com/docs/auth/ios/phone-auth?hl=ja

ドキュメントの通り、主に下記のセットアップを済ませる必要があります。

  • APNs キーの生成&Firebase コンソールへのアップロード
  • URLScheme の追記(ReCAPTHA 用途)
  • Push Notification の Capability 設定

さらに、iOS で SMS 送信するためには iosReceiptiosSecret という値をあらかじめ取得しておく必要があり、iosReceipt を取得するためには verifyIosClient エンドポイントを叩く必要があるなど、手続きがやや多く面倒なため検証用途であれば Android で試すのがオススメです。
https://cloud.google.com/identity-platform/docs/reference/rest/v1/accounts/verifyIosClient

Android の場合

Android: If you haven't already set your app's SHA-1 hash in the Firebase console, do so. See Authenticating Your Client for information about finding your app's SHA-1 hash.
https://firebase.flutter.dev/docs/auth/phone/#setup

  • SHA-1の設定
  • Client ID の設定

が必要です。

参考:
https://github.com/FirebaseExtended/flutterfire/issues/4651#issuecomment-808853566

[1-5]. SMS認証コードを利用してMFA登録を完了する

入手した sessionInfo と SMS 認証コード code などと合わせて、下記情報で該当 API を叩いて完了です。

入手方法
sessionInfo [1-4]の startMFAEnrollment で入手
phoneNumber [1-4]のユーザーからの入力で入手
code ユーザーからの入力で入手
API Endpoint
POST https://identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:finalize
gcloud_api_client.dart
Future<ApiResponse> finalizeMFAEnrollment({
  required String sessionInfo,
  required String code,
  required String phoneNumber,
}) async {
  const method = 'mfaEnrollment:finalize';
  final body = <String, dynamic>{
    'idToken': await _read(authRepository).getIdToken(),
    'phoneVerificationInfo': <String, String>{
      'sessionInfo': sessionInfo,
      'code': code,
      'phoneNumber': phoneNumber,
    },
  };
  return _post(method: method, body: body);
}

手順2. - MFAでログイン

手順1で特定のユーザーに対して、MFA を有効にできました。これにより、以降 MFA が設定されたユーザーについては、signInWithXxx系 の API でログインを試みると、MFA 要求が返ってくるようになります。

[2-1]. まずはFirebase SDKを使って普通にログインを試みる

await FirebaseAuth.instance.signInWithEmailAndPassword(
  email: email,
  password: password,
);
response
{
  "error": {
    "code": "second-factor-required",
    "message": "Please complete a second factor challenge to finish signing into this account.",
  }
}

MFA が必要なアカウントのため FlutterFire では second-factor-required のエラーが返ってきます。

第三者による不正ログインから守る(余談)

FlutterFire の signInWithEmailAndPassword メソッドは内部では、iOS/Android のネイティブ SDK の同等メソッドを呼んでいるにすぎません。それらもまた、下記 API を叩いているのと同義です。

API Endpoint
POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword

つまり、これで MFA登録したアカウントについては apiKey が第三者の手に渡っても、スクリプト経由からログインにも都度MFAが要求される ため、該当アカウントを強く保護できるようになりました。仕様はブラックボックスですが、Fireabse Auth には元々不自然なリクエストが続いた場合にはそのリクエストを一定時間受け付けなくなる挙動も確認できており、既存のままでも一定基準のセキュリティは担保できるものの、MFA 対応によってより強固できました(とくにユーザー目線や企業水準でガイドラインが設けられている場合など)。

[2-2]. MFA Challenge

さて、期待通り Exception が返ってきたので、second-factor-required で判定して MFA を要求します(MFA Challenge)。下記 API を叩きます。
…と言いたいところなのですが、APIを叩くのに必要な mfaPendingCredentialmfaInfo が残念ながらFlutterFireの signInWithEmailAndPassword メソッドでは返ってきません(未対応なのでそうですよね)。

signInWithPasswordのAPIを直接叩く

仕方ないので、signInWithPassword の API を直接叩いて mfaPendingCredentialmfaInfo を入手します。

API Endpoint
POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword

同様に API を叩きます。

gcloud_api_client.dart
Future<String?> signInWithEmailAndPasswordForMFA({
  required String email,
  required String password,
}) async {
  const method = 'accounts:signInWithPassword';
  final body = <String, dynamic>{
    'email': email,
    'password': password,
  };
  final responseBody = await _post(
    method: method,
    body: body,
    pathPrefix: '/v1',
  );
  return responseBody;
}

下記が返却されます。

response
{
  "kind": "identitytoolkit#VerifyPasswordResponse",
  "localId": "3uEtrK2G14WmgRfTgdLxLDN8eWm1",
  "email": "xxx@gmail.com",
  "displayName": "",
  "registered": true,
  "mfaPendingCredential": "AMzJoSk5RSC-PyU6PZ5OCyges6Yv2qZWP9AC9KwceJcDcu75TdpnfzanLj6rrSqYAY1BoihDOU_5nHPwx74t-U4a0VTTgOZj336bq_s0PBPiYpcgMBdieRDtfUDqzKlORqe7SlcP1H-QWlKXRJuESDNe9_W43ucYWP0X7_IWqqLg3wiwcVgG140",
  "mfaInfo": [
    {
      "phoneInfo": "+*******1234",
      "mfaEnrollmentId": "254f9a7a-77b9-489b-96d2-bcee5efa23bd",
      "displayName": "",
      "enrolledAt": "2021-11-19T11:54:33.273091Z"
    }
  ]
}

良いですね。今度はしっかり mfaPendingCredentialmfaInfo が返ってきました。

さて、ようやく準備が整ったので MFA Challenge です。

API Endpoint
POST https://identitytoolkit.googleapis.com/v2/accounts/mfaSignIn:start

以上をコードで表現すると下記です。

try{
  await FirebaseAuth.instance.signInWithEmailAndPassword(
    email: email,
    password: password,
  );
} on FirebaseAuthException catch (e) {
  if (e.code == 'second-factor-required') {
    // 1. MFAが必要か否かを判定して`mfaPendingCredential`と`mfaInfo`を取得するためにAPIを叩く
    final response =
        await _read(gcloudApiClient).signInWithEmailAndPasswordForMFA(
      email: email,
      password: password,
    );
    // apiKeyの指定漏れなどケア
    if (!response.success) {
      // ...省略
    }
    // `mfaPendingCredential`と`mfaInfo`を取り出す
    final mfaPendingCredential =
        response.json!['mfaPendingCredential'].toString();
    final mfaInfo = response.json!['mfaInfo'] as List<dynamic>;
    final mfaInfoWithCredential = MFAInfoWithCredential(
      mfaPendingCredential: mfaPendingCredential,
      mfaInfo: mfaInfo.first as Map<String, dynamic>,
    );
    // 2. MFA ChallengeのAPIを叩く
    final mfaResponse = await _read(gcloudApiClient).startMFASignIn(
      mfaInfoWithCredential: mfaInfoWithCredential,
    );
    if (mfaResponse.success) {
      // sessionInfoを取り出す
      final phoneSessionInfo =
          mfaResponse.json!['phoneResponseInfo'] as Map<String, dynamic>;
      final sessionInfo = phoneSessionInfo['sessionInfo'].toString();
    }
  }
}

https://github.com/htsuruo/flutter_identity_platform_mfa/blob/19e15f5cba1708d4655b13247b65ceb76e00230b/lib/auth_repository.dart#L50

[2-3]. MFAを使ってサインインする

最後です。下記3つの情報を使って MFA サインインします。

入手方法
mfaPendingCredential [2-2]の signInWithEmailAndPassword で入手
mfaInfo [2-2]の signInWithEmailAndPassword で入手
sessionInfo [2-2]の startMFASignIn(MFA Challenge)で入手
code ユーザーからの入力で入手
POST https://identitytoolkit.googleapis.com/v2/accounts/mfaSignIn:finalize
gcloud_api_client.dart
Future<void> finalizeMFASignIn({
  required MFAInfoWithCredential mfaInfoWithCredential,
  required String sessionInfo,
  required String code,
}) async {
  const method = 'mfaSignIn:finalize';
  final body = <String, dynamic>{
    'mfaPendingCredential': mfaInfoWithCredential.mfaPendingCredential,
    'phoneVerificationInfo': <String, String>{
      'sessionInfo': sessionInfo,
      'code': code,
      'phoneNumber': mfaInfoWithCredential.phoneInfo,
    },
  };
  final responseBody = await _post(method: method, body: body) ?? '';
  final json = jsonDecode(responseBody) as Map<String, dynamic>;
  final idToken = json['idToken'].toString();
}

結果は単純で idToken のみ返ってきます。これで無事 MFA アカウントの ID トークンを取得できました。

response
{
  "idToken": "xxx"
}
SMS 認証コードが誤っていた場合のレスポンス: 400

当然、SMS 認証コードが誤っていれば ID トークンは得られません。その場合は、下記のエラーINVALID_CODE が返されます。

response
{
  "error": {
    "code": 400,
    "message": "INVALID_CODE",
    "status": "INVALID_ARGUMENT"
  }
}

JWT の中身は下記です。MFA が登録されているアカウントのため、最下部の sign_in_second_factorsecond_factor_identifier が追加されているのが分かります。

jwt
{
  "iss": "https://securetoken.google.com/[project_id]",
  "aud": "[project_id]",
  "auth_time": 1637370293,
  "user_id": "xxx",
  "sub": "xxx",
  "iat": 1637370293,
  "exp": 1637373893,
  "email": "xxx@gmail.com",
  "email_verified": true,
  "firebase": {
    "identities": {
      "email": [
        "xxx@gmail.com"
      ]
    },
    "sign_in_provider": "password",
    // 下記2つが追加
    "sign_in_second_factor": "phone",
    "second_factor_identifier": "254f9a7a-77b9-489b-96d2-bcee5efa23bd"
  }
}

ちなみに、"sign_in_second_factor": "phone" となっていますが、コンソールで認証プロバイダ「電話番号」のトグルをオンにせずとも、しっかり機能しました(コンソールのトグルはあくまで sign_in_provider として有効にするか否かの設定なのだと思います)。

[2-4]. IDトークンの検証とFirebase SDKとの結合

主目的の MFA による ID トークンの取得が達成できた(スタミナが切れてしまった)ので、割愛します。取得した ID トークンを Admin SDK などを使って検証する必要があります。また、Identity Platform 経由で完了した認証は、当然 SDK には通知されない(つまり、authStateChanges() などで流れてこない)ため、追加の実装が必要になりそうです。

まとめ

本記事では、主に Identity Platform の API を利用して Flutter でも MFA の対応をしてみました。やっていることは適切なタイミングで API を叩く程度なのでそこまで難しくはないですが、SDK と併用で使おうとすると signInWithEmailAndPassword を2回叩く必要があったり、SMS コード送信のためのセットアップが必要だったりと、結構大変でした。
セキュリティへの警戒心が益々高くなっている近年、会社によっては「コンシューマにサービスを提供する最低基準として MFA 対応すること」と義務付けられていたりもするはずで、すぐに対応が必要な場合は今回の内容を実践してもらえると良いですが、それ以外では素直に SDK の対応を待つのが良いですね。最後に、想定よりボリューミーになってしまったので、ソースコードを貼っておきます。
https://github.com/htsuruo/flutter_identity_platform_mfa

参考

Discussion