flutterでOAuthのログイン機能を実装

flutterでOAuthなログイン機能を実装しました。

実装内容とシーケンスは下記の記事を参考にさせていただきました。
https://dev.classmethod.jp/articles/persistent-login-for-mobileapp/

また、バックエンド側は下記の記事を参考にしてください。
https://qiita.com/yufuku/items/b2b2b4d2eb46dba0476c

完成イメージは次になります。
![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/3c9714c9-047b-dcb8-2fb4-aaa08fe1666c.gif)

コード全体は下記になります。
https://github.com/fu-yuta/authentication_frontend/tree/ce529eb931e8892fc003672540e792577abf13b7

#環境
```shell
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 2.5.3, on macOS 11.2.3 20D91 darwin-x64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 31.0.0)
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio (version 2020.3)
[✓] VS Code (version 1.62.3)
[✓] Connected device (3 available)
```

# 画面レイアウト
まず、画面部分について説明します。
今回はログイン画面とコンテンツ画面（apiレスポンスの値を表示するだけ）の２画面を作成しました。

## ログイン画面
![スクリーンショット 2021-12-07 23.44.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/8b2e80e6-931d-a7a6-e22d-3b4e43044e28.png)

ログイン画面はユーザー名とパスワードを入力できるTextFieldとログインとサインアップ、キャンセルの
3つのボタンがある画面になります。

コード全体は下記になります。

```dart:lib/main.dart

void main() {
  runApp(const LoginApp());
}

class LoginApp extends StatelessWidget {
  const LoginApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ログイン画面',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const LoginPage(title: 'Login Page'),
    );
  }
}

class LoginPage extends StatefulWidget {
  const LoginPage({Key? key, required this.title}) : super(key: key);

  final String title;

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

class _LoginPageState extends State<LoginPage> {
  final _userNameTextController = TextEditingController();
  final _passwordNameTextController = TextEditingController();
  final FocusNode _userNamefocusNode = FocusNode();
  final FocusNode _passwordNameFocusNode = FocusNode();
  var _errorMessage = "";

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_errorMessage, style: const TextStyle(color: Colors.red)),
            TextField(
              controller: _userNameTextController,
              decoration: const InputDecoration(
                filled: true,
                labelText: 'Username',
              ),
              focusNode: _userNamefocusNode,
            ),
            const SizedBox(height: 12.0),
            TextField(
              controller: _passwordNameTextController,
              decoration: const InputDecoration(
                filled: true,
                labelText: 'Password',
              ),
              focusNode: _passwordNameFocusNode,
              obscureText: true,
            ),
            ButtonBar(
              children: <Widget>[
                TextButton(
                  child: const Text('CANCEL'),
                  onPressed: () {
                    _userNameTextController.clear();
                    _passwordNameTextController.clear();
                  },
                ),
                ElevatedButton(
                    child: const Text('LOGIN'),
                    onPressed: () {
                      null; //ログイン処理
                    }),
                ElevatedButton(
                    child: const Text('SIGNUP'),
                    onPressed: () {
                      null; //サインアップ処理
                    }),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

```

## コンテンツ画面
サーバー側にgetリクエストを送り返ってきた文字列(Hello World)を表示するための簡単な画面です。
appBarの右側にログアウト用のボタンも設定しています。

![スクリーンショット 2021-12-07 23.56.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/19690ba7-3d12-f9fd-11ae-84cc34f642ff.png)

コード全体は下記になります。

