4
1

【Flutter】QiitaAPIとOAuth2.0を使ってのログイン画面を作成する

Posted at

はじめに

みなさんこんにちわ、Flutter学習約2ヶ月の駆け出しエンジニアのはるさんです。

ログイン周りの実装を行う際にFirebaseAuthを使うとものすごく簡単に実装できますよね。
しかし企業や案件の全てがFirebaseを使っているわけではありません。
そこで今回はhttps通信を使ってQiitaAPIにアクセスし、OAuth2.0の仕組みを利用してログイン処理の実装を行う方法を書いていきたいと思います。

記事の対象者

  • OAuth2.0での認証と認可を実装したい方
  • https通信でAPIを呼び出す実装方法を学びたい方
  • ある程度のアプリ開発経験(iOS,Android,Flutterなど)がある方
  • flutterで小さなプロジェクトをいくつか作った経験のある方

記事を執筆時点での筆者の環境

  • macOS 14.3.1
  • Xcode 15.2
  • Swift 5.9
  • iPhone11 pro ⇒ iOS 17.2.1
  • Flutter 3.19.0
  • Dart 3.3.0
  • Pixel 7a ⇒ Android

1. 今回の実装の概要

まず、今回のコードはGitHub上で公開しています。

とりあえず実装のコードが見たいだけ!という方は以下のリンクからコードをご覧ください。

但し、まだアプリ自体は作成中なので以下の注意点を一度ご覧ください。

Githubのコードを見る際の注意点

当初はflutter_web_auth_2というパッケージをメインで紹介しようと作成したいたため、アプリ名はこのようになっています。

このアプリの目的は表題のとうりQiitaAPIとOAuth2.0を操作できることにあります。

また、今回はMVCベースで記載している内容をもとに説明していきます。

いわゆるテストしやすい設計だったり、再利用しやすい設計というよりは分かりやすく、手軽に動くコードを前提に書いています。

逆にレイヤードアーキテクチャを参考にしたり、再利用性を高める設計にしたりというふうにリファクタリングを進めており、refactorフォルダで進行中です。
が、まだ完成しておりません😰

これを完成させるにはまだ時間がかかると思ったので、一旦今回の記事を書いてしまおうと思った次第です。

フォルダーは以下のように分けています。

  • common_file → リファクターに関わらず共通で使うものを入れています。main.dartやエンティティ、env.dartなど
  • plain → 今回の記事で説明しているコードをまとめています。PlainHomePagePlainLoginPage など
  • refactor_now → リファクタリング中のコードです

1-1. OAuth2.0とhttps通信

簡単な説明をChatGPT先生にお願いしました。

OAuth 2.0とは?

インターネット上でユーザーのリソースへのアクセス許可を第三者に委任するためのオープンスタンダードです。
OAuth 2.0を使うとユーザーは自分のデータが保存されているサービス(例えば、GoogleやFacebookなど)にログインしている状態で、そのデータを他のアプリケーションが安全にアクセスできるよう許可できます。このプロセスはユーザーのパスワードをアプリに渡さず行われます。

https通信とは?

https(HyperText Transfer Protocol Secure)は、インターネット上で情報を安全に交換するためのプロトコルです。
httpsの「S」は「Secure(安全)」の意味です。この通信方法では、送受信する情報が暗号化されます。暗号化することで、情報が盗まれるリスクを減らし、第三者によるデータの改ざんを防ぎます。

Qiita APIを使ってログイン機能を構築する際、ユーザー名やパスワードなどの個人情報を扱います。このようなデータを安全に扱うためには、HTTPS通信が不可欠です。ログイン情報が暗号化されずに送信された場合、悪意のある第三者によって簡単に盗まれてしまう可能性があります。

1-2. アプリの機能

今回のアプリの挙動は以下のような形です。

1.ログイン画面でログインボタンを押すと、アプリ内ブラウザを起動する

2.アプリ内ブラウザではQiitaのログイン画面を表示する

3.Qiitaアカウントを使ってログイン後に「許可する」をタップして認可コードをもらってアプリのログイン画面に戻る

4.認可コードを使って「アクセストークン」を取得する
5.アクセストークンを取得したことをトリガーにしてホーム画面に移動する
6.ホーム画面ではアクセストークンを使ってQiitaに登録している自分の情報を表示する

7.ホーム画面にあるログアウトボタンを押すとサーバー側のアクセストークンを削除する
8.サーバーでの削除が成功したらホーム画面を閉じて、ログイン画面に戻る

9.もしもログイン中の状態でアプリを終了し、再度アプリを立ち上げるとログイン状態を判別して自動でホーム画面に遷移する

login-to-home.gif

今回はサンプルアプリのため、EntrancePage -> PlainLoginPageとしている。
本来は最初に表示される画面がPlainLoginPageであると想定してください。
GIFではPlainLoginPageに遷移した際、ログイン状態を判定しPlainHomePageに遷移している。

