🐕

【Flutter x Supabase】Supabase Authenticationを使ったユーザー登録・ログイン

2023/06/20に公開

これは何か

FlutterとSupabaseを使った認証機能についての簡単な整理です。最もシンプルなメールアドレス+パスワード認証についてのみ取り扱っています。

話さないこと

  • UIの構築方法
  • Supabaseプロジェクトの作成方法

環境

バージョン
Flutter 3.10.2
Dart 3.0.2
Xcode 14.3
Android Studio Flamingo (2022.2)
supabase_flutter 1.10.4

Supabaseとは

https://supabase.com/
Supabaseは Firebase代替となるオープンソースのmBaaS です。PostgresをベースとしたRelationalDataBase(RDB) を強い特徴としつつ、リアルタイム更新や認証、ストレージ、サーバーレス関数などFirebaseにも引けを取らない様々な機能を提供しています。

Firebaseとの最も大きな違いはFirebaseがNoSQLベースのDBであるのに対し、RDBベースのDBである事かと思います。商業用の多くのサービスがRDBをベースとしている中、Firebase採用にはNoSQLという新しいパラダイムを学ぶコストがありましたが、Supabaseの登場で 慣れ親しんだRDBのパラダイムで簡単にインフラを構築出来るのは大きなメリットです。

日本向けにはSupabaseに所属するタイラーさんが積極的に日本語での情報発信をしている事も日本ユーザーにとっては嬉しいポイントです。

準備

0. Supabaseプロジェクトを作成

こちらは既に作成している前提で進めていきます。まだの方は以下の記事を参考に作成してみてください。

https://zenn.dev/dshukertjr/books/flutter-supabase-chat/viewer/page2

1. パッケージをインストール

https://pub.dev/packages/supabase_flutter

flutter pub add supabase_flutter

2. Supabaseを初期化

mainメソッド内で、

  1. WidgetsFlutterBinding.ensureInitialized(); でFlutterが初期化されている事を確認
  2. Supabase.initilize() にて連携するSupabaseプロジェクトのURLAnon Keyを渡す

URLAnon key はプロジェクトごとに提供されます。

main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized(); // Flutterの初期化を確認
  await Supabase.initialize(
    url: 'https://<your_project_id>.supabase.co',// プロジェクトURL
    anonKey: '<your_anon_key>', // プロジェクトAnon key
  );
  runApp(const MyApp());
}

3. Supabaseクライアントを取得

SupabaseクライアントはSupabase.instance.clientで取得可能です。このクライアントを通して全ての機能を使います。

final supabase = Supabase.instance.client;

使い方

ユーザー登録

もっともシンプルなemailを使ったサインアップは Supabase.instance.client.auth.signUp()emailpasswordを渡すだけです。

メタデータを持たせたい場合はdataフィールドMap<String, dynamic>を渡すことで登録することができます。


  Future<void> _signUp({
    required String email,
    required String userName,
    required String password,
  }) async {
    try {
      await supabase.auth.signUp(
        email: email,
        password: password,
        data: {'user_name': userName}, // メタデータを登録する場合、dataフィールドに渡す
      );
    } on AuthException catch (error) {
      ...
    } on Exception catch (error) {
      ...
    }
  }

上記メソッドを実行するとSupabaseのAuthenticationに以下の通りユーザーが追加されているかと思います。

ログイン

Eメールログインは Supabase.instance.client.auth.signInWithPassword()emailpasswordを渡すだけです。

戻り値としてUserクラスとSessionクラスを含むAuthResponseクラスが返却されます。

  Future<void> _loginWithPassword({
    required String email,
    required String password,
  }) async {

    try {
      final response = await Supabase.instance.client.auth.signInWithPassword(
        email: email,
        password: password,
      );
      if (response.user != null) {
        ...
      }
    } on AuthException catch (error) {
      ...
    } on Exception catch (e) {
      ...
    } 
  }
