Flutter から Firebase を使う

Firebase のユーザ認証システムを Web アプリから使うという記事を前に出しました.
今度は、モバイルアプリ向けということで、同じ Firebase 認証システムを Flutter アプリから使ってみます.

Firebase にアプリ登録

最初に Firebase コンソールで、アプリケーションの登録をします。

前の記事で、Web アプリを登録していますのでそちらも参照ください。 同じ場所に追加で、 Android と iOS アプリを登録します。

開発中は、あまりアプリの ID 等は気にしないと思いますが、Firebase に登録する時には、きちんと決めておく必要があります。 一度登録したら変更できませんので (登録しているものを消して新しく作り直すとか面倒な作業が必要です)、考えてから設定してください。

通常、アプリ ID は、FQDN (Fully Qualified Domain Name) を逆読みしたものを使います。 com.techaas.flutter.firebase_auth のように、FQDN に プラットフォーム + アプリ名のようにして、重複しない様にします。

Firebase console

Firebase コンソールでアプリを追加すると構成ファイルがダウンロードできるようになります。 Android版の方は、ダウンロードしたものをandroid/app/google-services.jsonに配置しておきます。

iOS版はios/Runner/GoogleService-Info.plistに配置します。

構成ファイルは間違って公開されないように、.gitignoreなどに追加しておきましょう。

Flutter から Firebase を使う準備

Flutter から Firebase を使うための準備ですが、少しずつですが、色々なファイルを変更する必要があります。

android/build.gradleclasspath を追加します。

buildscript {
    ...
    dependencies {
        ...
        classpath 'com.google.gms:google-services:4.3.8'  // Google Services plugin
    }
}

android/app/build.gradle で plugin を使えるようにします。

apply plugin: 'com.google.gms.google-services'  // Google Services plugin

あと、同じファイルの applicationId を Firebase コンソールで登録した ID に合わせておいて下さい。

Flutter でパッケージを追加します。pubspec.yamlに以下を追加します。

dependencies:
  flutter:
    sdk: flutter
  ...
  firebase_core: ^1.4.0
  firebase_analytics: ^8.2.0
  firebase_auth: ^3.0.1
  flutter_signin_button: ^2.0.0

  http: ^0.13.3

パッケージを追加したら、pub getして、追加したパッケージをダウンロードしてください。

firebase_で始まるパッケージは、Firebaseのライブラリです。機能ごとに分かれているので必要なものを追加します。

flutter_signin_buttonは、ソーシャルログイン用のボタンを作ってくれるパッケージです。ご自分でデザインするなら必要ありません。

httpは、HTTPのネットワークライブラリでRestful APIなどを使う時に使用します。 今回は、JWT トークンの検証テストをする時に使います。

また、ios/Podfileの先頭あたりで、最低バージョンを設定します。これをやらないと、firebaeのビルドができませんでした。

platform :ios, '10.0'

iOSの方ですが、Firebaseのパッケージを使うために、pod repo updateが必要でした。 (cocoapod とか詳しくは書きませんが、この辺でハマると、少しネイティブの開発の知識が必要になります)

メイン画面

ソースコードは、以下にありますので、参照してください。

Github - flutter_firebase

まず、アプリの起動時に Firebase に接続します。

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}

WidgetsFlutterBinding.ensureInitialized() は、Firebaseを初期化する前に必要です。

SignInOutPageがメイン画面になります。

final FirebaseAuth _auth = FirebaseAuth.instance;

class SignInOutPage extends StatefulWidget {
  ...
}

class _SignInOutPageState extends State<SignInOutPage> {
  User? user;

  @override
  void initState() {
    _auth.userChanges().listen((event) {
      debugPrint('user: $event');
      setState(() => user = event);
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Container(
        margin: EdgeInsets.only(top: 100),
        alignment: Alignment.center,
        child: (user == null) ? _EmailPasswordForm() : _UserInfoCard(user),
      ),
    );
  }
}

initStateの中でログイン変化に合わせてUserが変化する様にしています。

userChanges()は、Stream<User?>を返します。Stream をlisten()して、そこでsetState()しています。 ユーザの状態が変化したら、Streamに変化後のUserが流れてきますので、それに応じて UI がリビルドされるようになります。

画面の方ですが、userが null はログインされていなので_EmailPasswordFormを表示します。 null以外ならログイン済になるので_UserInfoCardを表示することになります。

ログイン画面

ログイン画面の方は、入力フォームでユーザ名とパスワードを入力するテキストと、実際にログインを実行するボタンで構成します。

class _EmailPasswordForm extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _EmailPasswordFormState();
}

