方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Firebase】メールアドレス変更時にemailVerifiedがfalseにならない問題の解決策

firebase-mail-change-email-verified-error
この記事は約17分で読めます。

はじめに

方言を話すおしゃべり猫型ロボット「ミーア」を開発中。

https://mia-cat.com

アプリでのユーザー認証はFirebaseを利用しているが、一度Firebaaseでユーザー認証を済ませた後にメールアドレスをユーザーが変更したい場合に、メール認証をしなくてもユーザーがメールアドレスを変更できてしまうエラーが発生していたので、今回はこちらの解消を試みる。

メールアドレス変更時はverifyBeforeUpdateEmail()メソッドを利用

Firebaseのドキュメントに記載のように、ユーザーのメールアドレス変更時は、FirebaseAuth.instance.currentUser.verifyBeforeUpdateEmail() メソッドを利用して、新しいメールアドレスを引数にとることで、新しいメールアドレスにメール認証を送ることができる。

https://firebase.google.com/docs/reference/js/v8/firebase.User#verifybeforeupdateemail

この場合、ユーザーが新しいメールアドレスに送られてきたメール認証のリンクをクリックしたタイミングで、Firebase Authenticationに登録されているユーザーのメールアドレスが古いメールアドレスから新しいメールアドレスに切り替わる。

そして、メール認証したかどうかはFirebaseAuth.instance.currentUser.emailVerified を用いて判定できる。

user.emailVerifiedはメールアドレス変更時には役に立たない

初回メール登録時には emailVerified はもちろんメール登録していない状態では false なので、メール認証したかどうかの判定として emailVerified は有用だが、初回メール登録後にメールアドレスを変更する場合には、古いメールアドレスの emailVerified true が引き継がれてしまうために、新しいメールアドレス認証をクリックしなくても emailVerified true で通過してしまう問題がある。

await FirebaseAuth.instance.currentUser.reload() を手前に行ったとしてもこのエラーは解消できない。

つまり、下記コードでは、if (user != null && user.emailVerified) をtrueですり抜けてしまう。

Dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';

class MailCheckScreen extends StatefulWidget {
  final String email;

  const MailCheckScreen({super.key, required this.email});

  @override
  _MailCheckScreenState createState() => _MailCheckScreenState();
}

class _MailCheckScreenState extends State<MailCheckScreen> {
  final TextEditingController _authCodeController = TextEditingController();
  final _auth = FirebaseAuth.instance;
  bool isLoading = false;

  void _checkMail() async {
    setState(() {
      isLoading = true;
    });
    // 現在のユーザーを取得します
    User? user = _auth.currentUser;

    if (user != null) {
      // ユーザーの最新の情報を取得するためにリロードします
      await user.reload();
      user = _auth.currentUser;

      if (user != null && user.emailVerified) {
        setState(() {
          isLoading = false;
        });
        // メール認証が完了している場合
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => const LoginScreen(),
          ),
        );
      } else {
        // メール認証がまだ完了していない場合
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text("メールが未確認です。メールを確認してください。"),
          ),
        );
      }
      setState(() {
        isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return BaseContainer();
  }
}

というわけで、別の方法で新しいメールアドレスを認証したかどうかを検出できるようにする必要がある。

同じIssueに遭遇している人いた。
https://github.com/firebase/flutterfire/issues/6426

メールアドレス変更用のチェッククラスを用意

メールアドレス変更時に、新しいメールアドレスが認証されているかどうかをチェックするために、widget.emailを利用してメールアドレスの一致を確認する。

新しいメールに送られたメール認証をユーザーがしていない段階ではwidget.emailは新しいメールアドレスだが、user.emailは古いメールアドレスのまま(firebase authenticationでメールアドレスが更新されない)なので、メール認証を完了できないようになる。

Dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';

class MailCheckScreen extends StatefulWidget {
  final String email;

  const MailCheckScreen({super.key, required this.email});

  @override
  _MailCheckScreenState createState() => _MailCheckScreenState();
}

class _MailCheckScreenState extends State<MailCheckScreen> {
  final TextEditingController _authCodeController = TextEditingController();
  final _auth = FirebaseAuth.instance;
  bool isLoading = false;

  void _checkMail() async {
    setState(() {
      isLoading = true;
    });
    // 現在のユーザーを取得します
    User? user = _auth.currentUser;

    if (user != null) {
      // ユーザーの最新の情報を取得するためにリロードします
      await user.reload();
      user = _auth.currentUser;

      if (user != null && user.email == widget.email && user.emailVerified) {
        setState(() {
          isLoading = false;
        });
        // メール認証が完了している場合
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => const LoginScreen(),
          ),
        );
      } else {
        // メール認証がまだ完了していない場合
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text("メールが未確認です。メールを確認してください。"),
          ),
        );
      }
      setState(() {
        isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return BaseContainer();
  }
}

実際にログを見ると下記のようになる。

ちなみにuser.emailVerifiedは、メール認証を使用がしまいが、trueのままである。

ShellScript
<asynchronous suspension>
flutter: 現在のメール:変更前のメールアドレス
flutter: MailChangeCheckScreenに渡されたemailプロパティ:変更後の新しいメールアドレス
flutter: メール認証ステータス:true