AuthResponse
AuthResponse
class AuthResponse {
  final Session? session;
  final User? user;

  AuthResponse({
    this.session,
    User? user,
  }) : user = user ?? session?.user;

  /// Instanciates an `AuthResponse` object from json response.
  AuthResponse.fromJson(Map<String, dynamic> json)
      : session = Session.fromJson(json),
        user = User.fromJson(json) ?? Session.fromJson(json)?.user;
}

ログインユーザーを取得

ログイン中のユーザー情報はSupabase.instance.client.auth.currentUserで取得する事が出来ます。ログインしていない場合、戻り値はnullになります。

final User? user = Supabase.instance.client.auth.currentUser;

ログイン状態を監視して取得

またログイン状態を監視した上で取得することも可能です。

ログイン状態の変更は Supabase.instance.client.auth.onAuthStateChange を通して検知する事が出来ます。onAuthStateChangeAuthChangeEvent クラスと Session クラスを含む AuthState クラスのストリームを返してくれるのですが、このSessionクラスがUserクラスを保持しています。要は onAuthStateChange > AuthState > Session > User の流れで取得することになります。

  listenAuthStateChange() {
     // AuthStateのストリームを監視
     Supabase.instance.client.auth.onAuthStateChange.listen((AuthState state) {
      if (state.event == AuthChangeEvent.signedIn) {
        // ログイン時の処理
        final userData = state.session!.user; // Sessionクラスを通して、Userを取得
        ...
      } else if (state.event == AuthChangeEvent.signedOut) {
        // ログアウト時の処理
        ...
      }
    });
  }
AuthState
class AuthState {
  final AuthChangeEvent event;
  final Session? session;

  AuthState(this.event, this.session);
}
AuthChangeEvent
enum AuthChangeEvent {
  passwordRecovery,
  signedIn,
  signedOut,
  tokenRefreshed,
  userUpdated,
  userDeleted,
  mfaChallengeVerified,
}
Session
class Session {
  final String? providerToken;
  final String? providerRefreshToken;
  final String accessToken;

  /// The number of seconds until the token expires (since it was issued).
  /// Returned when a login is confirmed.
  final int? expiresIn;

  final String? refreshToken;
  final String tokenType;
  final User user;

  const Session({
    required this.accessToken,
    this.expiresIn,
    this.refreshToken,
    required this.tokenType,
    this.providerToken,
    this.providerRefreshToken,
    required this.user,
  });
 ...
}

ユーザー情報の取得

ユーザー情報は上記で取得したUserクラスから取得しましょう。メタデータもuserMetadataフィールドを通して取得できます。

final userID = user.id; 
final email = user.email;
final userName = user.userMetadata!['username']; // userMetadata[<キー名>]でメタデータ内の値を取得

上記以外の情報も取得できるので気になる方は以下のトグルから確認ください

User
class User {
  final String id;
  final Map<String, dynamic> appMetadata;
  final Map<String, dynamic>? userMetadata;
  final String aud;
  final String? confirmationSentAt;
  final String? recoverySentAt;
  final String? emailChangeSentAt;
  final String? newEmail;
  final String? invitedAt;
  final String? actionLink;
  final String? email;
  final String? phone;
  final String createdAt;
  ('Use emailConfirmedAt instead')
  final String? confirmedAt;
  final String? emailConfirmedAt;
  final String? phoneConfirmedAt;
  final String? lastSignInAt;
  final String? role;
  final String? updatedAt;
  final List<UserIdentity>? identities;
  final List<Factor>? factors;

  const User({
    required this.id,
    required this.appMetadata,
    required this.userMetadata,
    required this.aud,
    this.confirmationSentAt,
    this.recoverySentAt,
    this.emailChangeSentAt,
    this.newEmail,
    this.invitedAt,
    this.actionLink,
    this.email,
    this.phone,
    required this.createdAt,
    ('Use emailConfirmedAt instead') this.confirmedAt,
    this.emailConfirmedAt,
    this.phoneConfirmedAt,
    this.lastSignInAt,
    this.role,
    this.updatedAt,
    this.identities,
    this.factors,
  });

