[Flutter × Go] Withdrawal process: How to handle the “requires-recent-login” error when deleting Firebase Auth.

firebase-auth-requires-recent-login-error
This article can be read in about 22 minutes.

Introduction.

The following error occurred when the user clicked the button to unsubscribe from the application, although the user’s unsubscribe flow was created in the application with the talking cat-shaped robot “Mia”.

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)

#1      MethodChannelUser.delete (package:firebase_auth_platform_interface/src/method_channel/method_channel_user.dart:35:7)

#2      UserNotifier.deleteUser (package:clocky_app/api/user_notifier.dart:31:7)

Describes how to handle “requires-recent-login” errors when processing user withdrawal using Firebase Auth.

What is the problem? Occurs when there is no recent login

Here is the code for Flutter’s current user withdrawal process.

Dart
// lib/api/user_notifier.dart
class UserNotifier extends StateNotifier {
  final ApiClient apiClient;

  UserNotifier(this.apiClient) : super(null);

  Future 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;
  }
}

Checking the definition of the delete method of the FirebaseAuth class, the following warning was noted.

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 delete() async {
  return _delegate.delete();
}

This error occurs when a user has not logged in recently for security reasons. The user must be reauthenticated before being dismissed.

Incidentally, the Firebase documentation did not specify, as far as I could tell, how many days of inactivity would cause this error to appear.

Also, although not directly related to this issue, user data deletion in the DB had been performed prior to Firebase Auth data deletion, so when the “Unsubscribe” button was pressed, user data was deleted from the DB, but the Firebase Auth data remained undeleted This phenomenon was occurring, so it is necessary to recover this data as well.

Change the Firebase Auth deactivation process to use the Firebase Admin SDK on the Go server side instead of the Flutter front end. The SDK will automatically manage token expiration dates and update tokens as needed, thus separating the management of authentication status on the client side and eliminating the re-authentication error problem.

Go: Implement Firebase Auth Unsubscribe Process

Add Firebase Admin SDK to server

The Firebase Admin SDK is a set of server libraries used to operate Firebase from a privileged environment. Add the SDK by executing the following

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

Initialize SDK with OAuth 2.0 update token

Import Firebase Go SDK and use the GoogleApplicationCredentials stored in the Config structure to set options for credentials.

Then, an instance of the Firebase application is created with the configured authentication information as an argument.

This SDK initialization accesses Firebase Authentication through the generated *firebase.App instance to remove authentication data for a specific user at the time of the withdrawal request.

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
}

Add Firebase Auth user deletion: Firebase Auth -> DB

Incorporate the process of deleting users from Firebase Auth into the existing HandleDeleteUser method that deletes users from the DB during the withdrawal process.

Deleting Firebase Auth data is the most important and risky operation in the withdrawal process. This is because once a Firebase Auth account is deleted, its information cannot be reverted. Therefore, the user data deletion process should be changed in the following order

  • First, delete Firebase Auth user data.
  • Next, delete the user data in the database.

Add a new *firebase.App field to the UserHandler structure.

Create a new instance of type UserHandler using the NewUserHandler function and access the Firebase Admin SDK functions through the app field of that instance to remove a specific user from 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: change to only sending delete requests to API

Since the server side now uses the Firebase Admin SDK to handle user deletion, the deleteUser method on the Flutter side needs to be modified to not handle user deletion from Firebase Auth.

On the Flutter side, it is sufficient to send a delete request to the API, as the backend handles all the deletion processing.

API client (api_client.dart)

Modify the deleteUser function in api_client.dart so that the user ID is no longer required when sending a delete request to the server side. Instead, include an authentication token in the header to identify the user on the server side.

Dart
// lib/api/api_client.dart
Future 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}');
  }
}

User Notifier: Request user deletion through API client

In user_notifier.dart, remove the user removal process from Firebase Auth and simply request user removal through the API client.

Dart
// lib/api/user_notifier.dart
Future 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;  // エラーを再スローして、上位の処理でキャッチ可能に
  }
}

Create withdrawal completion screen

Call userNotifier.deleteUser() to perform the unsubscribe process, and after the unsubscribe process is successful, use Navigator.pushReplacement to transition the user to the WithdrawalCompleteScreen(). This allows the withdrawal process

Dart
// lib/screens/home/customer_support_screen.dart
//ユーザー削除ダイアログ
Future _showWithdrawDialog(BuildContext context) async {
  return showDialog(
    context: context,
    barrierDismissible: false,
    builder: (BuildContext dialogContext) {
      return AlertDialog(
        title: const Text('退会'),
        content: const Text('本当に退会しますか?この操作は取り消せません。'),
        actions: [
          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()));
                });
              },
            );
          })
        ],
      );
    },
  );
}

Inform the customer that the withdrawal has been successfully completed on the withdrawal processing screen.

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('新規登録へ戻る'),
            ),
          ],
        ),
      ),
    );
  }
}

operation check

When a user sends an unsubscribe request from the app, a Firebase ID token is passed to the server side as the value of the Authorization header, which is used for user authentication.

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

The server processes the user deletion based on a specific user ID (XXXXXXXX), succeeds, and returns HTTP status code 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}

In fact, we confirmed that the user in question was removed from Firebase Auth and from the DB.

The application screen also confirmed that the user can actually go to the new registration screen by clicking on the button that takes the user to the screen for completing the withdrawal and then back to the new registration.

コメント

Copied title and URL