はじめに
おしゃべり猫型ロボット「ミーア」で、アプリでユーザーの退会動線を作成したが、退会するボタンを押した時に下記エラーが生じた。
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: [firebase_auth/requires-recent-login] This operation is sensitive and requires recent authentication. Log in again before retrying this request.
#0 FirebaseAuthUserHostApi.delete (package:firebase_auth_platform_interface/src/pigeon/messages.pigeon.dart:1581:7)
<asynchronous suspension>
#1 MethodChannelUser.delete (package:firebase_auth_platform_interface/src/method_channel/method_channel_user.dart:35:7)
<asynchronous suspension>
#2 UserNotifier.deleteUser (package:clocky_app/api/user_notifier.dart:31:7)
<asynchronous suspension>
Firebase Authを使ったユーザー退会処理時の「requires-recent-login」エラーの対処法について記載。
何が問題なのか?最近のログインがない場合に発生
現状のFlutterの、ユーザー退会処理のコードがこちら。
// lib/api/user_notifier.dart
class UserNotifier extends StateNotifier<User?> {
final ApiClient apiClient;
UserNotifier(this.apiClient) : super(null);
Future<void> deleteUser() async {
// DBのデータを削除
await apiClient.deleteUser(state?.uid ?? '');
// Firebase Authデータを削除
auth.User? currentUser = auth.FirebaseAuth.instance.currentUser;
if (currentUser != null) {
await currentUser.delete();
}
state = null;
}
}
FirebaseAuthクラスのdeleteメソッドの定義を確認すると、下記のwarningが記載されていた。
/// Deletes and signs out the user.
///
/// **Important**: this is a security-sensitive operation that requires the
/// user to have recently signed in. If this requirement isn't met, ask the
/// user to authenticate again and then call [User.reauthenticateWithCredential].
///
/// A [FirebaseAuthException] maybe thrown with the following error code:
/// - **requires-recent-login**:
/// - Thrown if the user's last sign-in time does not meet the security
/// threshold. Use [User.reauthenticateWithCredential] to resolve. This
/// does not apply if the user is anonymous.
Future<void> delete() async {
return _delegate.delete();
}
このエラーは、セキュリティ上の理由で、ユーザーが最近ログインしていない場合に発生する。ユーザーを退会させる前に、再認証(reauthentication)させる必要がある。
ちなみに、何日ログインしていないとこのエラーが表示されるかは、Firebaseのドキュメントには自分が見た限りでは明記されていなかった。
また、今回の問題とは直接的な関係はないが、DBのユーザーデータ削除をFirebase Authデータ削除に先んじて行っていたため、「退会する」ボタンを押すと、DBからユーザーデータは削除されたにも関わらず、Firebase Authデータは削除されずに残ったままという現象が発生していたので、こちらも回収する必要がある。
Firebase Authの退会処理を、Flutterのフロントエンドではなく、Goのサーバー側でFirebase Admin SDKを使用して、実行する仕様に変更する。 SDKは自動的にトークンの有効期限を管理し、必要に応じてトークンを更新するので、クライアント側での認証状態の管理とは切り離され、再認証エラー問題は発生しなくなる。
Go:Firebase Authの退会処理を実装
サーバーにFirebase Admin SDKを追加
Firebase Admin SDK は、特権環境から Firebase を操作するために使用するサーバー ライブラリのセット。下記を実行してSDKを追加する。
https://github.com/firebase/firebase-admin-go
# Install the latest version:
go install firebase.google.com/go/v4@latest
# Or install a specific version:
go install firebase.google.com/go/v4@4.13.0
OAuth 2.0 更新トークンを使用してSDKを初期化
Firebase Go SDKをインポートし、 Config構造体に格納されているGoogleApplicationCredentialsを使用して、認証情報のオプションを設定。
そして、設定した認証情報を引数にして、Firebaseアプリケーションのインスタンスを生成。
このSDK初期化で、生成された*firebase.App
インスタンスを通じて、Firebase Authenticationにアクセスして、退会リクエスト時に特定のユーザーの認証データを削除する。
// firebase_auth.go
package clocky_be
import (
"context"
firebase "firebase.google.com/go"
"google.golang.org/api/option"
)
func FirebaseApp(config *Config) *firebase.App {
opt := option.WithCredentialsJSON([]byte(config.GoogleApplicationCredentials))
app, err := firebase.NewApp(context.Background(), nil, opt)
if err != nil {
panic("failed to initialize firebase app")
}
return app
}
Firebase Authのユーザー削除を追加:Firebase Auth→DBの順
退会処理時にDBからユーザー削除している既存のHandleDeleteUser
メソッドに、Firebase Authからユーザーを削除する処理を組み込む。
退会処理においては、Firebase Authのデータ削除が最も重要でリスキーな操作。なぜなら、一度Firebase Authのアカウントを削除すると、その情報は戻せないから。したがって、次の順序にユーザーデータ削除処理を変更する。
- 最初にFirebase Authのユーザーデータを削除。
- 次にデータベース内のユーザーデータを削除。
UserHandler構造体に、新しく*firebase.App
フィールドを追加する。
NewUserHandler
関数を使って UserHandler
型の新しいインスタンスを作成し、そのインスタンスの app
フィールドを通じて Firebase Admin SDK の機能にアクセスして、Firebase Auth から特定のユーザーを削除する。
// user_handler.go
type UserHandler struct {
db *sqlx.DB
sm ShadowManager
app *firebase.App
}
func NewUserHandler(db *sqlx.DB, sm ShadowManager, app *firebase.App) *UserHandler {
return &UserHandler{
db: db,
sm: sm,
app: app,
}
}
func (h *UserHandler) HandleDeleteUser(c echo.Context) error {
uid := c.Get("uid").(string)
if uid == "" {
return echo.NewHTTPError(http.StatusBadRequest, "UID is required")
}
fmt.Printf("Want to delete uid: %vn", uid)
// Firebase Authからユーザーを先に削除
authClient, err := h.app.Auth(context.Background())
if err != nil {
c.Logger().Errorf("Failed to get Firebase Auth client: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get Firebase Auth client")
}
if err := authClient.DeleteUser(context.Background(), uid); err != nil {
c.Logger().Errorf("Failed to delete Firebase auth user: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete Firebase auth user")
}
// Firebase削除に成功したらデータベースからユーザーを削除
if err := DeleteUser(h.db, uid); err != nil {
c.Logger().Errorf("failed to delete user from DB: %v", err)
}
return c.NoContent(http.StatusNoContent)
}
Flutter:APIに削除リクエストを送るのみに変更
サーバー側でFirebase Admin SDKを使用してユーザー削除の処理を行うようになったため、Flutter側のdeleteUser
メソッドはFirebase Authからのユーザー削除処理を行わないように修正する必要がある。
バックエンドがすべての削除処理を担うため、Flutter側ではAPIに削除リクエストを送るだけで十分。
APIクライアント(api_client.dart)
api_client.dart
におけるdeleteUser
関数を修正して、サーバー側に削除リクエストを送る際にはユーザーIDは必要なくなる。代わりに認証トークンをヘッダーに含めることで、サーバー側でユーザーを識別する。
// lib/api/api_client.dart
Future<void> deleteUser() async {
final url = Uri.parse('$apiUrl/app/me');
final headers = await apiHeaders(); // ここでユーザートークンを含むヘッダーを取得
print("apiHeaders: $headers");
final response = await http.delete(
url,
headers: headers,
);
if (response.statusCode != 204) {
throw Exception('Failed to delete user: ${response.body}');
}
}
ユーザーNotifier:APIクライアントを通じてユーザー削除をリクエスト
user_notifier.dart
においては、Firebase Authからのユーザー削除処理を削除し、単純にAPIクライアントを通じてユーザー削除をリクエストするだけにする。
// lib/api/user_notifier.dart
Future<void> deleteUser() async {
try {
await apiClient.deleteUser(); // APIクライアントを通じてユーザー削除をリクエスト
state = null; // 状態を更新
print("User delete request sent successfully");
} catch (e) {
print('Failed to delete user: $e');
throw e; // エラーを再スローして、上位の処理でキャッチ可能に
}
}
退会完了画面を作成
userNotifier.deleteUser()
を呼び出して退会処理を行い、退会処理が成功した後、Navigator.pushReplacement
を使用して、ユーザーを退会完了画面WithdrawalCompleteScreen()
に遷移させる。これにより、退会処理
// lib/screens/home/customer_support_screen.dart
//ユーザー削除ダイアログ
Future<void> _showWithdrawDialog(BuildContext context) async {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('退会'),
content: const Text('本当に退会しますか?この操作は取り消せません。'),
actions: <Widget>[
TextButton(
child: const Text('キャンセル'),
onPressed: () {
Navigator.of(dialogContext).pop();
},
),
Consumer(builder: (context, ref, _) {
final userNotifier = ref.read(userProvider.notifier);
return TextButton(
child: const Text('退会する'),
onPressed: () async {
userNotifier.deleteUser().then((value) async {
final wifiSetupNotifier =
ref.read(wifiSetupCheckProvider.notifier);
await wifiSetupNotifier.deleteWifiSetupDone();
if (context.mounted) Navigator.of(dialogContext).pop();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => WithdrawalCompleteScreen()));
});
},
);
})
],
);
},
);
}
退会処理画面で退会が無事完了したことを伝える。
// lib/screens/home/withdrawal_complete_screen.dart
import 'package:clocky_app/screens/registration/start_screen.dart';
import 'package:flutter/material.dart';
class WithdrawalCompleteScreen extends StatelessWidget {
const WithdrawalCompleteScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('退会完了'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('退会手続きが完了しました。'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Navigator.pushReplacement(context,
MaterialPageRoute(builder: (context) => StartScreen())),
child: const Text('新規登録へ戻る'),
),
],
),
),
);
}
}
動作確認
ユーザーがアプリから退会リクエストを送ると、Authorization
ヘッダーの値としてFirebase ID トークンがサーバー側に渡され、ユーザー認証に用いられる。
flutter: apiHeaders: {Content-Type: application/json, Accept: application/json, Authorization: Bearer XXXXXX}
flutter: User delete request sent successfully
サーバーが特定のユーザーID(XXXXXXX)に基づいてユーザー削除処理を行い、成功し、HTTPステータスコード204を返している。
clocky_api_local | Want to delete uid: XXXXXXX
clocky_api_local | {"time":"2024-05-07T07:27:35.548105276Z","id":"","remote_ip":"192.168.65.1","host":"192.168.XX.XXX:8080","method":"DELETE","uri":"/app/me","user_agent":"Dart/3.3 (dart:io)","status":204,"error":"","latency":1113168250,"latency_human":"1.11316825s","bytes_in":0,"bytes_out":0}
実際に、Firebase Authからも、DBからも該当ユーザーが削除されたことを確認した。
アプリ画面でも、実際に退会完了画面に遷移し、新規登録へ戻るボタンをクリックすると、新規登録画面に遷移できることを確認した。
コメント