  /// Returns a `User` object from a map of json
  /// returns `null` if there is no `id` present
  static User? fromJson(Map<String, dynamic> json) {
    if (json['id'] == null) {
      return null;
    }
    return User(
      id: json['id'] as String,
      appMetadata: json['app_metadata'] as Map<String, dynamic>,
      userMetadata: json['user_metadata'] as Map<String, dynamic>?,
      aud: json['aud'] as String,
      confirmationSentAt: json['confirmation_sent_at'] as String?,
      recoverySentAt: json['recovery_sent_at'] as String?,
      emailChangeSentAt: json['email_change_sent_at'] as String?,
      newEmail: json['new_email'] as String?,
      invitedAt: json['invited_at'] as String?,
      actionLink: json['action_link'] as String?,
      email: json['email'] as String?,
      phone: json['phone'] as String?,
      createdAt: json['created_at'] as String,
      // ignore: deprecated_member_use_from_same_package
      confirmedAt: json['confirmed_at'] as String?,
      emailConfirmedAt: json['email_confirmed_at'] as String?,
      phoneConfirmedAt: json['phone_confirmed_at'] as String?,
      lastSignInAt: json['last_sign_in_at'] as String?,
      role: json['role'] as String?,
      updatedAt: json['updated_at'] as String?,
      identities:
          (json['identities'] as List?)?.cast<Map<String, dynamic>>().map((e) {
        return UserIdentity.fromMap(e);
      }).toList(),
      factors:
          (json['factors'] as List?)?.cast<Map<String, dynamic>>().map((e) {
        return Factor.fromJson(e);
      }).toList(),
    );
  }

  Map<String, dynamic> toJson() => {
        'id': id,
        'app_metadata': appMetadata,
        'user_metadata': userMetadata,
        'aud': aud,
        'email': email,
        'phone': phone,
        'created_at': createdAt,
        // ignore: deprecated_member_use_from_same_package
        'confirmed_at': confirmedAt,
        'email_confirmed_at': emailConfirmedAt,
        'phone_confirmed_at': phoneConfirmedAt,
        'last_sign_in_at': lastSignInAt,
        'role': role,
        'updated_at': updatedAt,
      };
}

ログアウト

ログアウトは Supabase.instance.client.auth.signOut() を実行するだけです。

  Future<void> _logout() async {
    try {
      await Supabase.instance.client.auth.signOut();
    } on AuthException catch (error) {
      ...
    } on Exception catch (error) {
      ...
    }
  }

サンプル

以下にサンプルプロジェクトを用意したので、動くコードを見たい方は覗いてみてください。


https://github.com/heyhey1028/flutter_supabase_auth

lib
├── main.dart
├── pages
│   ├── login_page.dart
│   ├── my_home_page.dart
│   └── sign_up_page.dart
└── utils
    └── utils.dart
main.dart

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'pages/my_home_page.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Supabase.initialize(
    url: 'https://<your_project_id>.supabase.co',
    anonKey: '<your_anon_key>',
  );
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Supabase Auth Demo'),
    );
  }
}