```dart:lib/hello.dart
import 'package:authentication_frontend/requester/requester.dart';
import 'package:flutter/material.dart';

class Hello extends StatefulWidget {
  const Hello({Key? key}) : super(key: key);

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

class _HelloState extends State<Hello> {
  var _message = "";

  @override
  void initState() {
    super.initState();
    // getリクエスト
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Hello"),
        centerTitle: true,
        automaticallyImplyLeading: false,
        actions: [
          TextButton(
            onPressed: () {
              null; //ログアウト
            },
            child: const Text('LOGOUT',
                style: TextStyle(
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                    fontSize: 16.0)),
          ),
        ],
      ),
      body: Padding(
        padding: EdgeInsets.only(top: 100),
        child: Center(
          child: Column(
            children: [Text(_message, style: TextStyle(fontSize: 50.0))],
          ),
        ),
      ),
    );
  }
}
```
# APIリクエスト機能
APIリクエストは下記の通りとなります。
・ログイン
・サインアップ
・コンテンツリクエスト
・トークンリフレッシュ
・ログアウト
また、今回はレスポンスで返ってきたアクセストークン、リフレッシュトークンはflutter_secure_storageを利用してkeychainに保存するようにしています。
参考: https://pub.dev/packages/flutter_secure_storage

各リクエスト処理はRequesterクラスのfunctionとして一つにまとめました。

```dart:lib/requester/requester.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

class Requester {
  String uri = defaultTargetPlatform == TargetPlatform.android
      ? 'http://10.0.2.2:8080/v1/'
      : 'http://127.0.0.1:8080/v1/';

  Map<String, String> headers = {
    "Content-Type": "application/json",
  };
  final storage = new FlutterSecureStorage();

  Requester() {}

~ 以下各リクエスト処理をするfunc ~

```
それぞれの機能について詳細を説明していきます。

## ログイン
ログインはユーザー名とパスワードをpostでリクエストします。
正常レスポンスが返ってきたら、アクセストークン、リフレッシュトークンを保存します。
保存には`storage.write(key, value)`を使用します。

```dart:lib/requester/requester.dart
  Future<void> loginRequester(String name, String password) async {
    var loginUri = uri + "auth/login";

    var request = AuthRequest(name: name, password: password);

    final response = await http.post(Uri.parse(loginUri),
        body: json.encode(request.toJson()), headers: headers);

    if (response.statusCode == 200) {
      Map<String, dynamic> decoded = json.decode(response.body);
      var loginResponse = AuthResponse.fromJson(decoded);
      debugPrint(loginResponse.accessToken);
      await storage.write(key: "accessToken", value: loginResponse.accessToken);
      await storage.write(key: "refreshToken", value: loginResponse.refreshToken);
    } else {
      throw Exception("Login Error");
    }
  }
```
リクエスト、レスポンス用のデータクラスは下記になります。

```dart:lib/requester/requester.dart
class AuthResponse {
  final String accessToken;
  final String refreshToken;

  AuthResponse.fromJson(Map<String, dynamic> json)
      : accessToken = json['access_token'],
        refreshToken = json['refresh_token'];
}

class AuthRequest {
  final String name;
  final String password;

  AuthRequest({
    this.name = "",
    this.password = "",
  });

  Map<String, dynamic> toJson() => {
        'password': password,
        'user_name': name,
      };
}
```

## サインアップ
ログイン機能とほぼ同じです。

```dart:lib/requester/requester.dart
 Future<void> signUpRequester(String name, String password) async {
    var signUpUri = uri + "auth/signup";

    var request = AuthRequest(name: name, password: password);

    final response = await http.post(Uri.parse(signUpUri),
        body: json.encode(request.toJson()), headers: headers);

    if (response.statusCode == 200) {
      Map<String, dynamic> decoded = json.decode(response.body);
      var signUpResponse = AuthResponse.fromJson(decoded);
      await storage.write(
          key: "accessToken", value: signUpResponse.accessToken);
      await storage.write(
          key: "refreshToken", value: signUpResponse.refreshToken);
      debugPrint(signUpResponse.accessToken);
    } else {
      throw Exception("Sign UP Error");
    }
  }
```

 ## コンテンツリクエスト
コンテンツリクエストはgetリクエストで単純な文字列を受け取るだけのリクエストです。

