2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

認証認可をflutter+firebase+go_routerでやってみた

はじめに

Flutter on the WebでFirebase Authenticationとgo_routerを使って、認証・認可を実装する方法についてまとめました。
go_router無しで元々実装していましたが、こちらを使うと劇的にシンプルな構造になったのでおすすめです。

使用するライブラリ

今回使用するライブラリは以下の通りです。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  go_router: ^16.0.0
  firebase_core: ^3.15.1
  firebase_auth: ^5.6.2

実装

ポイント

GoRouterrefreshListenable

GoRouterrefreshListenableChangeNotifierを渡すことで、認証状態の変更を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の全体像を示します。

lib/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アプリケーションに柔軟な認証・認可機能を実装できます。特に、refreshListenableredirectを活用することで、宣言的に認証状態を管理できるのが大きなメリットです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?