認証認可をflutter+firebase+go_routerでやってみた
はじめに
Flutter on the WebでFirebase Authenticationとgo_routerを使って、認証・認可を実装する方法についてまとめました。
go_router無しで元々実装していましたが、こちらを使うと劇的にシンプルな構造になったのでおすすめです。
使用するライブラリ
今回使用するライブラリは以下の通りです。
dependencies:
flutter:
sdk: flutter
go_router: ^16.0.0
firebase_core: ^3.15.1
firebase_auth: ^5.6.2
実装
ポイント
GoRouter
のrefreshListenable
GoRouter
のrefreshListenable
にChangeNotifier
を渡すことで、認証状態の変更をgo_router
に通知し、リダイレクト処理を再実行させることができます。
class AuthNotifier extends ChangeNotifier {
late final StreamSubscription<User?> _authSubscription;
AuthNotifier() {
_authSubscription = FirebaseAuth.instance.authStateChanges().listen((_) {
notifyListeners();
});
}
@override
void dispose() {
_authSubscription.cancel();
super.dispose();
}
}
final _authNotifier = AuthNotifier();
final _router = GoRouter(
refreshListenable: _authNotifier,
// ...
);
redirect
での認証チェック
redirect
コールバック内で、ユーザーのログイン状態を確認し、未ログインの場合はログインページにリダイレクトします。
redirect: (BuildContext context, GoRouterState state) async {
final user = FirebaseAuth.instance.currentUser;
final bool loggedIn = user != null;
final bool loggingIn = state.matchedLocation == '/login';
if (!loggedIn) {
return loggingIn ? null : '/login';
}
if (loggingIn) {
return '/';
}
return null;
},
カスタムクレームを利用した認可
Firebase Authenticationのカスタムクレームを利用して、ユーザーにロールを付与し、特定のページへのアクセスを制御します。
Future<bool> _isUserAdmin() async {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return false;
try {
final idTokenResult = await user.getIdTokenResult(true); // Force refresh
return idTokenResult.claims?['role'] == 'admin';
} catch (e) {
print('Error getting custom claims: $e');
return false;
}
}
redirect
内で_isUserAdmin
を呼び出し、管理者でないユーザーが管理者ページにアクセスしようとした場合は、権限がないことを示すページにリダイレクトします。
redirect: (BuildContext context, GoRouterState state) async {
// ...
final bool isAdminRoute = state.matchedLocation == '/admin';
if (isAdminRoute) {
final bool isAdmin = await _isUserAdmin();
if (!isAdmin) {
return '/unauthorized';
}
}
return null;
},
main.dart
の全体像
最後に、main.dart
の全体像を示します。
import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:go_auth/sample_page.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'firebase_options.dart';
import 'package:go_auth/admin_page.dart';
import 'package:go_auth/unauthorized_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
if (kDebugMode) {
try {
await FirebaseAuth.instance.useAuthEmulator('localhost', 9099);
} catch (e) {
print('Failed to connect to Auth Emulator: $e');
}
}
usePathUrlStrategy();
runApp(const MyApp());
}
class AuthNotifier extends ChangeNotifier {
late final StreamSubscription<User?> _authSubscription;
AuthNotifier() {
_authSubscription = FirebaseAuth.instance.authStateChanges().listen((_) {
notifyListeners();
});
}
@override
void dispose() {
_authSubscription.cancel();
super.dispose();
}
}
final _authNotifier = AuthNotifier();
Future<bool> _isUserAdmin() async {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return false;
try {
final idTokenResult = await user.getIdTokenResult(true); // Force refresh
return idTokenResult.claims?['role'] == 'admin';
} catch (e) {
print('Error getting custom claims: $e');
return false;
}
}
final _router = GoRouter(
refreshListenable: _authNotifier,
routes: [
GoRoute(
path: '/',
builder: (context, state) =>
const MyHomePage(title: 'Flutter Demo Home Page'),
),
GoRoute(path: '/sample', builder: (context, state) => const SamplePage()),
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
GoRoute(path: '/admin', builder: (context, state) => const AdminPage()),
GoRoute(
path: '/unauthorized',
builder: (context, state) => const UnauthorizedPage(),
),
],
redirect: (BuildContext context, GoRouterState state) async {
final user = FirebaseAuth.instance.currentUser;
final bool loggedIn = user != null;
final bool loggingIn = state.matchedLocation == '/login';
final bool isAdminRoute = state.matchedLocation == '/admin';
if (!loggedIn) {
return loggingIn ? null : '/login';
}
if (loggingIn) {
return '/';
}
if (isAdminRoute) {
final bool isAdmin = await _isUserAdmin();
if (!isAdmin) {
return '/unauthorized';
}
}
return null;
},
);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
bool _isAdmin = false;
@override
void initState() {
super.initState();
_checkAdminStatus();
}
Future<void> _checkAdminStatus() async {
final isAdmin = await _isUserAdmin();
if (mounted) {
setState(() {
_isAdmin = isAdmin;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await FirebaseAuth.instance.signOut();
},
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You are logged in!'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => context.go('/sample'),
child: const Text('Go to Sample Page'),
),
if (_isAdmin)
Padding(
padding: const EdgeInsets.only(top: 20.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[700],
),
onPressed: () => context.go('/admin'),
child: const Text(
'Admin Panel',
style: TextStyle(color: Colors.white),
),
),
),
],
),
),
);
}
}
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Card(
elevation: 8.0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Welcome Back',
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Sign in to continue',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
TextField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
),
),
obscureText: true,
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
),
onPressed: () async {
print('Login button pressed');
try {
print('Attempting to sign in with email: ${_emailController.text}');
await FirebaseAuth.instance
.signInWithEmailAndPassword(
email: _emailController.text,
password: _passwordController.text,
);
print('Sign in successful');
} on FirebaseAuthException catch (e) {
print('Failed to sign in: ${e.message}');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to sign in: ${e.message}'),
backgroundColor: Colors.red,
),
);
}
},
child: const Text('Login'),
),
),
],
),
),
),
),
),
),
);
}
}
まとめ
Firebase Authenticationとgo_routerを組み合わせることで、Flutter on the Webアプリケーションに柔軟な認証・認可機能を実装できます。特に、refreshListenable
とredirect
を活用することで、宣言的に認証状態を管理できるのが大きなメリットです。