my_home_page.dart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import '../utils/utils.dart';
import 'login_page.dart';

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String userID = '';
  String userName = '';
  bool isLoggedIn = false;
  bool isLoading = false;

  
  void initState() {
    Supabase.instance.client.auth.onAuthStateChange.listen((state) {
      if (state.event == AuthChangeEvent.signedIn) {
        final userData = Supabase.instance.client.auth.currentUser!;

        setState(() {
          isLoggedIn = true;
          userID = userData.id;
          userName = userData.userMetadata!['username'];
        });
      } else if (state.event == AuthChangeEvent.signedOut) {
        setState(() {
          isLoggedIn = false;
          userID = '';
          userName = '';
        });
      }
    });
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: isLoading
            ? const CircularProgressIndicator()
            : Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Text(
                    'userID:$userID',
                  ),
                  Text(
                    'user name:$userName',
                    style: Theme.of(context).textTheme.headlineMedium,
                  ),
                ],
              ),
      ),
      floatingActionButton: isLoggedIn
          ? FloatingActionButton.extended(
              onPressed: () => _logout(),
              label: const Text('Logout'),
            )
          : FloatingActionButton.extended(
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) => const LoginPage(),
                  ),
                );
              },
              label: const Text('Login'),
            ),
    );
  }

  Future<void> _logout() async {
    setState(() {
      isLoading = true;
    });
    try {
      await Supabase.instance.client.auth.signOut();
    } on AuthException catch (error) {
      showErrorSnackBar(context, message: error.message);
    } on Exception catch (error) {
      showErrorSnackBar(context, message: error.toString());
    } finally {
      setState(() {
        isLoading = false;
      });
    }
  }
}

login_page.dart
// ignore_for_file: use_build_context_synchronously

