Identity Platformを使ってFlutterアプリを多要素認証(MFA)対応させる
このような挙動です。サインイン時に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が出れば本記事の手続きは不要になります)。
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% の標的型攻撃をブロックできることが示されました。
Firebase AuthとIdentity Platform
Firebase AuthとIdentity Platformの違いについては公式ドキュメントにわかりやすい比較があります。
Identity Platform と Firebase Authentication は、似たような機能を提供しています。どちらの場合も、バックエンド サービス、SDK、UI ライブラリを提供することで、簡単にアプリにログインできるようにしています。ただし、Identity Platform では、企業顧客向けの次のような追加機能が提供されています。
互いの関係性を平たく述べると、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 |
- REST Resource: accounts.mfaEnrollment
- REST Resource: accounts.mfaSignIn | Identity Platform Documentation
手順.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の設定をスキップできるようにすることもできれば全ユーザーに必須とすることも可能です。
[1-3]. Firebase SDKを使って普通にユーザー登録する
今回は「メールアドレス/パスワード」での認証を例に進めます。詳細は割愛します。
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: email,
password: password,
);
[1-4]. MFAを登録要求する
「登録要求」という言葉が正しいかは自信がありませんが、MFA登録に必要なsessionInfo
を受け取るための処理です。
POST https://identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:start
上記エンドポイントを叩きます(コードは一部抜粋)。phoneEnrollmentInfo
はStartMfaPhoneRequestInfo | Identity Platform Documentationを参考に指定します。今回はphoneNumber
のみを指定します。
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
を次に使います。
{
"phoneSessionInfo": {
"sessionInfo": "AJOnW4Ryf1_ZbpgP25KLE3nUnhS1-P0wD3PD0WE0pdyn3GeFSXhSnoEmk-REDwPL_ycaNHHR-8cOGynOok0si1_2i68XF5fE8kOXwf8PysJbyh1YSHxLfmOs0yu-oB2bpKe9FIJZNtgzlgGeTibD6XYX2zzgkZkmbQ"
}
}
MFA登録時にメール未検証の場合のレスポンス: 400
GoogleやAppleによる認証では登録時にemail_vetified
がtrueとなりますが、メールアドレス/パスワードによる認証では、登録時にはemail_vetified
がfalseなため先に検証処理を済ませる必要があります。
{
"error": {
"code": 400,
"message": "UNVERIFIED_EMAIL : Need to verify email first before enrolling second factors.",
"status": "INVALID_ARGUMENT"
}
}
クレデンシャルが古い場合のレスポンス: 400
MFA登録だけではなく、アカウント削除などセンシティブな操作を行う場合に、古いクレデンシャルを利用していると処理が行われません。上記のようなセンシティブな操作前に再認証を挟むUIにしておくと親切かもしれませんね。
{
"error": {
"code": 400,
"message": "CREDENTIAL_TOO_OLD_LOGIN_AGAIN",
"status": "INVALID_ARGUMENT"
}
}
MISSING_CLIENT_IDENTIFIERでハマル: 400
テスト用の電話番号であれば問題ないですが、実際にSMSを受信するよう試そうとすると、MISSING_CLIENT_IDENTIFIER
が返ってきてしまい、ハマってしまいました(検証はiOSシミュレーターおよびiOS実機で行っておりました)。
{
"error": {
"code": 400,
"message": "MISSING_CLIENT_IDENTIFIER",
"status": "INVALID_ARGUMENT"
}
}
結論
iOSで検証していたため、iosReceipt
とiosSecret
というものを指定する必要がありました。
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送信するためには、iosReceipt
とiosSecret
という値を予め取得しておく必要があり、さらに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 |
ユーザーからの入力で入手 |
POST https://identitytoolkit.googleapis.com/v2/accounts/mfaEnrollment:finalize
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,
);
{
"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を叩いているのと同義です。
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を叩くのに必要なmfaPendingCredential
とmfaInfo
が残念ながらFlutterFireのsignInWithEmailAndPassword
メソッドでは返ってきません(未対応なのでそうですよね)。
signInWithPasswordのAPIを直接叩く
仕方ないので、signInWithPassword
のAPIを直接叩いてmfaPendingCredential
とmfaInfo
を入手します。
POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword
同様にAPIを叩きます。
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;
}
下記が返却されます。
{
"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"
}
]
}
良いですね。今度はしっかりmfaPendingCredential
とmfaInfo
が返ってきました。
さて、ようやく準備が整ったのでMFA Challengeです。
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();
}
}
}
[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
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トークンを取得することができました。
{
"idToken": "xxx"
}
SMS認証コードが誤っていた場合のレスポンス: 400
当然、SMS認証コードが誤っていればIDトークンは得られません。その場合は、下記のエラーINVALID_CODE
が返されます。
{
"error": {
"code": 400,
"message": "INVALID_CODE",
"status": "INVALID_ARGUMENT"
}
}
JWTの中身は下記です。MFAが登録されているアカウントのため、最下部のsign_in_second_factor
とsecond_factor_identifier
が追加されているのが分かります。
{
"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の対応を待つのが良いですね。最後に、想定よりボリューミーになってしまったので、ソースコードを貼っておきます。
参考
- REST Resource: accounts.mfaEnrollment
- REST Resource: accounts.mfaSignIn | Identity Platform Documentation
- Firebase Auth REST API
- iOS アプリに多要素認証を追加する | Identity Platform のドキュメント | Google Cloud
- Method: accounts.signInWithIdp | Identity Platform Documentation
- Vue.jsとIdentity Platform(IDプラットフォーム)でAPIの認証機能を作る - Qiita
Discussion