💙

Flutter+Firebaseで認証周りの設計を考えてみる

2022/02/20に公開約16,500字

はじめに

Flutter+Firebaseで認証機能の実装に毎回悩んでいるので、どんなプロジェクトでも使い回せるようなクラス設計を意識してテンプレート的に実装してみました。

今後使いながらより改善していくつもりですが、ぜひ皆さんの意見もいただければと思い公開させていただきます。

今回実装する認証機能について

今回はメールアドレス認証とGoogleサインインの2つの認証機能を実装していきます。
メールアドレス認証でログイン後、アプリ内でGoogleアカウントとSNS連携・連携解除できるような機能も実装しています。

全体の実装イメージ(なんちゃってクラス図)

まずAuthBaseを定義し、それを継承するEmailAuthBaseSnsAuthBaseを定義しています。
そしてさらに、EmailAuthBaseSnsAuthBaseを継承して具象クラスであるEmailAuthGoogleAuthを実装します。

メールアドレス認証とSNS認証で共通している部分はAuthBaseで定義して、インターフェイスが異なる部分はEmailAuthBaseSnsAuthBaseで別途定義しているというわけです。

こういう実装にしておくことで、例えば後からAppleサインインを追加したいと思った場合でもSnsAuthBaseを継承するだけで楽に追加しやすくなるはずです。

AuthBaseの実装

AuthBaseは認証状態の変化の監視と、認証済みプロバイダの一覧取得を担っています。
コード内に適宜大事な点をコメントしていますので、ご参考にしてください。

auth_base.dart
import 'dart:async';

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_playground/providers/firebase.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

// abstractを付けてインスタンス化を禁止する
abstract class AuthBase with ChangeNotifier {
  AuthBase({required Reader reader})
      : _reader = reader,
        super() {
    WidgetsBinding.instance?.addPostFrameCallback(
      (_) {
        // ユーザーの状態変化をトリガーにnotifyListeners()を呼び出す
        _userChangeSubscription ??= _auth.userChanges().listen(
              (_) => notifyListeners(),
            );
      },
    );
  }

  // 外部に公開する必要のないフィールドはprivateにする
  final Reader _reader;
  late final _auth = _reader(firebaseAuthProvider);

  // AuthBaseクラスを継承するクラス間で1つのインスタンスでいいためstaticにする
  static StreamSubscription<User?>? _userChangeSubscription;

  // 子クラスでoverrideした場合は必ずこのメソッドも呼んでもらうために
  // @mustCallSuperアノテーションを付ける
  
  
  Future<void> dispose() async {
    // リッスンしたStreamは必ず停止する
    await _userChangeSubscription?.cancel();
    super.dispose();
  }

  /// 認証しているプロバイダー一覧を取得
  List<UserInfo> providerData() => _auth.currentUser?.providerData ?? [];
  List<String> providerIds() =>
      _auth.currentUser?.providerData.map((e) => e.providerId).toList() ?? [];
}

EmailAuthBaseの実装

EmailAuthBaseにはメールアドレス認証に必要な一通りのメソッドを定義しています。
こちらも抽象クラスのため、定義だけおこない実装はしていません。

Dartにはprotectedが完全にサポートされていませんが、@protectedアノテーションを付けることで外部からそのメソッドを呼び出したときに警告を出してくれるようにできます。

email_auth_base.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_playground/providers/auth/auth_base.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

// abstractを付けてインスタンス化を禁止する
abstract class EmailAuthBase extends AuthBase {
  EmailAuthBase({required Reader reader}) : super(reader: reader);

  /// 認証資格を取得
  /// クラス外部からは使用する必要のないメソッドのためprotectedにする
  
  Future<AuthCredential> authCredential(String email, String password);

  /// 新規登録
  Future<User?> register(String email, String password);

  /// メールアドレスを確認
  Future<bool> sendEmailVerification();

  /// サインイン
  Future<User?> signIn(String email, String password);

  /// SNS連携済みか
  bool isAlreadyLinked();

  /// メール連携
  Future<User?> link(String email, String password);

