【Flutter × Go】退会処理:Firebase Auth削除時の「requires-recent-login」エラー対処法

この記事は約18分で読めます。

はじめに

おしゃべり猫型ロボット「ミーア」で、アプリでユーザーの退会動線を作成したが、退会するボタンを押した時に下記エラーが生じた。

ShellScript
[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の、ユーザー退会処理のコードがこちら。

Dart
// 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が記載されていた。

Dart
/// 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

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にアクセスして、退会リクエスト時に特定のユーザーの認証データを削除する。

Go
// 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 から特定のユーザーを削除する。

Go
// 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は必要なくなる。代わりに認証トークンをヘッダーに含めることで、サーバー側でユーザーを識別する。

Dart
// 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クライアントを通じてユーザー削除をリクエストするだけにする。

Dart
// 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()に遷移させる。これにより、退会処理

Dart
// 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()));
                });
              },
            );
          })
        ],
      );
    },
  );
}

退会処理画面で退会が無事完了したことを伝える。

Dart
// 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 トークンがサーバー側に渡され、ユーザー認証に用いられる。

ShellScript
flutter: apiHeaders: {Content-Type: application/json, Accept: application/json, Authorization: Bearer XXXXXX}
flutter: User delete request sent successfully

サーバーが特定のユーザーID(XXXXXXX)に基づいてユーザー削除処理を行い、成功し、HTTPステータスコード204を返している。

ShellScript
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からも該当ユーザーが削除されたことを確認した。

アプリ画面でも、実際に退会完了画面に遷移し、新規登録へ戻るボタンをクリックすると、新規登録画面に遷移できることを確認した。

コメント

タイトルとURLをコピーしました