1-3. 使用するパッケージとその目的

パッケージについては知ってるよ!という方は目次から 2. 事前準備 まで飛ばしてご覧ください。
上記の機能を実装する上で必要なパッケージは結構あります。自分も作り始めてびっくりでした。
以下に使用する各パッケージの概要と使用目的を上げていきます。

なお、今回の記事ではそれぞれのパッケージの細かい使用法については解説しません。
それぞれに公式ドキュメントのリンクや私が実際に学んだ記事や動画のリンクを貼っておきますので、
詳しくはそちらを参考にしてみてください。

1. flutter_web_auth_2

dependencies:
  flutter_web_auth_2:

アプリ内ブラウザの起動と認可コードを受け取った後にアプリに戻ってくる処理を簡単に実装できます。

2. dio

dependencies:
  dio:

https通信を行う定番のパッケージです。

今回は以下の機能を実装することに使用しています。

  • 認可コードを使ってアクセストークンを取得する
  • アクセストークンを使ってユーザー情報を取得する
  • 指定のアクセストークンの削除を要求する

3. flutter_secure_storage

dependencies:
  flutter_secure_storage:

こちらはiOSではKeychain、AndroidではKeyStoreを使ってデータを保存してくれるパッケージです。
いわゆるローカルデータベース(デバイスのストレージ)に保存する仕組みを提供していますが、
よりセキュアな情報を保存することを目的にしています。
今回でいうと取得したアクセストークンを保存する目的で使用します。

しかし、このパッケージの注意点があります。
アプリをアンインストールした場合、通常はアプリに紐ずくデータも削除されます。
しかし、iOSだけなのですが削除されないのです。
これはKeychain特有の仕様です。
なので、iOSのために後述する対応が必要になります。

4. shared_preferences

dependencies:
  shared_preferences:

iOSではUserDefaults、AndroidではSharedPreferencesにデータを保存するパッケージです。
こちらもローカルデータベースに保存する仕組みですが、セキュリティー面ではそこまでの保証はないです。
一般的には一時的なデータを保存することに向いており、いわゆる設定関連などを保存するのに使われます。

今回でいうとこのアプリが「初めて起動したかのフラグ」を保存します。
アプリが起動するたびにこの「フラグ」を確認し、
初めての場合は上記で説明したアクセストークンをリセットします。
そうでない場合はそのままスルーします。

こうすることで、万が一ログアウトせずにアクセストークンが残った状態でアプリを削除し、再度アプリをインストールしても残されたアクセストークンを誤って使用せずに削除してから始めることが可能になります。
ここに関してはちょっと難解なので、実装を見た方が理解できると思います。

5. envied

dependencies:
  envied:

dev_dependencies:
  build_runner:
  envied_generator:

アクセストークンを取得する際に、アプリ開発者に与えられたクライアントIDとクライアントシークレットという二つのコードを使います。
ただ、それらをプロジェクト内に直接ハードコーディングしてしまうと悪用されるリスクがあります。
これらをある程度防ぐために難読化してくれるパッケージです。

このパッケージはなくても今回の実装に困ることはありませんが、使用しない場合は間違ってGitHubなどで公開しないように注意しましょう。

6. flutter_riverpod

こちらの動画は全部で4回にわたって解説されています。

dependencies:
  flutter_riverpod:
  riverpod_annotation:

dev_dependencies:
  build_runner: // enviedでも使ってる
  riverpod_generator:

状態管理パッケージの定番中の定番です。

なるべく使わずに実装しようとも思ったのですが、flutterのあらゆるプロジェクトで使われているので今回もこちらを使って状態管理していくことにしました。

今回で行けば以下の2つを管理ます。

  • アクセストークン
  • ユーザー情報

7. flutter_hooksとhooks_riverpod

dependencies:
  flutter_hooks:
  hooks_riverpod:

ざっくりいうと、StatefulWidgetをもっと簡潔に使えるようにしてさらに便利機能もつけたHookWidgetを使えるようにするパッケージです。
riverpodの作者が開発したパッケージでありさらにhooks_riverpodも導入することで、
riverpodのConsumerWidgetと併用することも可能です。

今回は画面を起動した直後に前述した
「このアプリが初めて起動したかをチェックする」処理と
「ホーム画面に遷移してきたらユーザー情報を取得する」処理
を実行させるためのuseEffectのために導入しています。

8. freezed

dependencies:
  freezed_annotation:
  json_annotation:

dev_dependencies:
  build_runner: // enviedとriverpodとかぶってる
  freezed:
  json_serializable:

こちらも定番中の定番パッケージですね。
いわゆるモデルのためのコードを自動生成してくれます。

また取得したJSONデータをDart用のオブジェクトに変換するためのfromJsonメソッドなんかも自動生成してくれます。