  /// サインアウト
  void signOut();

  /// メール連携解除
  Future<void> unlink();

  /// 認証情報を削除
  Future<bool> delete();

  /// 再認証
  Future<bool> reauth(String email, String password);
}

SnsAuthBaseの実装

SnsAuthBaseにはSNS認証に必要な一通りのメソッドを定義しています。
こちらもEmailAuthBaseと同様に、実装はしていません。

sns_auth_base.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_playground/providers/auth/auth_base.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

abstract class SnsAuthBase extends AuthBase {
  SnsAuthBase({required Reader reader}) : super(reader: reader);

  /// 認証資格を取得
  
  Future<OAuthCredential?> oAuthCredential();

  /// サインイン
  Future<User?> signIn();

  /// SNS連携済みか
  bool isAlreadyLinked();

  /// SNS連携
  Future<User?> link();

  /// サインアウト
  Future<void> signOut();

  /// SNS連携解除
  Future<void> unlink();

  /// 認証情報を削除
  /// retryをtrueにすることで認証情報の削除失敗時に再認証を行い、
  /// 再度認証情報削除を試みる
  Future<bool> delete([bool retry = false]);

  /// 再認証
  
  Future<bool> reauth();
}

EmailAuthNotifierの実装

いよいよ具象クラスの実装です。
まずはメールアドレス認証の実装をしているEmailAuthNotifierのコードになります。

EmailAuthNotifierとしている理由は、このクラスが、ChangeNotifierを継承しているAuthBaseと継承関係にあるからです。
今回はRiverpodでemailAuthProviderとしてProviderを提供する形にしています。

email_auth.dart
import 'dart:async';

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_playground/common/app_logger.dart';
import 'package:flutter_playground/providers/auth/email_auth_base.dart';
import 'package:flutter_playground/providers/firebase.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

/// AuthBaseがChangeNotifierを継承しているため、emailAuthProviderとして提供できる
final emailAuthProvider = ChangeNotifierProvider.autoDispose<EmailAuthNotifier>(
  (ref) => EmailAuthNotifier(reader: ref.read),
);

class EmailAuthNotifier extends EmailAuthBase {
  EmailAuthNotifier({required Reader reader})
      : _reader = reader,
        super(reader: reader);

  final Reader _reader;
  late final _auth = _reader(firebaseAuthProvider);

  final _emailAuthProviderId = 'password';

  
  Future<AuthCredential> authCredential(String email, String password) async {
    return EmailAuthProvider.credential(
      email: email,
      password: password,
    );
  }

  /// 新規登録
  
  Future<User?> register(
    String email,
    String password,
  ) async {
    try {
      final result = await _auth.createUserWithEmailAndPassword(
        email: email,
        password: password,
      );
      return result.user;
    } catch (error) {
      AppLogger().error(error);
      return null;
    }
  }

  /// メールアドレスを確認
  
  Future<bool> sendEmailVerification() async {
    final user = _reader(currentUserProvider);
    if (user == null) {
      return false;
    }
    await user.sendEmailVerification();
    return true;
  }

  /// サインイン
  
  Future<User?> signIn(
    String email,
    String password,
  ) async {
    try {
      final userCredential = await _auth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
      return userCredential.user;
    } catch (error) {
      AppLogger().error(error);
      return null;
    }
  }

  /// SNS連携済みかを確認
  
  bool isAlreadyLinked() {
    return _auth.currentUser?.providerData
            .where((info) => info.providerId == _emailAuthProviderId)
            .isNotEmpty ??
        false;
  }

  /// メール連携
  
  Future<User?> link(
    String email,
    String password,
  ) async {
    // 事前にログインしている状態でなければリンクできない
    if (_auth.currentUser == null) {
      return null;
    }

    try {
      final credential = await authCredential(email, password);
      final userCredential = await _auth.currentUser!.linkWithCredential(
        credential,
      );
      return userCredential.user;
    } catch (error) {
      AppLogger().error(error);
      return null;
    }
  }

  /// サインアウト
  
