TL;DR;
この記事は,Firebase Authenticationを実装するのに必要な知識を,簡単なログインアプリの実装を通して学んだ際のNoteです.
Firebase Authenticationとは
そもそもFirebaseというのはGogleが提供するバックエンド代替サービスです.
具体的には,バックエンドAPIを提供できるFunctionsサービス,RealTimeDatabaseやNoSQLサービスのFirestoreなどがあります(RemoteConfigやFirebaseHostingなどもあります).
その中でも,様々なSSOサービスを繋いでくれる認証プロバイダーの束役こそが,Firebase Authenticationというわけです.
Firebase Authenticationのメリットとは?
認証プロバイダーのメリットとは?
そもそも,認証プロバイダーには,FirebaseAuthenticationやSupabseAuthenticationなどが存在します.
これらは,世の中に散らばる認証プロバイダーを束ねて,独自のユニークなIDやidTokenなどを管理し,バックエンドやフロントエンドでユーザー認証の操作性を統一してくれるメリットがあります.
特に個人開発におけるメリットとしては,SSOなどの複雑かつ,セキュリティ要件の厳しい処理を代替してくれるというところが大きいです.
なぜFirebase Authenticationを採用したの?
これは,今回扱うフレームワークがGoogleのFlutterであることが大きく,導入の難易度を下げるために採用しました.
具体的には後述しますが,nodeのインストールと,firebase cliツールのインストール,をすまればあとは,普段のパッケージ追加と同じように扱うことができるのです.
Flutter系のアプリケーションを開発するならFirebase Authentication一択と言っていいほど簡単です.
(ただ,uidの管理方法やユーザーデータを扱うデータベースの設計が必須など少し面倒な点も・・・)
逆にReactやNext.jsを使ったフルスタック開発であれば,Firebaseも十分視野に入りますが,Nextとの親和性を考えて,Supabaseの採用も十分に考えられると思います.
実際に実装したもの
各クラスの解説
まず,本プロジェクトのディレクトリ構造は次のとおりです.
lib
├── firebase_options.dart
├── main.dart
├── models
│ └── app_user.dart
├── providers
│ └── auth_providers.dart
├── repositories
│ ├── auth_repository.dart
│ └── auth_repository_firebase.dart
├── routes
│ └── routes.dart
├── viewmodels
│ └── auth_viewmodel.dart
└── views
├── home_screen.dart
└── login_screen.dart
7 directories, 10 files
今回はrepositories -> viewmodels -> providers -> routes -> main.dartの順番に解説していきます.
repositories/auth_repository.dart
import 'dart:async';
abstract class AuthRepository<T> {
// signInは任意の値を返却する
FutureOr<T> signIn();
Future<void> signOut();
}
FirebaseAuthをはじめとして,認証プロバイダーの機能をまとめたインターフェースです.
repositories/auth_repository_firebase.dart
import 'package:flutter_firebase_auth/repositories/auth_repository.dart';
import 'package:firebase_auth/firebase_auth.dart';
class AuthRepositoryFirebase extends AuthRepository<User?> {
final FirebaseAuth _instance;
AuthRepositoryFirebase(this._instance);
@override
Future<User?> signIn() async {
// GoogleAuthProviderを使ってPopUpを使ってSignIn処理を行う
// 今回はWebのみを対象としての開発のため,この処理でOK
GoogleAuthProvider googleAuthProvider = GoogleAuthProvider();
// signin処理はこの部分で行う
return (await _instance.signInWithPopup(googleAuthProvider)).user;
}
@override
Future<void> signOut() async {
// サインアウト処理をラップ
await _instance.signOut();
}
}
実際にFirebaseAuthにおけるSignIn処理とSignOut処理をラップするためのRepositoryクラスです.
実際には,インスタンスかする際に,必ずFirebaseAuthをインジェクションする必要があるように設計しました.
viewmodels/auth_viewmodel.dart
import 'dart:async';
import 'package:flutter_firebase_auth/providers/auth_providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// AsyncNotifierはNotifier
class AuthViewmodel extends AsyncNotifier<void>{
@override
FutureOr<void> build() => null;
// PopupするGoogleのSSO
Future<void> signInWithGoogle() async {
await ref.watch(authRepositoryProvider).signIn();
}
// Firebase Authにおけるサインアウト処理
Future<void> signOut() async {
await ref.watch(authRepositoryProvider).signOut();
}
}
Viewの操作をラップし,Future型の状態変化を扱うためのサービスクラスとして,AsyncNotifierを採用している.実際には,authRepositoryProviderからRpositoryクラスをwatchし,その値をそのまま利用している.
providers/auth_provider.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_firebase_auth/repositories/auth_repository.dart';
import 'package:flutter_firebase_auth/repositories/auth_repository_firebase.dart';
import 'package:flutter_firebase_auth/viewmodels/auth_viewmodel.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 無理やりオーバーライド
final firebaseAuthInstanceProvider = Provider<FirebaseAuth>((ref) => throw UnimplementedError());
// インスタンスから排出されるステートの状態をStreamProviderがValueとして状態の値を配信
// Steam.broadcastを使用しなくても複数箇所でハンドリングするために
final authStateProvider = StreamProvider((ref) => ref.watch(firebaseAuthInstanceProvider).authStateChanges());
// AuthRepositoryにInstanceをDI
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final authRepository = AuthRepositoryFirebase(ref.watch(firebaseAuthInstanceProvider));
return authRepository;
});
// ViewModel自体のProvider
final authViewModelProvider = AsyncNotifierProvider(AuthViewmodel.new);
RepositoryとViewModelを繋げるためのProviderをまとめたものです.
firebaseAuthInstanceProviderはFirebaseAuthのインスタンスをシングルトン構成にするための,Providerです.
routes/routes.dart
import 'dart:async'; // 追加
import 'package:flutter/material.dart';
import 'package:flutter_firebase_auth/providers/auth_providers.dart';
import 'package:flutter_firebase_auth/views/home_screen.dart';
import 'package:flutter_firebase_auth/views/login_screen.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
final _key = GlobalKey<NavigatorState>();
final goRouterProvider = Provider<GoRouter>((ref) {
// Streamをwatchするのではなく,InstranceをWatchする
// これはStreamの値変化に応じてGoRouterがリビルドするのを防ぐため
// Steramの値変化はRedirectの発火にのみ用いられるべきである
final firebaseAuth = ref.watch(firebaseAuthInstanceProvider);
return GoRouter(
initialLocation: LoginScreen.path,
debugLogDiagnostics: true,
navigatorKey: _key,
refreshListenable: GoRouterRefreshStream(firebaseAuth.authStateChanges()),
routes: [
GoRoute(
path: HomeScreen.path,
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: LoginScreen.path,
builder: (context, state) => const LoginScreen(),
),
],
redirect: (context, state) {
// redirectが呼び出されるタイミングは2つ
// streamが新しい値を流した時
// 強制的に画面遷移された時
final currentUser = firebaseAuth.currentUser;
final isAuth = currentUser != null;
final currentPath = state.matchedLocation;
final isLoginScreen = currentPath == LoginScreen.path;
// ログイン済みの場合
if (isAuth) {
// ログイン画面にいるならホームへ飛ばす
if (isLoginScreen) {
return HomeScreen.path;
}
}
// 未ログインの場合
else {
// ホーム画面などにいるならログイン画面へ戻す
if (!isLoginScreen) {
return LoginScreen.path;
}
}
return null;
},
);
});
class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(Stream<dynamic> stream) {
notifyListeners();
_subscription = stream.listen(
(_) => notifyListeners(),
);
}
late final StreamSubscription<dynamic> _subscription;
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
このRouter機能が最も重要なポイントです!
どれだけ認証プロバイダーが高いセキュリティ要件を満たしていたとしても,それを使ってプログラミングする側が正しく利用できなければ,全く持って意味がありません.
具体的には,ログインもしていないのに,ログイン後のしか表示されないページや情報にアクセスできてしまうようなアプリ構造にしてはいけないのです.
そこで,今回は「ログインした人だけがログイン後のページにアクセスできるようにし,ログインしていない人がログイン後のページに飛ぼうとすればログインページに リダイレクト する」ようにすることで,アクセス保護を行おうと思います.
今回は,その状態によって変わるルーティング情報を,GoRouterオブジェクトをProviderで配信することで,動的に変化する状態を実現しました.
特に,Streamに乗って流れてくる認証情報を扱いつつ,GoRouterの更新回数を最低限にするために,GoRouterのRefreshLostenableプロパティに値の変化を通知するためのChangeNotifierを作成し,値として登録しました.
特に,ChangeNotifier内では,notifyListenersをStream型をlistenし,コールバック関数とすることで,値変化をNotifyしています.
リダイレクト機能の判定基準としては次のとおりです.
- リダイレクト関数が呼び出された時の現在のユーザーのデータを取得
- そのデータがあるかないかで,サインイン状況を判定
- 現在のパスをstateから取得
- ログインスクリーンにいるのかどうかを判定
- ログイン済みの場合に,ログイン画面にいるならHome画面へリダイレクト
- 未ログインの場合に,ログイン画面にいない場合はログイン画面へリダイレクト
- 最後のnullはここまで到達しないという意味
上記を実装しました.
views/home_screen.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_firebase_auth/providers/auth_providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class HomeScreen extends ConsumerWidget {
static final String path = '/home';
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final User? user = ref
.watch(authStateProvider)
.when(data: (d) => d, error: (e, s) => null, loading: () => null);
final String? displayName = user?.displayName;
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('SigninUser : $displayName'),
SizedBox(height: 30),
SizedBox(
width: 200,
child: ElevatedButton(
onPressed: () =>
ref.watch(authViewModelProvider.notifier).signOut(),
child: Text('サインアウト'),
),
),
],
),
),
);
}
}
サインアウト処理のみを実装した.
views/login_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_firebase_auth/providers/auth_providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginScreen extends ConsumerWidget {
static final String path = '/';
const LoginScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appBar = AppBar(title: Text('Firebase Authentication', style: TextStyle(color: Colors.grey.shade500)), centerTitle: true,);
final body = Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 50,
child: Image.asset('assets/web_light_rd_na@3x.png'),
),
SizedBox(
height: 20,
),
SizedBox(
width: 200,
child: IconButton(icon: Image.asset('assets/web_light_rd_SI@3x.png'), onPressed: () => ref.watch(authViewModelProvider.notifier).signInWithGoogle(),)
),
SizedBox(
height: 20,
),
Text('Secured by Firebase Authentication', style: TextStyle(color: Colors.grey.shade500),),
],
),
);
return Scaffold(appBar: appBar, body: body,);
}
}
GoogleAuthによって決められているGoogleのSSOボタンを実装し,サインイン処理のみを行なった.
main.darat
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_firebase_auth/providers/auth_providers.dart';
import 'package:flutter_firebase_auth/routes/routes.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'firebase_options.dart';
Future<void> main() async {
// アプリの完全な初期化を保証
WidgetsFlutterBinding.ensureInitialized();
// 実行している環境に応じて,アプリIDやプロジェクトIDなどを切り替える
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
final app = FlutterFirebaseAuthApp();
final scope = ProviderScope(overrides: [
firebaseAuthInstanceProvider.overrideWithValue(FirebaseAuth.instance)
], child: app);
runApp(scope);
}
class FlutterFirebaseAuthApp extends ConsumerWidget {
const FlutterFirebaseAuthApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp.router(routerConfig: ref.watch(goRouterProvider),);
}
}
FirebaseAuth.instanceをfirebaseAuthInstanceProviderへオーバーライドすることで,注入しています.
参考