今回は取得したユーザー情報をUser クラスにしていますが、こちらをこのパッケージで作成しています。

9. riverpod_lint

dev_dependencies:
  custom_lint:
  riverpod_lint:

analysis_options.ymlに以下を追記

analyzer: 
  plugins:
    - custom_lint

こちらを使うとriverpod関連のlintの指摘と、
VSCodeでウィジェットを⌘ + .(ドット)で選択するとConsumerWidgetやHookWidgetへ簡単に変換できるようになります。
必須ではないですが、便利なのでおすすめです。

2. 事前準備

実装前の簡単な事前準備を以下に記載します。準備は以下の3点です。

  • パッケージのインストール
  • QiitaAPIの設定
  • Android側のschemeの設定

2-1. プロジェクトの作成とパッケージのインストール

新規でプロジェクトを作成したら、まずはpubspec.yamlに以下を記述して保存ボタンを押してパッケージをインストールしてください。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  envied:
  flutter_web_auth_2:
  dio:
  shared_preferences:
  flutter_riverpod:
  riverpod_annotation:
  flutter_hooks:
  hooks_riverpod:
  cupertino_icons: ^1.0.6
  flutter_secure_storage:
  freezed_annotation:
  json_annotation:

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner:
  envied_generator:
  riverpod_generator:
  custom_lint:
  riverpod_lint:
  freezed:
  json_serializable:
  flutter_lints: ^3.0.0

riverpod_lintを使用する場合はanalysis_options.yamlに以下のコードを最終行に追加します。

analysis_options.yaml
analyzer:
  plugins:
    - custom_lint

2-2. QiitaAPIの設定

まだQiitaのアカウントを持っていない場合は、アカウントの作成をしてください。
アカウントを作成したのちにQiitaAPIを使えるように設定していきます。

1.アカウントの設定画面を開きます

スクリーンショット 2024-03-18 21.19.26.png

2.アプリケーション画面の「アプリケーションを登録する」を選択します

スクリーンショット 2024-03-18 21.22.56.png

3.必要事項を入力して保存ボタンを押します

a. アプリケーションの名前 → プロジェクト名をそのまま入れる
b. アプリケーションの説明 → 任意ですが、一応入れました
c. WebサイトのURL → アプリのサイトなどを入れると思われます。ひとまず自身のマイページのURLを貼っておきました
d. リダイレクト先のURL → 認証が終わって認可コードを渡す場合にこの値を使って画面を閉じてアプリに戻ります。アプリ名://oauth-callbackにしておきます。

スクリーンショット 2024-03-18 21.28.50.png

4.作成したアプリの編集ボタンを押します

スクリーンショット 2024-03-18 21.40.15.png

  1. Client IDClient Secretを確認する
    コードへ転記する際にコピーしてそのままプロジェクトに貼り付けます。機密情報になるので取り扱いには気をつけましょう。

スクリーンショット 2024-03-18 21.46.22.png

2-3. Android側のschemeの設定

schemeとはいわゆるアプリの住所です。

アプリ上でログインボタンを押してQiitaAPIのOAuth2.0で認証を行う場合にアプリ内ブラウザを立ち上げます。

その後アプリ内ブラウザを閉じて認可コードと一緒にアプリへと帰ってくるのですが、その帰ってくるアプリを識別させるための住所的なものをschemeで設定しておかなければなりません。

iOSも本来は設定するのですが、flutter_web_auth_2によってそこは設定しなくても良いです(楽ちん😆)

ただ、Android側は設定が必要です。

やることはAndroidManifest.xmlにQiitaAPIで設定したリダイレクト先URLを設定することです。

  • ファイルの場所

ディレクトリはflutter_web_auth_2_sample/android/app/src/main/AndroidManifest.xml

flutter_web_auth_2_sample ⇒ それぞれ作ったアプリ名になってます

スクリーンショット 2024-03-20 15.07.32.png

  • 追加するコード

ここに以下を追加します。原本はこちらのサイトに載っています。

以下の中でandroid:nameの部分はcom.linus.の次にアプリ名を入れ、最後に.CallbackActivityを記載します。
android:schemeの右辺をQiitaAPIで設定したアプリ名://oauth-callbackのうち、アプリ名だけを記述します。

        <activity
            android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
            android:exported="true">
            <intent-filter android:label="flutter_web_auth_2">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="fwa2sample" />
            </intent-filter>
        </activity>

そして上記のコードをAndroidManifest.xml に差し込みます。

スクリーンショット 2024-03-03 18.09.17.png

3. 【実装】アクセストークンを状態で定義

今回のアプリではログインしているかどうかの判定をアクセストークンの有無で判定します。

取得したアクセストークンをriverpodを使って管理します。

作成後はbuild_runnerの実行をお忘れなく。

access_token_state.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'access_token_state.g.dart';