class _EmailPasswordFormState extends State<_EmailPasswordForm> {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Form(
        key: _formKey,
        child: Card(
          child: Padding(
            padding: const EdgeInsets.all(20),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Container(
                  alignment: Alignment.center,
                  child: const Text(
                    'ユーザー名とパスワードを入力してください',
                    style: TextStyle(fontWeight: FontWeight.bold),
                  ),
                ),
                TextFormField(
                  controller: _emailController,
                  decoration: const InputDecoration(labelText: 'Email'),
                  validator: (String? value) {
                    if (value != null && value.isEmpty) return 'テキストを入力してください';
                    return null;
                  },
                ),
                TextFormField(
                  controller: _passwordController,
                  decoration: const InputDecoration(labelText: 'Password'),
                  validator: (String? value) {
                    if (value != null && value.isEmpty) return 'テキストを入力してください';
                    return null;
                  },
                  obscureText: true,
                ),
                Container(
                  padding: const EdgeInsets.only(top: 16),
                  alignment: Alignment.center,
                  child: SignInButton(
                    Buttons.Email,
                    text: 'ログイン',
                    onPressed: () async {
                      if (_formKey.currentState!.validate()) {
                        await _signInWithEmailAndPassword();
                      }
                    },
                  ),
                ),
              ],
            ),
          ),
        ));
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

並んでいるのは普通のUIウジェットなので特に説明はありません。

Android Login

ログインボタンが押されると、_signInWithEmailAndPassword()が呼び出されます。

  Future<void> _signInWithEmailAndPassword() async {
    try {
      final User? user = (await _auth.signInWithEmailAndPassword(
        email: _emailController.text,
        password: _passwordController.text,
      ))
          .user;

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('${user?.email} でログインしました'),
        ),
      );
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('ユーザー名かパスワードが違います'),
        ),
      );
    }
  }

この部分が実際の認証処理です。

と言っても、入力された結果で、Firebase のsignInWithEmailAndPassword()を呼び出すだけです。 ネットワークの処理とかユーザの突き合わせ処理とか、全部 Firebase がやってくれますので、アプリとしては 成功するか、失敗してエラーを出したかみるだけです。

ログインに成功すると、上でみた、initState()内でlistenしているStreamUserが流れますので、 ボタンの処理の方は結果を表示するだけですみます。

Reactive になっているので、処理の単位とコードが明確に分かれるのがわかるかと思います。 その辺がきちんと分割できるので、規模が大きくなっても比較的依存関係とか気にしないでプログラムを書けるのが Flutter の利点でもあります。

ログアウト処理

ログイン済みになると表示される_UserInfoCard画面でログアウト処理をします。

class _UserInfoCard extends StatefulWidget {
  final User? user;

  const _UserInfoCard(this.user);

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

class _UserInfoCardState extends State<_UserInfoCard> {
  @override
  Widget build(BuildContext context) {
    return Column(crossAxisAlignment: CrossAxisAlignment.center, children: <Widget>[
      Text((widget.user == null)
          ? 'Not signed in'
          : '${widget.user!.isAnonymous ? '匿名ユーザ\n\n' : ''}'
              'Email: ${widget.user!.email} (verified: ${widget.user!.emailVerified})\n\n'),
      SizedBox(
        width: 150,
        child: ElevatedButton(
            child: const Text('Check Token'),
            style: ElevatedButton.styleFrom(
              primary: Colors.green,
            ),
            onPressed: () async {
              await _checkToken();
            }),
      ),
      SizedBox(height: 20),
      SizedBox(
        width: 150,
        child: ElevatedButton(
            child: const Text('Logout'),
            onPressed: () async {
              await _signOut();
            }),
      ),
    ]);
  }

ユーザの情報を表示するのと、ボタンが2つあるだけです。

Android Logout

ログアウトのボタンを押すと、_signOut()を呼び出して、その中でログアウト処理をします。

  Future<void> _signOut() async {
    await _auth.signOut();
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text('ログアウトしました'),
      ),
    );
  }

中身は、signOut()を呼び出すだけです。

ここも同じで、ログアウト後の処理はメッセージ表示だけで、実質はなにもしません。

トークンの検証

最後に JWT トークンを取得して、サーバで検証をして、ログイン情報が正しくサーバに渡るか検証します。

ログアウト画面にあるボタンから、_checkToken()が呼び出されます。

  Future<void> _checkToken() async {
    String? token = await widget.user?.getIdToken();

    if (token == null) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('トークンが取得できませんでした'),
        ),
      );
      return;
    }
    debugPrint('token: $token');

    var response = await http.get(url, headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'Authorization': 'Bearer $token',
    });
    print('Response status: ${response.statusCode}');
    // print('Response body: ${response.body}');
    if (response.statusCode == 200) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('トークンが正しく検証されました'),
        ),
      );
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('検証に失敗しました'),
        ),
      );
    }
  }

最初に、widget.user?.getIdToken()を呼び出して、JWTトークンを取得します。 ログインした状態になっていれば、ここでトークンの文字列が返ってきます。

それを、http.get()のパラメータとして渡します。 Web アプリの時と同じで、 Authorizationヘッダーに、Bearerで文字列を指定します。

サーバ側は、トークンを Firebase に問い合わせて、正しければ、HTTP response として、 statusCode200を返す仕様にしていますので、それをチェックします。 (今回の記事ではレスポンスコードだけで見ているので、こういう処理にしているだけです)。

まとめ

Webアプリの時と同じで、Firebaseをきちんとセットアップして、アプリから使える様にできれば、実際の処理は 1行コードです。

WebアプリとFlutterでモバイルアプリの全部のパターンで同じ様な処理にできるので、ほぼ全てを同じシステムでカバーできるようになっていると思います。

モバイルアプリの方でも、SNS などのソーシャルログインをサポートさせることもできます。その辺は、また時間が取れたらやってみようと思います。