user.email == widget.email の条件を追加したことで、ユーザーが新しいメール認証をしていない段階で確認完了を押した時にはSnackBarにエラーが表示されるようになった。

メール認証後のトークン切れ問題に対応

ちなみに、上記だけでは不十分で、ユーザーがメール認証したとしても、下記エラーが生じてログイン画面に遷移しない。

ShellScript
flutter: error [firebase_auth/user-token-expired] The user's credential is no longer valid. The user must sign in again.

これは、verifyBeforeUpdateEmail() メソッドで新しいメール認証をした時点でFirebase Authenticationのメールアドレスが変更されているにも関わらず、Flutterの画面はまだログイン画面に遷移していないため、古いメールアドレスのままで確認完了ボタンを押したことによる。このため、await user.reload();の段階でトークン切れエラーが発生する。

というわけで、await user.reload();を含む全体をtry catchメソッドで囲み、トークン切れの場合には再ログインを促すようにする。

最終的なメール変更確認画面は下記。

Dart
import 'package:clocky_app/screens/login/login_screen.dart';
import 'package:clocky_app/screens/registration/email_registration_screen.dart';
import 'package:clocky_app/widgets/base_container.dart';
import 'package:clocky_app/widgets/buttons.dart';
import 'package:clocky_app/widgets/spacing.dart';
import 'package:clocky_app/widgets/texts.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';

class MailChangeCheckScreen extends StatefulWidget {
  final String email;

  const MailChangeCheckScreen({super.key, required this.email});

  @override
  _MailChangeCheckScreenState createState() => _MailChangeCheckScreenState();
}

class _MailChangeCheckScreenState extends State<MailChangeCheckScreen> {
  final TextEditingController _authCodeController = TextEditingController();
  final _auth = FirebaseAuth.instance;
  bool isLoading = false;

  void _checkMail() async {
    setState(() {
      isLoading = true;
    });
    // 現在のユーザーを取得
    User? user = _auth.currentUser;

    if (user != null) {
      try {
        // ユーザーの最新の情報を取得するためにリロード
        await user.reload();
        user = _auth.currentUser;

        debugPrint('現在のメール:${user?.email}');
        debugPrint('MailChangeCheckScreenに渡されたemailプロパティ:${widget.email}');
        debugPrint('メール認証ステータス:${user?.emailVerified}');

        if (user != null && user.email == widget.email && user.emailVerified) {
          setState(() {
            isLoading = false;
          });
          // メール認証が完了している場合
          Navigator.pushReplacement(
            context,
            MaterialPageRoute(
              builder: (context) => const LoginScreen(),
            ),
          );
        } else {
          // メール認証がまだ完了していない場合
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(
              content: Text("メールが未確認です。メールを確認してください。"),
            ),
          );
        }
      } catch (e) {
        debugPrint("user info cannot reload");
        debugPrint('error ${e.toString()}');
        if (e.toString().contains('user-token-expired')) {
          // トークンが期限切れの場合
          Navigator.pushReplacement(
            context,
            MaterialPageRoute(
              builder: (context) => const LoginScreen(),
            ),
          );
        } else {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text('エラーが発生しました: $e'),
            ),
          );
        }
      } finally {
        setState(() {
          isLoading = false;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return BaseContainer(
      children: <Widget>[
        const HeaderText("認証メールを送信しました"),
        Spacing.h16(),
        const Text(
            "入力いただいたメールアドレスに届いた認証リンクをクリックしてください。メール認証を終えたら、再度この画面に戻り「確認完了」ボタンを押してください。"),
        Spacing.h16(),
        AppButton(
          onPressed: _checkMail,
          text: '確認完了',
        ),
        Spacing.h16(),
        AppButton(
          onPressed: () async {
            // メール再送信
            _auth.currentUser?.sendEmailVerification().then((value) => {
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(
                      content: Text("認証メールを再送信しました"),
                    ),
                  )
                });
          },
          text: 'メールを再送する',
          styleType: ButtonStyleType.SECONDARY,
        ),
        Spacing.h16(),
        AppButton(
          onPressed: () async {
            // logout
            _auth.signOut().then((value) => {
                  Navigator.pushReplacement(
                    context,
                    MaterialPageRoute(
                      builder: (context) => const EmailRegistrationScreen(),
                    ),
                  )
                });
          },
          text: '最初からやり直す',
          styleType: ButtonStyleType.SECONDARY,
        ),
      ],
    );
  }
}

上記変更で、無事ユーザーがメール認証した場合でもログイン画面に遷移されるようになった。

動作確認

全体的な画面遷移は下記。

ユーザーが新しいメールアドレスを入力して、更新するをクリックすると、認証メール送信の画面に遷移。

そして、メール認証をしない状態で確認完了をユーザーが押すと、SnackBarでエラーが表示されログイン画面には遷移できずに、メール認証をした場合には確認完了を押すとログイン画面に遷移し、ユーザーは新しいメールアドレスとパスワードで再度サインインする。

無事サインインできれば、トークン切れ問題も解消される。

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