/// QiitaAPIのOAuthで取得したアクセストークンの状態を管理する
/// 保存先はよりセキュアなセキュアストレージを使用する
@riverpod
class PlainAccessTokenNotifier extends _$PlainAccessTokenNotifier {
  @override
  Future<String?> build() async {
    /// 初期値はセキュアストレージから呼び出す
    /// 認証前の段階では値がないのでnullの可能性がある
    return await _loadAccessToken();
  }

  // 状態を変更する関数
  Future<void> save(String token) async {
    await _saveAccessToken(token: token);
    state = AsyncData(token);
  }

  /// アクセストークンを削除する
  Future<void> delete() async {
    await _deleteAccessToken();
    state = const AsyncData(null);
  }

  /// アクセストークンキーをセキュアストレージに保存する際のキー
  final _accessTokenKey = 'plainAccessToken';

  /// セキュアストレージをインスタンス化
  final _storage = const FlutterSecureStorage();

  /// 保存されているアクセストークンを呼び出す
  Future<String?> _loadAccessToken() async {
    final accessToken = await _storage.read(key: _accessTokenKey);
    return accessToken;
  }

  /// アクセストークンを保存する
  Future<void> _saveAccessToken({required String token}) async {
    await _storage.write(key: _accessTokenKey, value: token);
  }

  /// アクセストークンを削除する
  Future<void> _deleteAccessToken() async {
    _storage.delete(key: _accessTokenKey);
  }
}

取得したアクセストークンは一度flutter_secure_storageを使ってデバイスに保存します。

保存したアクセストークンに対する操作はstateを経由して呼び出し、保存、削除を行います。

4. 【実装】ログイン画面

前提ですが、画面自体のUIを構成するplain_home_page.dart

処理のロジックを記述しているplain_home_page_controller.dart に分かれています。

この画面で実装する内容は以下のとおりです。

  • 初回起動判定
    このアプリを起動したのがインストール後に初めてか否かを判定し、初めてだったら念の為FlutterSecureStorageに保存されているかもしれないアクセストークンの削除処理を行う。
  • ログイン済み判定
    FlutterSecureStorageにアクセストークンがあるかを確認し、なければログイン画面を表示し、あるのであればホーム画面を表示する
  • ログイン処理-認証と認可コード取得
    ログインボタンを押したらアプリ内ブラウザを起動してQiitaの認証画面に遷移する。
    ユーザーが認証を済ませて戻ってくる際に認可コードを持って帰ってくる。
  • ログイン処理-アクセストークン取得
    ユーザーが認証を済ませて戻ってくる際に認可コードを持って帰ってくるので、そのコードを使ってアクセストークンをQiitaに要求する
  • ログイン処理-アクセストークン保存
    アクセストークンを取得できたらFlutterSecureStorageに保存する

4-1. 初回起動判定

以下をPlainLoginPageControllerに定義します。

plain_login_page_controller.dart
  /// 状態に関する処理も書くのでrefをもらっておく
  final WidgetRef ref;

  /// refからnotifierを持っておく
  PlainAccessTokenNotifier get accessTokenNotifier =>
      ref.read(plainAccessTokenNotifierProvider.notifier);
  
  /// このアプリがインストール後最初の起動であるかどうかを確認する
  ///
  /// iOSの場合はFlutterSecureStorageはキーチェーンに保存される。
  /// 今回でいうアクセストークンを保存するが、キーチェーンはアプリがアンインストールされても
  /// そのままデータが残ってしまう。
  /// 再度アプリを再インストールした場合でも以前のアクセストークンを使用できてしまうのを防ぐために、
  /// SharedPreferences(iOSではUserDefault)に初回起動フラグを保存する。
  /// そのフラグによって例えば初めての起動の場合は念の為、アクセストークンキーの保存があるなしに
  /// かかわらず一旦削除する処理を入れている
  Future<void> checkFirstLunch() async {
    final prefs = await SharedPreferences.getInstance();
    const isFirstLunchKey = 'plain_is_first_lunch';
    // 初めての起動かどうかフラグを取得
    final isFirstLunch = prefs.getBool(isFirstLunchKey);
    // もしもフラグがない、またはtrueだった場合は(つまり初めての起動)
    if (isFirstLunch == null || isFirstLunch) {
      // アクセストークンを削除
      await accessTokenNotifier.delete();
      // 初回起動フラグをfalseで保存
      prefs.setBool(isFirstLunchKey, false);
    }
  }

アプリがログイン画面を表示した際、checkFirstLunch()を実行するようにします。

useEffect内のcontroller.checkFirstLunch(); で実行させます。

plain_login_page.dart
class PlainLoginPage extends HookConsumerWidget {
  const PlainLoginPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // アクセストークンの保存状態を監視
    // アクセストークンのある、なしでログイン状態を判定する
    final accessToken = ref.watch(plainAccessTokenNotifierProvider);

