【Flutter x Supabase】Supabase Authenticationを使ったユーザー登録・ログイン
これは何か
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とは
Supabaseは Firebase代替となるオープンソースのmBaaS です。PostgresをベースとしたRelationalDataBase(RDB) を強い特徴としつつ、リアルタイム更新や認証、ストレージ、サーバーレス関数などFirebaseにも引けを取らない様々な機能を提供しています。
Firebaseとの最も大きな違いはFirebaseがNoSQLベースのDBであるのに対し、RDBベースのDBである事かと思います。商業用の多くのサービスがRDBをベースとしている中、Firebase採用にはNoSQLという新しいパラダイムを学ぶコストがありましたが、Supabaseの登場で 慣れ親しんだRDBのパラダイムで簡単にインフラを構築出来るのは大きなメリットです。
日本向けにはSupabaseに所属するタイラーさんが積極的に日本語での情報発信をしている事も日本ユーザーにとっては嬉しいポイントです。
準備
0. Supabaseプロジェクトを作成
こちらは既に作成している前提で進めていきます。まだの方は以下の記事を参考に作成してみてください。
1. パッケージをインストール
flutter pub add supabase_flutter
2. Supabaseを初期化
main
メソッド内で、
-
WidgetsFlutterBinding.ensureInitialized();
でFlutterが初期化されている事を確認 -
Supabase.initilize()
にて連携するSupabaseプロジェクトのURLとAnon Keyを渡す
URLとAnon key はプロジェクトごとに提供されます。
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()
にemail
とpassword
を渡すだけです。
メタデータを持たせたい場合は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()
にemail
、password
を渡すだけです。
戻り値として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
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
を通して検知する事が出来ます。onAuthStateChange
は AuthChangeEvent
クラスと 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) {
...
}
}
サンプル
以下にサンプルプロジェクトを用意したので、動くコードを見たい方は覗いてみてください。
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 Fucntion
とDatabase 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を定義
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を定義
create trigger on_auth_user_created
after insert on auth.users
for each row
execute procedure handle_new_user();
参考
Discussion