import 'package:flutter/material.dart';
import 'package:flutter_social_button/flutter_social_button.dart';
import 'package:flutter_supabase_auth/utils/utils.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'sign_up_page.dart';

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  bool isLoading = false;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Login'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          key: _formKey,
          autovalidateMode: AutovalidateMode.onUserInteraction,
          child: Column(
            children: <Widget>[
              TextFormField(
                controller: _emailController,
                keyboardType: TextInputType.emailAddress,
                decoration: const InputDecoration(
                  labelText: 'Email',
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your email';
                  }
                  return null;
                },
              ),
              TextFormField(
                controller: _passwordController,
                decoration: const InputDecoration(
                  labelText: 'Password',
                ),
                obscureText: true,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your password';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 24.0),
              ElevatedButton(
                child: isLoading ? const CircularProgressIndicator() : const Text('Login'),
                onPressed: () {
                  if (_formKey.currentState!.validate()) {
                    _loginWithPassword(
                      email: _emailController.text,
                      password: _passwordController.text,
                    );
                  }
                },
              ),
              TextButton(
                child: const Text('Go to Signup'),
                onPressed: () {
                  Navigator.pushReplacement(
                    context,
                    MaterialPageRoute(
                      builder: (context) => const SignUpPage(),
                    ),
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }

  Future<void> _loginWithPassword({
    required String email,
    required String password,
  }) async {
    setState(() {
      isLoading = true;
    });
    try {
      final response = await Supabase.instance.client.auth.signInWithPassword(email: email, password: password);
      if (response.user != null) {
        Navigator.of(context).pop();
      }
    } on AuthException catch (error) {
      showErrorSnackBar(context, message: error.message);
    } on Exception catch (e) {
      showErrorSnackBar(context, message: e.toString());
    } finally {
      setState(() {
        isLoading = false;
      });
    }
  }
}

sign_up_page.dart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../utils/utils.dart';
import 'login_page.dart';

class SignUpPage extends StatefulWidget {
  const SignUpPage({super.key});

  
  State<SignUpPage> createState() => _SignUpPageState();
}

class _SignUpPageState extends State<SignUpPage> {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _userNameController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  final TextEditingController _confirmPasswordController = TextEditingController();
  bool isLoading = false;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Signup'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          key: _formKey,
          autovalidateMode: AutovalidateMode.onUserInteraction,
          child: Column(
            children: <Widget>[
              TextFormField(
                controller: _emailController,
                keyboardType: TextInputType.emailAddress,
                decoration: const InputDecoration(
                  labelText: 'Email',
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your email';
                  }
                  return null;
                },
              ),
              TextFormField(
                controller: _userNameController,
                keyboardType: TextInputType.name,
                decoration: const InputDecoration(
                  labelText: 'User Name',
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your user name';
                  }
                  return null;
                },
              ),
              TextFormField(
                controller: _passwordController,
                decoration: const InputDecoration(
                  labelText: 'Password',
                ),
                obscureText: true,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your password';
                  }
                  return null;
                },
              ),
              TextFormField(
                controller: _confirmPasswordController,
                decoration: const InputDecoration(
                  labelText: 'Confirm Password',
                ),
                obscureText: true,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please confirm your password';
                  }
                  if (value != _passwordController.text) {
                    return 'Passwords do not match';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 24.0), // Spacer(

              ElevatedButton(
                child: isLoading ? const CircularProgressIndicator() : const Text('Signup'),
                onPressed: () async {
                  if (isLoading) return;
                  if (_formKey.currentState!.validate()) {
                    await _signUp(context);
                  }
                },
              ),
              TextButton(
                child: const Text('Go to Login'),
                onPressed: () {
                  Navigator.pushReplacement(
                    context,
                    MaterialPageRoute(builder: (context) => const LoginPage()),
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }

  Future<void> _signUp(BuildContext context) async {
    setState(() {
      isLoading = true;
    });
    final email = _emailController.text;
    final userName = _userNameController.text;
    final password = _passwordController.text;
    try {
      await Supabase.instance.client.auth.signUp(
        email: email,
        password: password,
        data: {'username': userName},
      );
      if (!mounted) return;
      Navigator.of(context).pop();
    } on AuthException catch (error) {
      showErrorSnackBar(context, message: error.message);
    } on Exception catch (error) {
      showErrorSnackBar(context, message: error.toString());
    } finally {
      setState(() {
        isLoading = false;
      });
    }
  }
}


utils.dart
import 'package:flutter/material.dart';

/// エラーが起きた際のSnackbarを表示
void showErrorSnackBar(BuildContext context, {required String message}) {
  ScaffoldMessenger.of(context).showSnackBar(SnackBar(
    content: Text(message),
    backgroundColor: Colors.red,
  ));
}

おまけ: サインアップと同時にユーザーテーブルに登録する (Database Triggers)

ユーザーが新規登録した際に、Authenticationだけでなく、データベースにあるテーブルにもユーザーを登録したいケースがあると思います。そんなケースでは Database Functions (Postgres Function)Database Triggers (Postgres Triggers) を使って、書き込みに応じて別のテーブル操作を実行する事が出来ます。

こちらの記事の主題からズレてしまうので詳しくは解説しませんが,
Database FucntionDatabase TriggersをSupabaseプロジェクトの SQL Editor に定義、実行する事で実現する事が出来ます。
記述はPL/pgSQLというSQLを手続き型言語として拡張したプログラミング言語を用います。

以下では例として、Authenticationへのユーザー登録をトリガーにして、ProfileテーブルにユーザーのIDとuser_nameというメタデータをコピーするDatabase Functionを実行しています。

詳しくは章末の参考記事を是非読んでみてください

1. Profileテーブルを作成

create table if not exists public.profiles (
    id uuid references auth.users on delete cascade not null primary key,
    username varchar(24) not null unique,
    created_at timestamp with time zone default timezone('utc' :: text, now()) not null,
);
comment on table public.profiles is 'ユーザー名などのユーザー情報を保持する';

2. Database Functionを定義

handle_new_user
create or replace function public.handle_new_user() returns trigger as $$
begin
    insert into public.profiles(id, username)
    values(new.id, new.raw_user_meta_data->>'user_name');
    return new;
end;
$$ language plpgsql security definer;

3. Database Triggersを定義

onauth_user_created
create trigger on_auth_user_created
after insert on auth.users
for each row
execute procedure handle_new_user();

参考

https://zenn.dev/dshukertjr/books/flutter-supabase-chat/viewer/page4

https://qiita.com/ester41/items/3401606322888a0a7f21

Flutter大学

Discussion