    // ロジックを実行するcontrollerをインスタンス化
    final controller = PlainLoginPageController(ref: ref);

    // 画面初期化時に実行する
    // accessTokenの値が変わると再実行される
    useEffect(
      () {
        // 初回の起動かどうかチェックして必要な処理を実行
        controller.checkFirstLunch();
        return null;
      },
      [accessToken],
    );
    // 省略

4-2. ログイン済み判定

riverpodのAysyncValueの機能を使います。

この機能のwhenメソッドを使って、アクセストークンの状態によって表示するウィジェットを切り替えます。

plain_login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_web_auth_2_sample/plain/state/access_token_state.dart';
import 'package:flutter_web_auth_2_sample/plain/page/plain_home_page.dart';
import 'package:flutter_web_auth_2_sample/plain/page/plain_login_page_controller.dart';
import 'package:flutter_web_auth_2_sample/common_file/show_custom_snack_bar.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class PlainLoginPage extends HookConsumerWidget {
  const PlainLoginPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // アクセストークンの保存状態を監視
    // アクセストークンのある、なしでログイン状態を判定する
    final accessToken = ref.watch(plainAccessTokenNotifierProvider);

    // ロジックを実行するcontrollerをインスタンス化
    final controller = PlainLoginPageController(ref: ref);

    // 画面初期化時に実行する
    // accessTokenの値が変わると再実行される
    useEffect(
      () {
        // 初回の起動かどうかチェックして必要な処理を実行
        controller.checkFirstLunch();
        return null;
      },
      [accessToken],
    );
    // riverpodのAsyncValueの機能を用いてWidgetを作成
    return accessToken.when(
      data: (accessToken) {
        // アクセストークンがない==ログインしていないのでログイン画面を表示
        if (accessToken == null) {
          return _LoginPage(controller);
        } else {
          // アクセストークンがある==ログイン中なのでログイン画面を表示する
          // 最初にログインページを表示した後にホームページに**遷移させる**
          Future.microtask(
            () {
              Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (context) => const PlainHomePage(),
                  fullscreenDialog: true,
                ),
              );
            },
          );
          return _LoginPage(controller);
        }
      },
      // エラーがあったらとりあえず今回はログイン画面(適切な画面を出す)
      error: (e, _) => _LoginPage(controller),
      // ローディング中はぐるぐるを表示
      loading: () => const Center(child: CircularProgressIndicator()),
    );
  }
}

/// ログインページ
class _LoginPage extends StatelessWidget {
  // 省略
 }

アクセストークンがある場合にあえてログイン画面を生成して、そこからホーム画面に遷移するようにFuture.microtaskで囲っています。

ここでホーム画面をいきなり生成してしまうと、ホーム画面でログアウト処理を実行して画面を閉じた際に表示する画面が何もないことになってしまうのでこのようにあえて生成させています。

4-3. ログイン処理-認証と認可コード取得

ログインボタンを押した際の処理ですが、大きく分けて2つに分けることができます。

まずは、認証画面に遷移して認可コードを取得する処理です。

以下のリンクに記載があるように必要なurlを作成して認可コードの要求を行います。

plain_login_page_controller.dart
  /// ホスト
  final _host = 'qiita.com';
  
	/// 認証を行い認可コードを取得する
  Future<String?> _fetchCode() async {
    const authEndPoint = '/api/v2/oauth/authorize';
    const scope = 'read_qiita';
    const state = 'bb17785d811bb1913ef54b0a7657de780defaa2d';
    // Uri.httpsメソッドでそれぞれの構成要素を一つにまとめてくれる
    // 但し、そのままだと使えないので最後に`.toString()`で文字列に変換しておく
    final url = Uri.https(_host, authEndPoint, {
      'client_id': Env.clientId,
      'scope': scope,
      'state': state,
    }).toString();
    try {
      // FlutterWebAuth2を使って認証を行う
      final result = await FlutterWebAuth2.authenticate(
        // 認証を行う問い合わせ先を指定
        url: url,
        // あらかじめ登録した認証が終わったら認可コードを返却する先(このアプリ)の名称を指定
        callbackUrlScheme: 'fwa2sample',
        // ここをtrueにしておくと、iOSだと処理を開始した時の内部ブラウザに移動する時の
        // 確認のダイアログの表示が省略される
        options: const FlutterWebAuth2Options(preferEphemeral: true),
      );
      // 認可コードを'code'というパラメータで入っているので、それを使って取り出す
      final code = Uri.parse(result).queryParameters['code'];
      // 取り出したコードを返却する
      return code;
    } catch (e) {
      rethrow;
    }
  }

ここではまず、認証画面のURLをfinal url に定義しています。このurlの定義はUri.httpsメソッドのおかげで定義しやすくなっています。

ちなみにscopeにはこのアプリでできることを設定しており、今回は読み込みだけのread_qiitaを指定しています。
書き込み、削除なども設定できますので、そこは用途によって変えてください。