```dart:lib/requester/requester.dart
  Future<String> helloRequester() async {
    var helloUri = uri + "hello";
    var accessToken = await storage.read(key: "accessToken");
    headers["Authorization"] = accessToken ?? "";

    debugPrint("send helloRequester");
    final response = await http.get(Uri.parse(helloUri), headers: headers);

    if (response.statusCode == 200) {
      Map<String, dynamic> decoded = json.decode(response.body);
      var helloResponse = HelloResponse.fromJson(decoded);
      return helloResponse.message;
    } else if (response.statusCode == 401 || response.statusCode == 404) {
      debugPrint("send refreshTokenRequester");
      await refreshTokenRequester();
      var value = await helloRequester();
      return value;
    } else {
      throw Exception("Hello Error");
    }
  }


class HelloResponse {
  final message;

  HelloResponse.fromJson(Map<String, dynamic> json) : message = json['message'];
}
```

リクエスト時にAuthorizationヘッダーに,アプリで保存していたアクセストークンをつけます。
保存していた値を取り出すのは`storage.read(key)`を使用します。
もし、コンテンツのリクエスト時に401,404エラー（accessTokenでの認証エラー）が返ってきたら、後述のアクセストークンのリフレッシュリクエストを送信し、トークンを更新した後に再度リクエストするようにしています（retry回数とか指定した方が良いかも…）。

## トークンリフレッシュ
トークンリフレッシュは、アプリに保存していたリフレッシュトークンを使用してリクエストします。
正常にレスポンスで返ってくる更新されたアクセストークンをアプリに保存し直す処理です。

```dart:lib/requester/requester.dart
  Future<void> refreshTokenRequester() async {
    var refreshTokenUri = uri + "auth/refresh_token";
    var refreshToken = await storage.read(key: "refreshToken");

    var request = RefreshTokenRequest(refreshToken: refreshToken ?? "");

    final response = await http.post(Uri.parse(refreshTokenUri),
        body: json.encode(request.toJson()), headers: headers);

    if (response.statusCode == 200) {
      Map<String, dynamic> decoded = json.decode(response.body);
      var refreshTokenResponse = AuthResponse.fromJson(decoded);
      await storage.write(
          key: "accessToken", value: refreshTokenResponse.accessToken);
      await storage.write(
          key: "refreshToken", value: refreshTokenResponse.refreshToken);
      debugPrint(refreshTokenResponse.accessToken);
    } else {
      throw Exception("Token Refresh Error");
    }
  }

class RefreshTokenRequest {
  final String refreshToken;

  RefreshTokenRequest({
    this.refreshToken = "",
  });

  Map<String, dynamic> toJson() => {
        'refresh_token': refreshToken,
      };
}
```

## ログアウト
アクセストークンを使ってログアウトのリクエストを送っています。

```dart:lib/requester/requester.dart
  Future<void> logoutRequester() async {
    var logoutUri = uri + "auth/logout";
    var accessToken = await storage.read(key: "accessToken");
    headers["Authorization"] = accessToken ?? "";

    final response = await http.post(Uri.parse(logoutUri), headers: headers);

    if (response.statusCode == 201) {
      await storage.delete(key: "accessToken");
    }
  }
```

# 画面での処理
画面上で各リクエスト処理を実装していきます。

## ログイン
ログインボタンが押された時(OnPressed)に、先程のloginRequesterを呼び出す処理を追加します。

```dart:lib/main.dart
ElevatedButton(
    child: const Text('LOGIN'),
    onPressed: () {
        Requester()
            .loginRequester(_userNameTextController.text,
                _passwordNameTextController.text)
            .then((_) {
        setState(() {
            _errorMessage = "";
        });
        Navigator.push(context,
            MaterialPageRoute(builder: (context) => Hello()));
        }).onError((error, stackTrace) {
        debugPrint(error.toString());
        _userNameTextController.clear();
        _passwordNameTextController.clear();
        setState(() {
            _errorMessage = "ログインに失敗しました。ユーザー名かパスワードが間違っています。";
        });
        });
    }),
``` 
ボタン押下時に`_userNameTextController`と`_passwordNameTextController`の値をリクエスタに渡します。
リクエスタの処理が成功（.thenブロック）したら次のHallo画面に遷移します。
エラー(.onErrorブロック)の時は`_errorMessage`にエラー文言を入れて画面に表示しています。

