はじめに
過去に以下の記事でログイン機能を実装する内容を投稿しました。
基本的な機能としてQiitaAPIのOAuthでサービスにログインし、ログインが成功したら専用の画面に遷移するというものです。
あれから現場で学んだ知識を活かしてより現実的なコードで機能を実装したいと思い、
アップデートした内容を紹介したいと思います。
今回ご紹介する内容と以前書いた記事の内容との相違点は主に以下の通りです。
- レイヤードアーキテクチャにそって記述
- 認証をflutter_appauthパッケージで行う方針に変更
- go_routerのリダイレクト機能を使ってログイン状態による遷移の管理
- ログイン後に遷移する画面はNavigationBarを持つ画面構成
記事の対象者
- ログイン機能を実装したい方
- go_routerを使ってログイン状態によるルーティングを管理したい方
- レイヤードアーキテクチャで書いてみたい方
- riverpodの自動生成方法を理解している方
- go_router_builderの自動生成方法を理解している方
記事を執筆時点での筆者の環境
[✓] Flutter (Channel stable, 3.22.1, on macOS 14.5 23F79 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.92.0)
サンプルプロジェクト
- ログインボタンを押して認証ページへ遷移
- 任意の方法でログインして認可コードを取得する(メール、Googleアカウント、GitHubアカウントなど)
- 許可するボタンをタップしてアクセストークンを取得する
- ログインすると初期画面である一覧画面に遷移する
- 設定画面にあるログアウトボタンを押すと、アクセストークンを破棄してログイン画面に戻る
※1. GIFでは2はすでに行なっているためスキップされています。
※2. GIFでは出していませんがログインした状態でアプリを終了して、再度アプリを起動した場合はログイン状態を保持する機能を有しています。
ソースコード
記事内では適時コードを抜粋して載せていますが、詳細な内容はソースコードでご確認ください。
なお、今回のソースコード上にはQiitaAPIに接続するためのクライアントIDやシークレットIDは非公開になっています。
よって仮にこのリポジトリをクローンしてもそのままでは動作しませんのでご了承ください。
こちらはブランチがfeature/log_in
であることに注意してください。
現在はmainと同じ内容ですが、今後機能を増やした場合に記事の内容によってブランチを変えていく予定です。
QiitaのAPIの設定
もしもソースコードを使ってQiitaAPIを使ったログイン機能を動作させたい場合にはAPIの設定が必要です。
以下で解説していますのでこちらをご覧ください。
なお、リンクがうまく貼れてないです🫣
ジャンプ先の記事で2-2. QiitaAPIの設定までスクロールをお願いします🙏
リンク先の少し下の方になります。
1. アーキテクチャ、状態管理、設定まわり
簡単にではありますが、今回のプロジェクトにおけるアーキテクチャ、状態管理、設定についてお話ししていきます。
1-1. アーキテクチャ
今回はレイヤードアーキテクチャに沿ってコードを記述しています。
参考にしたのは以下の記事です。
主にトップのディレクトリ構成は以下の4+1の5つの構成です。
基本の4つ
- presentation層
- application層
- domain層
- data層
+1
- core
presentation層
主にUIに関わるファイルを置きます。
screenやwidgetなどのUIコンポーネントや大元のappもここに配置しています。
画面はscreenという命名としますが、なるべくロジックは分離したいので各screenにはview_modelを置いています。
view_modelから必要な機能がある場合はapplication層から呼び出しますが、場合によっては直接data層を触っても良いことにしています。(今回該当する処理はありません。)
presentations
├── app_start_up
│ ├── app_start_up_screen.dart
│ ├── provider.dart
│ └── provider.g.dart
├── list
│ └── list_screen.dart
├── login
│ ├── login_screen.dart
│ ├── login_view_model.dart
│ └── login_view_model.g.dart
├── settings
│ ├── settings_screen.dart
│ ├── settings_view_model.dart
│ └── settings_view_model.g.dart
└── app.dart
application層
主にユーザー行動に基づく機能を提供するファイルを置いています。
今回は認証関連の機能しかないため、auth_service
のみが置かれています。
様々なrepositoryの機能をここで束ねて一つの意味のある機能として提供しているイメージです。
applications
└── auth_service
├── provider.dart
├── provider.g.dart
└── service.dart
domain層
このアプリで使うデータの形を定義した俗にいうモデルを置く場所です。
一般的にはユーザー情報や購入情報、メモの情報などがあります。
今回はdomainに置くものがないためファイルは配置されていますが未使用です。
/// 例えばこういったクラスを定義する
class User {
const User({
required this.id,
required this.name,
required this.eMail,
});
final String id;
final String name;
final String eMail;
}
data層
何かしらのデータにアクセスするための機能を提供します。infrastructure層と命名しても良いでしょう。
ローカルデータにアクセスする、httpクライアントを使ってAPIに接続するなどがあります。
主に根幹となる機能がここに詰まっていて、それをapplication層で呼び出したり、場合によってはpresentation層で直接呼び出したりします。
基本的には大元の機能はパッケージを使用しているため、それぞれのパッケージの差し替えを行いやすいようにインスタンスはriverpodを経由しています。
各種のパッケージのインスタンスはlocal_sources
またはremote_sources
で管理します。
そしてそれらを使ってデータにアクセスする機能がrepositories
に収納されています。
data
├── local_sources
│ ├── secure_storage.dart
│ ├── secure_storage.g.dart
│ ├── shared_preference.dart
│ └── shared_preference.g.dart
├── remote_sources
│ ├── app_auth.dart
│ ├── app_auth.g.dart
│ ├── http.dart
│ └── http.g.dart
└── repositories
├── key_value_repository
│ ├── provider.dart
│ ├── provider.g.dart
│ └── repository.dart
├── secure_storage_repository
│ ├── provider.dart
│ ├── provider.g.dart
│ └── repository.dart
└── web_auth_repository
├── provider.dart
├── provider.g.dart
└── repository.dart
core
coreは上記に該当しない機能を置いています。
util
とかにしているプロジェクトもありそうですね。
なるべく上記4つの層に入れ、それ以外でどうしても分類できないものを入れています。
今回は定数関連、ログ関連、ルーティング関連、envファイルを入れています。
場合によってはここに便利メソッドなどもくるかもしれません。
core
├── constants
│ └── constants.dart
├── log
│ └── logger.dart
├── router
│ ├── app_navigation_bar.dart
│ ├── app_router.dart
│ ├── app_router.g.dart
│ ├── app_state_change_notifier.dart
│ ├── route.dart
│ └── route.g.dart
├── env.dart
└── env.g.dart
1-2. 状態管理
状態管理とDIにおいてはriverpodを使って行っています。
また、一部を除き基本的にはbuild_runnerを用いた自動生成コードを使っています。
1-3. 設定まわり
今回はhttps通信を行う機能をdio
で、認証関連でflutter_appauth
というパッケージを使用しています。
それぞれのパッケージを使う上でiOSとAndroidで必要な設定があります。
インターネット使用の許可
まず2つのパッケージに共通の設定がインターネットを使用を許可する設定です。
こちらはAndroidのみ設定が必要で、AndroidManifest
に追記が必要です。
<uses-permission android:name="android.permission.INTERNET"/>
flutter_appauthの設定
こちらはflutter_appauth
のREADME通りに書いていきます。
Androidの設定
<!-- fllutter_appauthの設定 -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="replace">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="qiita-reader" />
</intent-filter>
</activity>
<!-- fllutter_appauthの設定 -->
<data android:scheme="qiita-reader" />
の部分が各自で設定したコールバックの名称になります。
iOSの設定
<!-- fllutter_appauthの設定 -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>qiita-reader</string>
</array>
</dict>
</array>
<!-- fllutter_appauthの設定 -->
<string>qiita-reader</string>
の部分が各自で設定したコールバックの名称になります。
2. ルーティングの処理をかく
画面の基本的な構成としてログイン画面があり、ログイン後は2タブ構成の画面(以後、ログイン後画面と記載)があります。
ログイン後画面は1タブそれぞれに1画面ずつ置いてあります。
これをriverpod
とgo_router
とgo_router_builder
を使って管理します。
go_router
は画面遷移を管理するパッケージですが、それに合わせてgo_router_builder
を使うと今回のようなNavigationBar
を用いたShellRoute
を簡単に、かつ型安全に取り扱うことができるようになります。
今回の記事では主にgo_router
のリダイレクト機能に重きを置いて解説するため、NavigationBar
の定義やルートの記述方法などの解説は割愛して記載します。
以下にgo_router
とgo_router_builder
の使い方で参考にさせて頂いた記事を掲載しておきます。
2-1. 各画面を作成
ログイン画面 | 一覧画面 | 設定画面 |
---|---|---|
![]() |
![]() |
![]() |
各画面を作成します。
コードは以下に記載しています。
ログイン画面
class LoginScreen extends ConsumerWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewModel = ref.watch(loginViewModelProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('ログイン画面')),
body: Center(
child: ElevatedButton(
onPressed: viewModel.startLogin,
child: const Text('ログイン'),
),
),
);
}
}
一覧画面
class ListScreen extends StatelessWidget {
const ListScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('一覧'),
),
);
}
}
設定画面
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewModel = ref.watch(settingsViewModelProvider.notifier);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('設定'),
const Gap(50),
ElevatedButton(
onPressed: viewModel.startLogout,
child: const Text('ログアウト'),
),
],
),
),
);
}
}
2-2. ログイン画面とログイン後画面でルートを分ける
以下にgo_router_builderを使う際のルート設定を一部記載しています。
TypedShellRouteの配下にログイン後画面とログイン画面を並列に配置します。
ログイン後画面はNavigationBarを使った構成のため、TypedStatefulShellRoute配下に一覧画面や設定画面を配置しています。
一方、ログイン画面は先ほど述べたように同じ階層、並列で配置してTypedShellRouteの直下に配置します。
こうすることでgo_routerの制御さえしっかりと行えばログインしていないのにログイン後画面に遷移することがなくなります。
// 大元のルート
@TypedShellRoute<AppShellRoute>(
routes: [
// ログイン後の画面 ここから ---->
TypedStatefulShellRoute<NavigationShellRoute>(
branches: [
TypedStatefulShellBranch<ListBranch>(
routes: [
TypedGoRoute<ListRoute>(
path: '/',
name: 'list_screen',
),
],
),
TypedStatefulShellBranch<SettingsBranch>(
routes: [
TypedGoRoute<SettingsRoute>(
path: '/settings',
name: 'settings_screen',
),
],
),
],
),
// ログイン後の画面 ここまで ---->
// ログイン前の画面
TypedGoRoute<LoginRoute>(
path: '/login',
name: 'login_screen',
),
],
)
ルートの全体コードはこちら
/// アプリケーション全体のナビゲーションを管理するためのキー。
/// このキーを使うことで、アプリケーションのどこからでも
/// ナビゲーターに直接アクセスし、画面遷移を制御することができる。
final rootNavigationKey = GlobalKey<NavigatorState>();
// 大元のルート
@TypedShellRoute<AppShellRoute>(
routes: [
// ログイン後の画面 ここから ---->
TypedStatefulShellRoute<NavigationShellRoute>(
branches: [
TypedStatefulShellBranch<ListBranch>(
routes: [
TypedGoRoute<ListRoute>(
path: '/',
name: 'list_screen',
),
],
),
TypedStatefulShellBranch<SettingsBranch>(
routes: [
TypedGoRoute<SettingsRoute>(
path: '/settings',
name: 'settings_screen',
),
],
),
],
),
// ログイン後の画面 ここまで ---->
// ログイン画面
TypedGoRoute<LoginRoute>(
path: '/login',
name: 'login_screen',
),
],
)
/// アプリの大元のルート
class AppShellRoute extends ShellRouteData {
const AppShellRoute();
static final $navigationKey = rootNavigationKey;
@override
Widget builder(
BuildContext context,
GoRouterState state,
Widget navigator,
) {
return Scaffold(
body: navigator,
);
}
}
// Branchはタブのルートの入れ物
class ListBranch extends StatefulShellBranchData {
const ListBranch();
}
class SettingsBranch extends StatefulShellBranchData {
const SettingsBranch();
}
/// タブのナビゲーターを設定
class NavigationShellRoute extends StatefulShellRouteData {
const NavigationShellRoute();
@override
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: AppNavigationBar(
navigationShell: navigationShell,
),
);
}
}
// それぞれの画面を設定
class ListRoute extends GoRouteData {
const ListRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const ListScreen();
}
}
class SettingsRoute extends GoRouteData {
const SettingsRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const SettingsScreen();
}
}
class LoginRoute extends GoRouteData {
const LoginRoute();
@override
Widget build(BuildContext context, GoRouterState state) {
return const LoginScreen();
}
}
bottomNavigationBarに設定しているAppNavigationBarはこちら
/// アプリのナビゲーションバーを設定する
class AppNavigationBar extends StatelessWidget {
const AppNavigationBar({
required this.navigationShell,
super.key,
});
/// StatefulShellRouteの状態を管理するウィジェット
///
/// go_routerの機能
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return NavigationBar(
// StatefulNavigationShellが保持している現在のインデックスを割り当てる
selectedIndex: navigationShell.currentIndex,
destinations: const [
NavigationDestination(
icon: Icon(Icons.list_alt),
label: '一覧',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: '設定',
),
],
onDestinationSelected: _select,
backgroundColor: const Color.fromARGB(255, 90, 188, 64),
);
}
/// タブをタップした際の処理
///
/// 引数のインデックスに該当するブランチに移動し、
void _select(int index) {
// ナビゲーションシェルのページを切り替える
navigationShell.goBranch(
// 移動するブランチのインデックス
index,
// ブランチのルートページに戻すかどうか
initialLocation: index == navigationShell.currentIndex,
);
}
}
2-3. 初期画面をログイン画面にする
GoRouterのインスタンスを今回はriverpodで管理しています
ここでアプリを起動した際の初期値をログイン画面に設定します。
@Riverpod(keepAlive: true)
GoRouter appRouter(AppRouterRef ref) {
return GoRouter(
// アプリケーション全体のナビゲーションを管理するためのキーを設定
navigatorKey: rootNavigationKey,
// アプリ起動時に最初に表示する画面のルートを指定
initialLocation: const LoginRoute().location, // 👈 ここで初期化画面設定
// 使用するルート一覧を指定(go_router_builderで自動生成されたルート)
routes: $appRoutes,
// ルートのリフレッシュを監視するためのリスナーを設定
refreshListenable: ref.read(appStateChangeNotifierProvider),
// 特定の条件に基づいてリダイレクトを行うロジックを指定
redirect: ref.read(appStateChangeNotifierProvider).redirect,
);
}
refreshListenable
とredirect
を使ってログイン状態の有無によって遷移を切り替える処理の解説は4. ログイン状態によって画面を遷移させる
3. 認証関連の機能の実装
まずは認証関連の機能を考えます。
今回の場合、以下の4つの機能が必要です。
- ログインする機能
- ログアウトする機能
- ログインの有無を知らせる機能
- 認証関連機能の初期設定
この機能を提供するクラスをapplication層のServiceクラスとして定義します。
abstract interface class AuthServiceBase {
/// 初期化
Future<void> init();
/// ログイン処理
Future<void> login();
/// ログアウトの処理
Future<void> logout();
/// ログイン状態の変更を通知するストリーム
Stream<bool> get isLoggedInStream;
}
ログインとログアウトに必要な認証認可のAPI、およびアクセストークンの取得と削除のAPIは以下を確認しながら作成しました。
3-1. ログインの処理
ログイン機能には以下の順番で処理を行う必要があります。
- QiitaAPIの認証APIを使用して認可コードを取得する
- QiitaAPIで認可コードを使用してアクセストークンを取得する
- 取得したアクセストークンをセキュアストレージに保存する
- ログインしたことがあることを記録する
- ログインしたことをイベントとして配信する
上記をまとめると以下の内容になります。
class AuthService implements AuthServiceBase {
AuthService(this.ref);
final ProviderRef<dynamic> ref;
WebAuthRepositoryBase get _webAuth => ref.read(webAuthRepositoryProvider);
KeyValueRepositoryBase get _keyValueStore =>
ref.read(keyValueRepositoryProvider);
SecureStorageRepositoryBase get _secureStorage =>
ref.read(secureStorageRepositoryProvider);
Dio get _httpClient => ref.read(httpProvider);
final StreamController<bool> _authStateChanges = StreamController<bool>();
// 省略
@override
Future<void> login() async {
try {
// webAuthで認可コードを取得
final code = await _webAuth.fetchAuthorizationCode();
if (code == null) {
throw Exception('認可コードがnullです');
}
// 認可コードでアクセストークンを取得する
final accessToken = await _fetchAccessToken(code);
if (accessToken == null) {
throw Exception('アクセストークンががnullです');
}
// アクセストークンをセキュアストレージに保存する
await _secureStorage.setAccessToken(accessToken);
final isFirstLogin = await _keyValueStore.getIsFirstLogin();
if (isFirstLogin == null || isFirstLogin == true) {
// 今回が初めてのログインだった場合はフラグをfalseで保存する
await _keyValueStore.setIsFirstLogin(value: false);
}
_authStateChanges.sink.add(true);
} catch (e, s) {
logger.e('エラーです', error: e, stackTrace: s);
}
}
/// 認可コードを使ってアクセストークンを取得する
Future<String?> _fetchAccessToken(String code) async {
// もらった認可コードでアクセストークンを取得する
// https://qiita.com/api/v2/docs#%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3-1
final data = {
'client_id': Env.clientId,
'client_secret': Env.clientSecret,
'code': code,
};
try {
final response = await _httpClient.post<Map<String, dynamic>>(
Constants.tokenEndPoint,
data: data,
);
if (response.statusCode == 200 || response.statusCode == 201) {
final accessToken = response.data?['token'].toString();
return accessToken;
} else {
logger.e('ステータスコード:${response.statusCode}');
return null;
}
} catch (e, s) {
logger.e('不明なエラー', error: e, stackTrace: s);
rethrow;
}
}
1. QiitaAPIの認証APIを使用して認可コードを取得する
// webAuthで認可コードを取得
final code = await _webAuth.fetchAuthorizationCode();
この処理はflutter_appauth
というパッケージを使用して実装しています。
認証に関わるデータの受け渡しやリダイレクト処理を簡潔に書くことができるパッケージです。
上記の処理はrepositories
のWebAuthRepository
に定義しています。
class WebAuthRepository implements WebAuthRepositoryBase {
WebAuthRepository(this.ref);
final ProviderRef<dynamic> ref;
@override
Future<String?> fetchAuthorizationCode() async {
try {
final webAuth = ref.read(appAuthProvider);
final result = await webAuth.authorize(
AuthorizationRequest(
Env.clientId,
Constants.redirectUrl,
serviceConfiguration: Constants.serviceConfiguration,
scopes: Constants.scope,
),
);
if (result == null || result.authorizationCode == null) {
logger.e('認可コードが取得できませんした', stackTrace: StackTrace.current);
return null;
}
// 取り出したコードを返却する
return result.authorizationCode;
} catch (e, s) {
logger.e('エラーが発生しました', error: e, stackTrace: s);
return null;
}
}
}
authorize
メソッドを使って認可コード要求します。
引数のAuthorizationRequest
の中に必要な情報を詰め込みます。
第一引数のclientId
はQiitaのAPI設定画面に記載されているものです。
こちらは機密情報にあたるためenvied
というパッケージを使って難読化しています。
第二引数のredirectUrl
はこちらもQiitaのAPI設定画面で設定したこのアプリの戻り先を指定するURLです。
オプショナルの引数であるserviceConfiguration
に認可コードを要求するエンドポイントのURLを、
scopes
にはこのアプリで要求する機能を指定します。
この3つはConstants
クラスにstatic
な定数として定義しています。
もちろん直接記述しても大丈夫です。
Constants
/// アプリ内で仕様する定数を管理する
abstract final class Constants {
static const host = 'qiita.com';
static const authEndPoint = '/api/v2/oauth/authorize';
static const tokenEndPoint = '/api/v2/access_tokens';
static const scope = ['read_qiita'];
static const redirectUrl = 'qiita-reader://oauth-callback';
static String get qiitaBaseUrl => Uri.https(host).toString();
static const serviceConfiguration = AuthorizationServiceConfiguration(
authorizationEndpoint: 'https://$host$authEndPoint',
// 今回ここは使われていませんが一応入れてます
tokenEndpoint: 'https://$host$tokenEndPoint',
);
}
authorize
の戻り値の中のauthorizationCode
の中に認可コードが入ってきます。
2. QiitaAPIで認可コードを使用してアクセストークンを取得する
// 認可コードでアクセストークンを取得する
final accessToken = await _fetchAccessToken(code);
今回はdio
を使ってhttps通信でリクエストを送ります。
/// 認可コードを使ってアクセストークンを取得する
Future<String?> _fetchAccessToken(String code) async {
// もらった認可コードでアクセストークンを取得する
// https://qiita.com/api/v2/docs#%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3-1
final data = {
'client_id': Env.clientId,
'client_secret': Env.clientSecret,
'code': code,
};
try {
final response = await _httpClient.post<Map<String, dynamic>>(
Constants.tokenEndPoint,
data: data,
);
if (response.statusCode == 200 || response.statusCode == 201) {
final accessToken = response.data?['token'].toString();
return accessToken;
} else {
logger.e('ステータスコード:${response.statusCode}');
return null;
}
} catch (e, s) {
logger.e('不明なエラー', error: e, stackTrace: s);
rethrow;
}
}
data
の中に詰めているパラメータの一つclient_secret
はclientId
と同じくQiitaのAPI設定画面に記載されているものです。
こちらもenvied
で難読化しています。
_httpClient.post
メソッドでリクエストを送信します。
第一引数にリクエストするエンドポイントを入れ、引数のdataに先ほど詰め込んだdata
を入れています。
その戻り値の中にtoken
というパラメータ名でアクセストークンが返ってきます。
dioをインスタンス化する際にbaseUrlを設定すると、基本のアクセス先を固定できます。
/// Dioのインスタンスを生成する
@Riverpod(keepAlive: true)
Dio http(HttpRef ref) {
// baseUrlを設定すると、httpリクエストは必ずqiitaBaseUrlを使うようになる
return Dio(BaseOptions(baseUrl: Constants.qiitaBaseUrl));
}
実は今回token
というパラメータ名で返ってくるのがネックでした。
パラメータ名がaccessToken
であったならば先ほど紹介したflutter_appauthにある別のメソッドauthorizeAndExchangeCode
で認可コードの取得とアクセストークンの取得を一つのメソッドで行うことが可能だったかもしれません。
// 💡 以下のように本当はできたかもしれない,,,
@override
Future<String?> fetchAccessToken() async {
try {
final webAuth = ref.read(appAuthProvider);
// 認可コードとアクセストークンを要求
final result = await webAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
Env.clientId,
Constants.redirectUrl,
clientSecret: Env.clientSecret,
serviceConfiguration: Constants.serviceConfiguration,
scopes: Constants.scope,
),
);
if (result == null || result.accessToken == null) {
logger.e('アクセストークンが取得できませんした', stackTrace: StackTrace.current);
return null;
}
// 取り出したコードを返却する
return result.accessToken;
} catch (e, s) {
logger.e('エラーが発生しました', error: e, stackTrace: s);
return null;
}
}
上記のメソッドでうまくいかなかったため、今回は認可コードの取得とアクセストークンの取得を分けた経緯があります。
別のサービスであればauthorizeAndExchangeCode
だけで事足りる場合があるかもしれません。
3. 取得したアクセストークンをセキュアストレージに保存する
// アクセストークンをセキュアストレージに保存する
await _secureStorage.setAccessToken(accessToken);
アクセストークンはサービスを利用する上で必要になってくるので保存しておく必要があります。
例えばQiitaに登録しているアカウント情報の取得やログアウトする際の手続きに必要です。
アクセストークンはできるだけセキュアな領域に保存する必要があるため、flutter_secure_storage
を使って保存します。
flutter_secure_storage
は、データを安全なストレージに保存するためのAPIを提供します。
iOSであればキーチェーン、AndroidではencryptedSharedPreferences
をオプションで指定して難読化をかけて保存されます。
/// FlutterSecureStorageのインスタンスを生成
@Riverpod(keepAlive: true)
FlutterSecureStorage secureStorage(SecureStorageRef ref) {
const secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
// Androidの場合は暗号化を有効にする
encryptedSharedPreferences: true,
),
);
return secureStorage;
}
こちらの詳細解説は割愛します。
コードは以下でご確認ください。
SecureStorageRepository
class SecureStorageRepository implements SecureStorageRepositoryBase {
SecureStorageRepository(this.ref);
final ProviderRef<dynamic> ref;
static const accessTokenKey = 'accessToken';
@override
Future<String?> getAccessToken() => _get<String>(accessTokenKey);
@override
Future<void> setAccessToken(String? token) => _set(accessTokenKey, token);
Future<T?> _get<T>(String key) async {
final secureStorage = ref.read(secureStorageProvider);
final value = await secureStorage.read(key: key);
switch (T) {
case int:
return int.tryParse(value ?? '') as T?;
case double:
return double.tryParse(value ?? '') as T?;
case bool:
return bool.tryParse(value ?? '') as T?;
case String:
return value as T?;
case DateTime:
return DateTime.tryParse(value ?? '') as T?;
case _:
throw UnsupportedError('対応していない型です。');
}
}
Future<void> _set(String key, Object? value) async {
final secureStorage = ref.read(secureStorageProvider);
switch (value) {
case final int v:
await secureStorage.write(key: key, value: v.toString());
case final double v:
await secureStorage.write(key: key, value: v.toString());
case final bool v:
await secureStorage.write(key: key, value: v.toString());
case final String v:
await secureStorage.write(key: key, value: v);
case final DateTime v:
await secureStorage.write(key: key, value: v.toIso8601String());
case null:
await secureStorage.delete(key: key);
default:
throw UnsupportedError('対応していない型です。');
}
}
}
尚、自前で用意した便利メソッドを_get
や_set
にまとめています。
こちらはgetAccessToken
メソッドやsetAccessToken
メソッドの定義内でfinal secureStorage = ref.read(secureStorageProvider);
を呼び出して保存や取得を呼び出しても大丈夫です。
4. ログインしたことがあることを記録する
final isFirstLogin = await _keyValueStore.getIsFirstLogin();
if (isFirstLogin == null || isFirstLogin == true) {
// 今回が初めてのログインだった場合はフラグをfalseで保存する
await _keyValueStore.setIsFirstLogin(value: false);
}
こちらはその名の通り初めてログインしたことがあるかどうかをフラグとして保存します。
なぜこのフラグを保存する必要があるのかについては後ほどアプリを再ダウンロードした場合の対策を行う(iOS専用)で解説します。
こちらの保存先はセキュアである必要がないため、shared_preferences
に保存します。
こちらも詳細解説は割愛します。
ソースコードは以下です。
KeyValueRepository
class KeyValueRepository implements KeyValueRepositoryBase {
KeyValueRepository(this.ref);
final ProviderRef<dynamic> ref;
/// 初めてログインしたかのフラグのキー
static const isFirstLoginKey = 'isFirstLogin';
@override
Future<bool?> getIsFirstLogin() async => _get<bool>(isFirstLoginKey);
@override
Future<void> setIsFirstLogin({bool? value}) => _set(isFirstLoginKey, value);
/// 指定されたキーに関連付けられたデータをSharedPreferencesから取得します。
///
/// [key] には取得したいデータのキーを指定します。
/// この関数は、ジェネリック型[T]に基づいて適切なデータ型の取得を試みます。
///
/// 型[T]に応じて以下の取得方法が使用されます:
/// - `int`: SharedPreferences.getIntを使用して整数を取得。
/// - `double`: SharedPreferences.getDoubleを使用して浮動小数点数を取得。
/// - `String`: SharedPreferences.getStringを使用して文字列を取得。
/// - `bool`: SharedPreferences.getBoolを使用してブーリアン値を取得。
/// - `DateTime`: 文字列として保存された日時を[DateTime.parse]を使用して解析。
/// - `List`: JSON文字列として保存されたリストを`json.decode`を使用して解析。
/// - `Map`: JSON文字列として保存されたマップを`json.decode`を使用して解析。
///
/// [T]がサポートされていない型の場合、[UnsupportedError]がスローされます。
///
/// この関数は非同期であり、結果は`Future<T?>`として返されます。
///
/// @param key 取得したいデータのキー。
/// @return [Future<T?>] 取得したデータを含むFuture、もしくはnull。
/// @throws [UnsupportedError] 指定された型がサポートされていない場合。
Future<T?> _get<T>(String key) async {
final pref = await ref.read(sharedPreferencesProvider.future);
switch (T) {
case int:
return pref.getInt(key) as T?;
case double:
return pref.getDouble(key) as T?;
case String:
return pref.getString(key) as T?;
case bool:
return pref.getBool(key) as T?;
case DateTime:
return switch (pref.getString(key)) {
final dateTimeString? => DateTime.parse(dateTimeString) as T,
_ => null,
};
case const (List<dynamic>):
final value = pref.get(key);
if (value is List<String>) {
return value as T?;
}
return switch (value) {
final String stringValue => json.decode(stringValue) as T,
_ => null,
};
case const (Map<dynamic, dynamic>):
return switch (pref.getString(key)) {
final value? => json.decode(value) as T,
_ => null,
};
case _:
throw UnsupportedError('対応していない型です');
}
}
/// 指定されたキーと値をSharedPreferencesに保存します。
///
/// - 値の型に基づいて適切なSharedPreferencesのメソッドを使用します。
/// - 値が`null`の場合はキーを削除します。
/// - 値の保存後、変更があったキーを_onValueChanged Streamに通知します。
///
/// `SharedPreferences`に値を保存する際、値の型に基づいて適切な保存方法を選択します。
/// - `int`型の場合はを`SharedPreferences.setInt`を呼び出します。
/// - `double`型の場合は、`SharedPreferences.setDouble`を呼び出します。
/// - `bool`型の場合は、`SharedPreferences.setBool`を呼び出します。
/// - `String`型の場合は、`SharedPreferences.setString`を呼び出します。
/// - `DateTime`型の場合はISO8601文字列に変換し、`SharedPreferences.setString`を呼び出します。
/// - `List<String>`型の場合は、`SharedPreferences.setStringList`を呼び出します。
/// - 値が`null`の場合は、対応するキーのデータを削除します。
/// - 上記のどの型にも該当しない場合は、値をJSON文字列にエンコードし、`SharedPreferences.setString`を呼び出します。
Future<void> _set(String key, Object? value) async {
final pref = await ref.read(sharedPreferencesProvider.future);
switch (value) {
case final int intValue:
await pref.setInt(key, intValue);
case final double doubleValue:
await pref.setDouble(key, doubleValue);
case final bool boolValue:
await pref.setBool(key, boolValue);
case final String stringValue:
await pref.setString(key, stringValue);
case final DateTime dateTimeValue:
await pref.setString(key, dateTimeValue.toIso8601String());
case final List<String> listStringValue:
await pref.setStringList(key, listStringValue);
case null:
await pref.remove(key);
case _:
await pref.setString(key, jsonEncode(value));
}
}
}
5. ログインしたことをイベントとして配信する
_authStateChanges.sink.add(true);
こちらは後述する 3-3. ログインの有無を知らせる機能 で解説します。
3-2. ログアウトの処理
ログアウト機能には以下の順番で処理を行う必要があります。
- 現在デバイスで保持しているアクセストークンを取得
- QiitaAPIでアクセストークンを破棄するように要求する
- 破棄が成功したら、デバイス上のアクセストークンを削除する
- ログアウトしたことをイベントとして配信する
上記をまとめると以下の内容になります。
class AuthService implements AuthServiceBase {
AuthService(this.ref);
final ProviderRef<dynamic> ref;
// 省略
SecureStorageRepositoryBase get _secureStorage =>
ref.read(secureStorageRepositoryProvider);
Dio get _httpClient => ref.read(httpProvider);
final StreamController<bool> _authStateChanges = StreamController<bool>();
// 省略
@override
Future<void> logout() async {
// 保存されたアクセストークンを取得
final accessToken = await _secureStorage.getAccessToken();
if (accessToken == null) return;
// サーバーにアクセストークンの破棄を要求
final path = '${Constants.tokenEndPoint}/$accessToken';
final response = await _httpClient.delete<Response<dynamic>>(path);
// サーバーで削除が成功したら実行
if (response.statusCode == 204) {
// セキュアストレージのアクセストークンを削除する
await _secureStorage.setAccessToken(null);
_authStateChanges.sink.add(false);
} else {
logger.e('ログアウトに失敗しました');
}
}
}
1. 現在デバイスで保持しているアクセストークンを取得
// 保存されたアクセストークンを取得
final accessToken = await _secureStorage.getAccessToken();
if (accessToken == null) return;
次で解説する 「QiitaAPIでアクセストークンを破棄するように要求する」 ために必要なのでまずここでデバイスに保存されているアクセストークンを取得します。
2. QiitaAPIでアクセストークンを破棄するように要求する
// サーバーにアクセストークンの破棄を要求
final path = '${Constants.tokenEndPoint}/$accessToken';
final response = await _httpClient.delete<Response<dynamic>>(path);
通常アクセストークンには有効期限が設けられている場合が多いですが、QiitaAPIが提供するアクセストークンには有効期限がありません。
よってログアウトする場合に一度発行したアクセストークンの削除を要求する必要があります。
公式のドキュメントに従い、dioのDeleteメソッドで指定のエンドポイントに先ほど取得したアクセストークンを入れて削除を要求します。
3. 破棄が成功したら、デバイス上のアクセストークンを削除する
// サーバーで削除が成功したら実行
if (response.statusCode == 204) {
// セキュアストレージのアクセストークンを削除する
await _secureStorage.setAccessToken(null);
戻り値のstatusCodeで削除が成功したか判断してから、デバイスに保存されているアクセストークンを削除します。
204を確認しないで削除してしまうと、仮にサーバー側で削除が失敗してもデバイス上のアクセストークンを削除してしまうので注意してください。
4. ログアウトしたことをイベントとして配信する
_authStateChanges.sink.add(false);
こちらも次の 「3-3. ログインの有無を知らせる機能」 で解説します。
3-3. ログインの有無を知らせる機能
ログインの有無を知らせる機能とは、ログイン中であるかどうかの状態が変わった瞬間にリアルタイムで知らせる機能です。
その名の通りログイン中の有無なのでbool
の値で表現します。
リアルタイムに値を流すにはStream
が最適です。
方法としてはAuthService
内でStreamController
を使ってイベントを流します。
ログイン処理の最後にtrue、ログイン処理の最後にfalseを配信するようにすればいいだけです。
final StreamController<bool> _authStateChanges = StreamController<bool>();
@override
Stream<bool> get isLoggedInStream => _authStateChanges.stream;
@override
Future<void> login() async {
try {
// 省略
_authStateChanges.sink.add(true); // 👈 ここでストリームに流す
} catch (e, s) {
logger.e('エラーです', error: e, stackTrace: s);
}
}
@override
Future<void> logout() async {
// 省略
_authStateChanges.sink.add(false); // 👈 ここでストリームに流す
} else {
logger.e('ログアウトに失敗しました');
}
}
このisLoggedInStreamを使って後に紹介するログイン状態によって画面を遷移させる機能を実現します。
3-4. 認証関連機能の初期設定
この機能の目的は以下です。
- アプリ起動直後にログイン中の有無を配信する
- アプリを再ダウンロードした場合の対策を行う(iOS専用)
それぞれ解説します。
アプリ起動直後にログイン中の有無を配信する
例えば、ログイン処理を行った後にアプリを終了させたとします。
今回のアプリでは再びアプリを起動した場合にログイン状態を引き継いでいたいとしています。
そうなった場合にはアプリを起動した直後にログインの有無を知らなければいけません。
しかし、先の実装のままだとログイン処理またはログアウト処理をした後にならないとイベントが配信されていません。
そこでアプリ起動直後にisLoggedInStream
を使ってその有無を把握します。
ではログインしているかどうかの判断材料はなんでしょうか?
それはログインできていれば手に入れているもの、すなわちアクセストークンの有無です。
よって保存されているアクセストークンを取得し、その有無をそのままisLoggedInStream
に流せばいいのです。
@override
Future<void> init() async {
// ここは一旦無視するとして
final accessToken = await _secureStorage.getAccessToken();
// アクセストークンがnullならfalse、あるならtrue
_authStateChanges.sink.add(accessToken != null);
}
しかし、そこで問題になってくるのがFlutterSecureStorage
の仕様上の問題です。
次で詳しく解説します。
アプリを再ダウンロードした場合の対策を行う(iOS専用)
SharedPreferences
を使ってローカルにデータを保存した場合、アプリをアンインストールするとそのアプリに紐づくデータも一緒に削除されます。
しかし、これはiOS限定にはなりますがFlutterSecureStorage
を使って保存されたデータはアプリをアンインストールしてもデータは一緒に削除されません。
これはiOSのKeyChainの機能の仕様です。これが何を意味するかというと上記でアクセストークンの有無によってログインの有無を判定する際の不具合を起こす可能性があります。
なぜかというと以下のようになります。
- ログインした状態でアプリをアンインストールする
- アプリを再ダウンロードする
- アプリを起動した際
init
メソッド内でアクセストークンを取得する - 昔保存していたアクセストークンが残っているのでログイン状態になってしまう
よって一回でもログインしたことがあるかを確認します。
このフラグがない、もしくはtureだった場合はインストールしたばかりということになります。
その場合は念の為一度アクセストークンをnullで保存すれば誤ってログインされることはありません。
@override
Future<void> init() async {
// 一回でもログインしたことがあるかを確認
final isFirstLogin = await _keyValueStore.getIsFirstLogin();
if (isFirstLogin == null || isFirstLogin) {
// アプリを再ダウンロードした場合を加味して念のためアクセストークンをnullで登録
await _secureStorage.setAccessToken(null);
}
final accessToken = await _secureStorage.getAccessToken();
_authStateChanges.sink.add(accessToken != null);
}
なお、最初のログインから2回目以降はアプリ起動時にアクセストークンをnullにする必要はないです。
そのことから4.ログインしたことがあることを記録するで書いた処理、初めてログインした状態をfalseで保存しているのです。
4. ログイン状態によって画面を遷移させる
3. 認証関連の機能の実装においてログインとログアウトの機能はできました。
この章ではそのログイン状態によって画面を自動で切り替える処理を実装していきます。
まず、このログイン状態の有無によって画面を遷移させる機能を実現させるにはgo_router
のredirect
の機能を使って実現させます。
redirect
機能を簡単に説明すると、以下の内容になります。
- リダイレクトする条件を監視するクラスが変更を検知する
- 監視クラスが変更を検知すると変更があった旨を配信
- 変更の配信を受信した
GoRrouter
はリダイレクトを実行 - リダイレクトに基づいたルーティングを行う
4-1. リダイレクトする条件を監視するクラスを作成する
/// アプリケーションの状態変化に基づいてルートを管理するためのプロバイダ
final appStateChangeNotifierProvider =
ChangeNotifierProvider<AppStateChangeNotifier>(AppStateChangeNotifier.new);
/// アプリの状態に伴うルート変更を管理する`ChangeNotifier`
///
/// `notifyListeners`が呼び出されると、`GoRouter`が`refresh`され、
/// 必要に応じてリダイレクト処理が行われます。
class AppStateChangeNotifier extends ChangeNotifier {
AppStateChangeNotifier(this.ref) {
// `isLoggedInProvider`の変更を監視し、変更があれば`notifyListeners`を非同期に呼び出す
final isLoggedInSubscription = ref.listen(isLoggedInProvider, (_, __) {
Future.microtask(notifyListeners);
});
// このNotifierが破棄されるときに`isLoggedInSubscription`もクリーンアップされるようにする
ref.onDispose(isLoggedInSubscription.close);
}
// このクラスが依存するプロバイダを操作するためのレフ
final ChangeNotifierProviderRef<AppStateChangeNotifier> ref;
// 以下省略
}
AppStateChangeNotifier
クラスは、アプリケーションの状態を監視し、状態の変化をリスナーに通知するためChangeNotifier
クラスを継承して定義しています。
ChangeNotifier
はFlutterの標準クラスで、notifyListeners
というメソッドを持っています。
このメソッドを呼び出すと、ChangeNotifier
をリスニングしているすべてのリスナーに変更があったことを通知します。
AppStateChangeNotifier
のコンストラクタでは、isLoggedInProvider
の変更をリッスン(監視)しています。
@Riverpod(keepAlive: true)
Stream<bool> isLoggedIn(IsLoggedInRef ref) {
return ref.read(authServiceProvider).isLoggedInStream;
}
isLoggedInStream
は3-3. ログインの有無を知らせる機能で作成したStreamでした。
つまりログインした時とログアウトした時のイベントが流れてきます。
ここでは何かしらログインに関わるイベントが流れてきたらその変更を通知するようにしています。
このisLoggedInProvider
へアクセスするためにAppStateChangeNotifier
クラスの中でrefを利用しています。
このrefはChangeNotifierProviderRef<AppStateChangeNotifier>
型で、AppStateChangeNotifier
専用のリファレンス(参照)を保持しています。
これにより、refを通じて他のプロバイダにアクセスすることができるようになっています。
4-2. リダイレクトの処理
/// ルート遷移時に呼び出され、リダイレクト先のパスを返す非同期メソッド
///
/// ログイン状態に基づいて、適切なリダイレクトルールを適用します。
/// - `return`: リダイレクト先のパス。リダイレクトが不要な場合は`null`を返します。
Future<String?> redirect(BuildContext context, GoRouterState state) async {
final isLoggedIn = await ref.read(isLoggedInProvider.future);
// ログイン状態に応じて適切なガードを適用
switch (isLoggedIn) {
case true:
return _authGuard(state);
case false:
return _noAuthGuard(state);
}
}
/// ログイン済みユーザー向けのリダイレクトルール
///
/// ログイン済みの場合、ログイン画面にアクセスしようとするとリスト画面にリダイレクトします。
/// - `state`: 現在のルートの状態を保持する`GoRouterState`
/// - `return`: リダイレクト先のパス。リダイレクトが不要な場合は`null`を返します。
Future<String?> _authGuard(
GoRouterState state,
) async {
// ログイン画面にアクセスしようとした場合、リスト画面にリダイレクト
if (state.fullPath == const LoginRoute().location) {
return const ListRoute().location;
}
return null; // リダイレクトが不要な場合
}
/// 未ログインユーザー向けのリダイレクトルール
///
/// 未ログインの場合、ログイン画面以外のアクセスを試みるとログイン画面にリダイレクトします。
/// - `return`: リダイレクト先のパス。リダイレクトが不要な場合は`null`を返します。
Future<String?> _noAuthGuard(
GoRouterState state,
) async {
// ログイン画面以外にアクセスしようとした場合、ログイン画面にリダイレクト
if (state.fullPath != const LoginRoute().location) {
return const LoginRoute().location;
}
return null; // リダイレクトが不要な場合
}
処理内容を簡潔に書くと以下の通りです。
- 現在のログイン状態を取得
- ログイン状態によってそれぞれのガードメソッドを実行する
- ログインしていた場合
- 現在いる画面がログイン画面であったならリスト画面のパスを返す
- ログイン画面にいるのなら何もしない
- ログインしていなかった場合
- 現在いる画面がログイン画面以外であればログイン画面のパスを返す
- ログイン画面にいるのであれば何もしない
- ログインしていた場合
4-3. appStateChangeNotifierProvider
を定義する
/// アプリケーションの状態変化に基づいてルートを管理するためのプロバイダ
final appStateChangeNotifierProvider =
ChangeNotifierProvider<AppStateChangeNotifier>(AppStateChangeNotifier.new);
refを通してAppStateChangeNotifier
クラスにアクセスできるようなります。
4-4. go_routerにルートをリフレッシュさせる監視クラスとリダイレクト処理を設定する
@Riverpod(keepAlive: true)
GoRouter appRouter(AppRouterRef ref) {
return GoRouter(
// アプリケーション全体のナビゲーションを管理するためのキーを設定
navigatorKey: rootNavigationKey,
// アプリ起動時に最初に表示する画面のルートを指定
initialLocation: const LoginRoute().location,
// 使用するルート一覧を指定(go_router_builderで自動生成されたルート)
routes: $appRoutes,
// ルートのリフレッシュを監視するためのリスナーを設定
refreshListenable: ref.read(appStateChangeNotifierProvider), // 👈
// 特定の条件に基づいてリダイレクトを行うロジックを指定
redirect: ref.read(appStateChangeNotifierProvider).redirect, // 👈
);
}
これでログイン状態によって画面を切り替える機能が実装できました✌️
終わりに
今回は、以前投稿したQiitaAPIのOAuthを使ったログイン機能を、より現実的なアーキテクチャと実装方法でアップデートしました。
レイヤードアーキテクチャの採用やflutter_appauthパッケージの利用、go_routerによるルーティング管理など、現場で得た知識を活かして、コードの保守性と拡張性を高めることができました。
このリダイレクト機能を拡張すれば、例えばメンテナンスモードの実装、初回同期の実装、DeepLinkによる画面遷移などさまざまな機能を付随させることも可能です。
この記事を通して、より実践的なログイン機能の実装方法について理解を深めていただけたら幸いです。