'client_id': Env.clientId,Env.clientIdの部分にQiitaAPIで登録した文字列が実際には入ります。

urlをプリント文に出してみると以下のような形になります。内容はあくまで例です。

https://qiita.com/api/v2/oauth/authorize?client_id=a91f0396a0968ff593eafdd194e3d17d32c41b1da7b25e873b42e9058058cd9d&scope=read_qiita&state=bb17785d811bb1913ef54b0a7657de780defaa2d

このurlをアプリ内ブラウザで開いてね、という処理をFlutterWebAuth2を使って実行しています。
resultに入る値は認可コードを含んでいますが、認可コードだけが入っているわけではありません。
そこでUri.parseメソッドでcodeというパラメータに該当する値を取り出します。
これでこの_fetchCode()の返り値としてfinal codeを得ることができました。

しかし、ユーザーが途中で処理をキャンセルしたり、何かしらのエラーで処理が中断する可能性もあるのでtry-catch構文を使っておきます。

このメソッドは別のメソッドで使う想定なのでエラーはrethrowにしておきます。

4-4. ログイン処理-アクセストークン取得

上記までで認可コードを取得できました。
この認可コードを使って以下のリンクにあるようにアクセストークンを要求します。

plain_login_page_controller.dart
  /// ホスト
  final _host = 'qiita.com';

  /// アクセストークンを取得する
  Future<String?> _fetchAccessToken(String code) async {
    const tokenEndPoint = '/api/v2/access_tokens';
    // ドキュメントにある必要な情報を入れてurlを作成
    final url = Uri.https(_host, tokenEndPoint, {
      // クライアントIDとクラアントシークレットはそれぞれ登録したものを代入してください
      'client_id': Env.clientId,
      'client_secret': Env.clientSecret,
      'code': code,
    }).toString();
    // Dioでhttpsの通信を行う
    final dio = Dio();
    // アクセストークンを要求
    final response = await dio.post(url);
    // レスポンスの中からアクセストークンを取り出す
    final accessToken = response.data['token'];
    // アクセストークンを返却
    return accessToken;
  }

ここでもまずはアクセストークンを要求するためのurlを定義しています。
そして今度は認証画面ではなく上記のurlに直接https通信を投げるのでdio を使って要求します。
レスポンスで帰ってきたデータから認可コードの時と同じようにtoken というパラメータを指定して取り出し、返却することで取得が実現します。

4-4. ログイン処理-アクセストークン保存

ここまできたら_fetchCode()_fetchAccessToken(String code)を組み合わせてログイン処理をメソッドとして定義しましょう。

そして最終的に取得したアクセストークンを状態をとうしてデバイスに保存すれば完了です。

plain_login_page_controller.dart
  /// 状態に関する処理も書くのでrefをもらっておく
  final WidgetRef ref;

  /// refからnotifierを持っておく
  PlainAccessTokenNotifier get accessTokenNotifier =>
      ref.read(plainAccessTokenNotifierProvider.notifier);

  /// ログイン処理
  ///
  /// 1.認証して認可コードを取得する
  /// 2.認可コードを使ってアクセストークンを取得
  /// 3.アクセストークンをデバイスに保存する
  Future<void> startLogin() async {
    // 認証を行って認可コードを取得する
    final code = await _fetchCode();
    if (code != null) {
      // 認証コードを使ってアクセストークンを取得する
      final accessToken = await _fetchAccessToken(code);
      if (accessToken != null && accessToken.isNotEmpty) {
        // アクセストークンを保存する
        await accessTokenNotifier.save(accessToken);
      }
    }
  }

あとはこのstartLogin()をログインボタンに仕込めば完成です。

この処理を実行すると最終的にはログイン画面で監視しているアクセストークンの状態が変更され、自動的に画面が切り替わります。