  Future<void> signOut() async {
    await _auth.signOut();
  }

  /// メール連携解除
  
  Future<void> unlink() async {
    try {
      await _auth.currentUser?.unlink(_emailAuthProviderId);
    } on FirebaseAuthException catch (error) {
      AppLogger().error(error);
    } on Exception catch (error) {
      AppLogger().error(error);
    }
  }

  /// 認証情報を削除
  
  Future<bool> delete() async {
    if (_auth.currentUser == null) {
      return false;
    }

    try {
      await _auth.currentUser!.delete();
      return true;
    } on FirebaseException catch (error) {
      AppLogger().error(error);
      return false;
    } on Exception catch (error) {
      AppLogger().error(error);
      return false;
    }
  }

  /// 再認証
  /// リンク解除時など、センシティブな操作をおこなう時に
  /// 再認証が必要な場合にコールする
  
  Future<bool> reauth(String email, String password) async {
    if (_auth.currentUser == null) {
      return false;
    }

    final credential = await authCredential(email, password);
    final userCredential =
        await _auth.currentUser!.reauthenticateWithCredential(credential);

    return userCredential.user != null;
  }
}

GoogleAuthNotifierの実装

基本的にはEmailAuthNotifierと同じ流れです。
SnsAuthBaseを継承して各メソッドをoverrideして実装しています。

google_auth.dart
import 'dart:async';

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_playground/common/index.dart';
import 'package:flutter_playground/providers/auth/sns_auth_base.dart';
import 'package:flutter_playground/providers/firebase.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

/// AuthBaseがChangeNotifierを継承しているため、googleAuthProviderとして提供できる
final googleAuthProvider =
    ChangeNotifierProvider.autoDispose<GoogleAuthNotifier>(
  (ref) => GoogleAuthNotifier(reader: ref.read),
);

class GoogleAuthNotifier extends SnsAuthBase {
  GoogleAuthNotifier({required Reader reader})
      : _reader = reader,
        super(reader: reader);

  final Reader _reader;
  late final _auth = _reader(firebaseAuthProvider);

  final _googleAuthProviderId = 'google.com';

  final _googleSignInHandler = GoogleSignIn(
    scopes: [
      'email',
      'https://www.googleapis.com/auth/contacts.readonly',
    ],
  );

  
  Future<OAuthCredential?> oAuthCredential() async {
    try {
      final googleSignInAccount = await _googleSignInHandler.signIn();
      final googleSignInAuthentication =
          await googleSignInAccount?.authentication;

      if (googleSignInAuthentication == null) {
        throw Exception(
          'Could not retrieve [GoogleSignInAuthentication] for this account.',
        );
      }

      return GoogleAuthProvider.credential(
        accessToken: googleSignInAuthentication.accessToken,
        idToken: googleSignInAuthentication.idToken,
      );
    } on Exception catch (error) {
      AppLogger().error(error);
      return null;
    }
  }

  /// サインイン
  /// デバッグモードではキャンセル操作を行うとエラーが出る(パッケージのバグ)
  /// https://stackoverflow.com/questions/59561486/canceling-google-sign-in-cause-an-exception-in-flutter
  
  Future<User?> signIn() async {
    try {
      final credential = await oAuthCredential();
      if (credential == null) {
        throw Exception('oAuthCredential is null.');
      }
      final userCredential = await _auth.signInWithCredential(credential);
      return userCredential.user;
    } on Exception catch (error) {
      AppLogger().error(error);
      return null;
    }
  }

  /// SNS連携済みかを確認
  
  bool isAlreadyLinked() {
    return _auth.currentUser?.providerData
            .where((info) => info.providerId == _googleAuthProviderId)
            .isNotEmpty ??
        false;
  }

  /// SNS連携
  
  Future<User?> link() async {
    // 事前にログインしている状態でなければリンクできない
    if (_auth.currentUser == null) {
      return null;
    }

    try {
      final credential = await oAuthCredential();
      if (credential == null) {
        throw Exception('oAuthCredential is null.');
      }
      final userCredential = await _auth.currentUser!.linkWithCredential(
        credential,
      );
      return userCredential.user;
    } on Exception catch (error) {
      AppLogger().error(error);
      return null;
    }
  }