## サインアップ
ログイン処理とほぼ同じです。

```dart:lib/main.dart
ElevatedButton(
    child: const Text('SIGNUP'),
    onPressed: () {
        Requester()
            .signUpRequester(_userNameTextController.text,
                _passwordNameTextController.text)
            .then((_) {
        setState(() {
            _errorMessage = "";
        });
        Navigator.push(context,
            MaterialPageRoute(builder: (context) => Hello()));
        }).onError((error, stackTrace) {
        debugPrint(error.toString());
        _userNameTextController.clear();
        _passwordNameTextController.clear();
        setState(() {
            _errorMessage = "ユーザーの作成に失敗しました。既に登録済みのユーザーです。";
        });
        });
    })
```

## コンテンツの表示
Hallo画面が表示されるタイミングでコンテンツのリクエストを送ります。

```dart:lib/hello.dart
class _HelloState extends State<Hello> {
  var _message = "";

  @override
  void initState() {
    super.initState();
    Requester().helloRequester().then((value) {
      setState(() {
        _message = value;
      });
    }).onError((error, stackTrace) {
      showDialog(
          context: context,
          builder: (_) {
            return AlertDialog(
              title: Text("認証に失敗しました。再ログインをお願いします。"),
              actions: [
                TextButton(
                    onPressed: () => Navigator.pop(context), child: Text("OK")),
              ],
            );
          }).then((_) {
        Navigator.pop(context);
      });
    });
  }
```
処理の成功時(.thenブロック)ではレスポンスで返ってきた文字列を表示するための変数に入れています。
エラー時(.onErrorブロック)では認証に失敗した旨のメッセージをダイアログ表示し、ログイン画面に戻しています。
コンテンツのリクエストではサーバー側でアクセストークンの検証が行われているのと、helloRequesterの中でリフレッシュトークンによるアクセストークンの更新リクエストも行っているので、両方が失敗したということは認証切れ扱いとしています。
![ezgif.com-gif-maker (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/09335421-bed6-a05d-7dc2-88ed3e928f3f.gif)

## ログアウト
最後にログアウト処理でになります。

```dart:lib/hello.dart
Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Hello"),
        centerTitle: true,
        automaticallyImplyLeading: false,
        actions: [
          TextButton(
            onPressed: () {
              Requester().logoutRequester().then((_) {
                Navigator.pop(context);
              });
            },
            child: const Text('LOGOUT',
                style: TextStyle(
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                    fontSize: 16.0)),
          ),
        ],
      ),
```
AppBarの右側にLOGOUTボタンを用意し、押されたら`logoutRequester`を呼んでログイン画面に戻しています。
AppBarの右側にWidgetを追加するには`actions`を使います。
また、左側の戻るボタンを消すには`automaticallyImplyLeading: false`に設定します。

これで実装は以上となります。

# おわりに
ログインページでの認証と認証できたユーザーだけ、コンテンツを表示するページを作りました。
今回は、どのユーザーでも共通のコンテンツを表示していましたが、ユーザー情報元にユーザー毎にコンテンツを分けることももちろん可能です。

また、テストしやすいという理由でログインページ→コンテンツページという画面遷移の流れでしたが、これだとアプリを閉じるたびに再ログインをしないといけないで、コンテンツページ（認証切れの場合）→ ログインページという流れが良いと思い修正しています。（https://github.com/fu-yuta/authentication_frontend/tree/content_main）