plain_login_page.dart
@override
  Widget build(BuildContext context, WidgetRef ref) {
    // アクセストークンの保存状態を監視
    // アクセストークンのある、なしでログイン状態を判定する
    final accessToken = ref.watch(plainAccessTokenNotifierProvider);
    
    // 省略
    
    // riverpodのAsyncValueの機能を用いてWidgetを作成
    return accessToken.when(
    // 以下でaccessTokenの状態によって生成されるWidgetが変わる

5.【実装】ユーザー情報の定義

アクセストークンを取得したことで可能になることの一つにユーザー情報の取得があります。
これをホーム画面に表示する機能を実装していきます。
その準備としてモデルと状態を定義していきます。

5-1. モデル

ユーザー情報はまずモデルとしてUser クラスを定義しています。

取得できる情報はさまざまで、以下のリンクに詳細が載っています。

今回はその中で3つの情報を取得することとし、以下のようにモデルを定義します。

user.dart
// ignore_for_file: invalid_annotation_target
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.g.dart';
part 'user.freezed.dart';

/// QiitaAPIから取得したユーザー情報のエンティティ
@freezed
class User with _$User {
  const factory User({
    // nameはそのままでOK
    required String name,
    // JSONは基本スネークキャメルケース、Dartはローワーキャメルケース
    // @JsonKey(name: 'items_count')でしてしたものをitemsCountとして置き換えてくれる
    // 但し、リントにはなぜか怒られるので1行目の // ignore_for_file: invalid_annotation_target
    // をつけておく
    @JsonKey(name: 'items_count') required int itemsCount,
    @JsonKey(name: 'profile_image_url') required String profileImageUrl,
  }) = _User;

  // JSON変換用のファクトリーメソッド
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

このクラスを freezed を使ってコードを生成します。
生成する場合はbuild_runnerを使用します。

上記を定義することfromJson メソッドが簡単に定義することができます。
Qiitaに要求したデータはJSON形式で帰ってくるため、その変換メソッドを用意する必要があります。

注意点としてパラメータ名を通常のローワーキャメルケースにするとうまく変換できません。これはQiitaAPIから帰ってくるJSONはスネークキャメルケースのためです。

そこもよしなに変換するように@JsonKey(name: 'items_count')のようにしてこのパラメータはrequired int itemsCountのことだよ、と指定する必要があります。

5-2. 状態

次に状態としてUserクラスを定義しておきます。

import 'package:dio/dio.dart';
import 'package:flutter_web_auth_2_sample/common_file/entity/user.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'plain_user_notifier.g.dart';
/// QiitaAPIから取得したログイン中のユーザー情報の状態を管理する
/// 取得自体がFutureなので、初期値はローディング
@riverpod
class PlainUserNotifier extends _$PlainUserNotifier {
  @override
  AsyncValue<User> build() {
    return const AsyncValue.loading();
  }

  /// ログイン中のユーザーデータを要求する
  Future<void> fetchUserData(String accessToken) async {
    const host = 'qiita.com';
    const endPoint = '/api/v2/authenticated_user';
    final url = Uri.https(host, endPoint).toString();
    // ユーザー情報を要求する場合にはヘッダーにアクセストークンを含める
    final headers = {'Authorization': 'Bearer $accessToken'};
    try {
      // Dioを使ってhttpsで要求する
      final dio = Dio();
      final response = await dio.get(
        url,
        options: Options(headers: headers),
      );
      // JSONで扱えるデータの元を取り出す
      final json = response.data;
      // Dartで扱える[User]オブジェクトに変換
      final user = User.fromJson(json);
      // 状態に保存する
      state = AsyncValue.data(user);
    } catch (e, s) {
      state = AsyncValue.error(e, s);
    }
  }
}

Userデータの初期値はAsyncValue.loading()としておきます。
そして今回は書き込み、削除などは行わず読み込みだけなのでfetchUserData() だけを定義します。
先ほどのリンクを参考にQiitaAPIでユーザー情報を取得する際のurlを作成し、https通信で要求します。
帰ってきたデータをJSONに変換し、状態に保存するながれです。

6. 【実装】ホーム画面

ホー無画面もログイン画面と同じく画面のUIを定義するplain_home_page.dart と、

画面で実行するロジックを定義したplain_home_page_controller.dart に分けています。

6-1. ユーザー情報を取得する

ユーザー情報を取得するメソッドを以下のように定義しています。

plain_home_page_controller.dart
/// PlainHomePageで実行するロジックを集約したクラス
class PlainHomePageController {
  const PlainHomePageController({
    required this.ref,
  });

  final WidgetRef ref;

  /// ユーザー情報を取得する
  Future<void> getUser() async {
    // アクセストークンを取り出す
    final accessToken = ref.read(plainAccessTokenNotifierProvider).value;
    if (accessToken != null) {
      // アクセストークンを使ってユーザー情報を取り出す
      ref.read(plainUserNotifierProvider.notifier).fetchUserData(accessToken);
    }
  }

// 省略
  
}

そしてホーム画面が表示された時にgetUser()メソッドを実行してユーザー情報を取得します。

正常に取得が完了すると、ログイン画面と同様にwhenメソッドによって必要なウィジェットを生成して表示します。

plain_home_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_web_auth_2_sample/common_file/entity/user.dart';
import 'package:flutter_web_auth_2_sample/common_file/show_custom_snack_bar.dart';
import 'package:flutter_web_auth_2_sample/plain/page/plain_home_page_controller.dart';
import 'package:flutter_web_auth_2_sample/plain/state/plain_user_notifier.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class PlainHomePage extends HookConsumerWidget {
  const PlainHomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // userのstateを監視
    final user = ref.watch(plainUserNotifierProvider);

    // ロジックを実行するcontrollerをインスタンス化
    final controller = PlainHomePageController(ref: ref);

    useEffect(
      () {
        // userデータをAPIから取得
        controller.getUser();
        return null;
      },
      [user],
    );
    return user.when(
      loading: () {
        return const Scaffold(
          body: Center(
            child: CircularProgressIndicator(),
          ),
        );
      },
      error: (error, _) {
        return const Scaffold(
          body: Center(
            child: Text('例外が発生しました'),
          ),
        );
      },
      data: (user) {
        return _PlainHomePage(
          user: user,
          controller: controller,
        );
      },
    );
  }
}

class _PlainHomePage extends StatelessWidget {
  const _PlainHomePage({
    required this.user,
    required this.controller,
  });

  final User user;
  final PlainHomePageController controller;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text(
            'PlainHomePage',
            style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
          ),
          automaticallyImplyLeading: false,
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              ClipOval(
                child: SizedBox(
                  height: MediaQuery.of(context).size.height / 3,
                  child: Image.network(
                    user.profileImageUrl,
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              ListTile(
                leading: const Text(
                  'ユーザー名',
                  style: TextStyle(fontSize: 20),
                ),
                title: Text(
                  user.name,
                  style: const TextStyle(fontSize: 28),
                ),
              ),
              ListTile(
                leading: const Text(
                  '記事投稿数',
                  style: TextStyle(fontSize: 20),
                ),
                title: Text(
                  user.itemsCount.toString(),
                  style: const TextStyle(fontSize: 28),
                ),
              ),
              ElevatedButton(
                  onPressed: () async {
                    final result = await controller.startLogout();
                    if (result && context.mounted) {
                      showCustomSnackBar(context, 'ログアウトしました😀');
                      Navigator.pop(context);
                    } else if (!result && context.mounted) {
                      showCustomSnackBar(context, 'ログアウトに失敗しました');
                    }
                  },
                  child: const Text('ログアウト')),
            ],
          ),
        ));
  }
}