  /// サインアウト
  
  Future<void> signOut() async {
    await _googleSignInHandler.signOut();
    await _auth.signOut();
  }

  /// google認証とのリンクを解除
  
  Future<void> unlink() async {
    try {
      final ids = providerIds();
      if (ids.length == 1 && ids[0] == _googleAuthProviderId) {
        // リンクを解除する前にメアド認証するように促す
        AppLogger().info(
          'You need to log in with your email address before unlink Google account.',
        );
      }
      await _auth.currentUser?.unlink(_googleAuthProviderId);
    } on FirebaseAuthException catch (error) {
      AppLogger().error(error);
    } on Exception catch (error) {
      AppLogger().error(error);
    }
  }

  /// 認証情報を削除する
  /// 直近5分前後に認証している必要があるため、
  /// retryをtrueにすると削除に失敗した時は再認証後、
  /// 再度削除処理をトライするようにしている
  
  Future<bool> delete([bool retry = false]) async {
    if (_auth.currentUser == null) {
      return false;
    }

    try {
      await _auth.currentUser!.delete();
      return true;
    } on FirebaseException catch (error) {
      AppLogger().error(error);
      // 再認証が必要かつ、リトライフラグが立っている時
      if (error.code == 'requires-recent-login' && retry) {
        AppLogger().info('Retry...');
        final isSuccess = await reauth();
        return isSuccess ? await delete(false) : false;
      }
    }
    return false;
  }

  
  Future<bool> reauth() async {
    if (_auth.currentUser == null) {
      return false;
    }

    final credential = await oAuthCredential();
    if (credential == null) {
      return false;
    }
    final userCredential =
        await _auth.currentUser!.reauthenticateWithCredential(credential);

    return userCredential.user != null;
  }
}

認証状態によって画面を切り替える実装例

ウィジェットツリーの上部にて、StreamBuilderFirebaseAuth.instance.userChanges()を監視しておくことで、ユーザーのログイン状態の変化に応じて画面を切り替えることができるようになります。

以下はそのサンプルコードになります(不要な箇所は省略しているため、あくまでイメージです)。

app.dart
import 'package:firebase_auth/firebase_auth.dart';
...

class App extends HookConsumerWidget {
  const App({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      ...
      home: const AuthGate(),
    );
  }
}

class AuthGate extends HookConsumerWidget {
  const AuthGate({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final emailAuthController = ref.watch(emailAuthProvider);
    // userChanges()をstreamBuilderで監視することで、
    // 認証状態が変わった時に返す画面を変えることができる
    return StreamBuilder(
      stream: FirebaseAuth.instance.userChanges(),
      builder: (context, AsyncSnapshot<User?> snapshot) {
        // Waiting
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Scaffold(
            body: CircularProgressIndicator.adaptive(),
          );
        }
        // ログイン済み
	// ホーム画面へ遷移
        if (snapshot.hasData && snapshot.data != null) {
          return const HomePage();
        }
        // メールアドレス認証ができていない
        if (!(snapshot.data?.emailVerified ?? false)) {
          return RegisterPage(
            onRegisterPressed: (email, password) {
              emailAuthController.register(email, password);
            },
          );
        }
        // ログインしていない
        return RegisterPage(
          onRegisterPressed: (email, password) {
            emailAuthController.register(email, password);
          },
        );
      },
    );
  }
}

最後に

今回はできるだけインターフェイスを共通化し、拡張もしやすいクラス設計を意識して実装方法を考えてみました。
まだまだ改善の余地はありそうですが、皆さんの意見(もしいただければ)を参考に改良していけたらと考えています。

最後に、僕が働かせていただいている会社の紹介をさせていただきます。
Flutterのみならず、Webや他の分野にも興味がある方を募集しております。
少しでも気になった方はぜひご応募していただければと思います。

Discussion

ログインするとコメントできます