🔒

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

21 min read

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

はじめに

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

ただ、その簡単さ故に痒いところに手が届かない面もあります。本記事で扱う多要素認証(MFA)もその一つで、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設定を必要に応じて利用できるようになります。勘違いしやすい点としては、利用できるようになっただけで相応の実装をしない限り何も適用されません。つまり、既にサービスを公開しておりユーザーが定着している場合でも、有効化したからといって既存ユーザーに影響が出る心配はありません。

この後実装を見ていきますが、アプリケーションの裁量次第でユーザー単位で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登録するには下記条件を満たしている必要があります。

  • 事前にアカウントのメール検証が済んでいる(つまりemail_verifiedがtrue)
  • クレデンシャルが比較的新しい(場合によっては再認証が必要になります)
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のエラーが返ってきます。

  • プラットフォームによってはauth/multi-factor-auth-requiredなど多少エラーコードが異なりますが実態は一緒です。
  • この時点ではまだIDトークンを受け取ることができません。

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

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を入手します。

MFA系のエンドポイントとは異なり/v1のエンドポイントになるため注意して下さい。

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登録済のアカウントに対して直接signInWithPasswordAPIを叩くと200が返ってきます(=正常に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

ログインするとコメントできます