6-2. ログアウトの処理

最後にログアウトの処理を実装します。

plain_home_page_controller.dart
  final WidgetRef ref;

  /// ログアウトを行い、完了をbool値で返却して知らせる
  ///
  /// ログアウト==アクセストークンの削除だが、サーバー側とローカルデバイス側の両方の削除が必要
  Future<bool> startLogout() async {
    try {
      // 現在保存されているアクセストークンを呼び出す
      final accessToken = ref.read(plainAccessTokenNotifierProvider).value;
      if (accessToken != null) {
        const host = 'qiita.com';
        const tokenEndPoint = '/api/v2/access_tokens';
        // エンドポイントに削除したいトークンの値を'/'を挟んで追加する
        final url = Uri.https(
          host,
          '$tokenEndPoint/$accessToken',
        ).toString();
        final dio = Dio();
        // サーバー側にアクセストークンの削除を要求
        final response = await dio.delete(url);
        // 削除が受理されるとレスポンスで'204'というコードが返却される
        if (response.statusCode == 204) {
          // サーバー側でのアクセストークン削除の実行を確認したのちに
          // ローカルでのアクセストークンの削除を実行する
          await ref.read(plainAccessTokenNotifierProvider.notifier).delete();
          // 全ての削除が完了したら'true'を返す
          return true;
        } else {
          // 204以外のコードが返却された、即ちサーバー側で削除が失敗したので`false`を返す
          return false;
        }
      } else {
        // アクセストークンが`null`つまり削除処理ができてないので`false`を返す
        return false;
      }
    } catch (e) {
      // 何かしらのエラーで処理が完全に終わってないので`false`を返す
      return false;
    }
  }
}

上記をログアウトボタンに実装します。
ログアウト処理の成功をboolで受け取り、成功した場合は画面を閉じてログイン画面に戻ります。

plain_home_page.dart
class _PlainHomePage extends StatelessWidget {
  const _PlainHomePage({
    required this.user,
    required this.controller,
  });

  final User user;
  final PlainHomePageController controller;
  @override
  Widget build(BuildContext context) {
    return Scaffold(

// 省略
              ElevatedButton(
                  onPressed: () async {
                    final result = await controller.startLogout();
                    if (result && context.mounted) {
                      showCustomSnackBar(context, 'ログアウトしました😀');
                      Navigator.pop(context);
                    } else if (!result && context.mounted) {
                      showCustomSnackBar(context, 'ログアウトに失敗しました');
                    }
                  },
                  child: const Text('ログアウト')),
            ],
          ),
        ));
  }
}

終わりに

いかがだったでしょうか?
ログインの実装は様々なサービスで使われており、技術と知識は必須と言っても過言ではありません。
Flutterで実装する際には多くのパッケージを使用して実装するので、混乱しがちです。
まずは実装したい機能を実現するための処理を細分化して列挙し、
それぞれに必要な処理を実現するためのパッケージを選定するとわかりやすいなと思いました。

この記事がFlutter初学者の方の助けになれれば幸いです。

4
1
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
4
1