Flutterアプリで、マルチログインを実装してみた

仕事でマルチログインの実装をしたので、その実装方法や悩んだことを残しておきます。

## 実現したかったこと

iOS / Android 向けの Flutter アプリで、複数アカウントを切り替えながら利用したい。
アクティブなアカウントは1つのみで、それ以外のアカウントは非アクティブ（＝バックグラウンド）な状態。
また、それぞれのユーザ・セグメント向けのPUSH通知も考慮する。

Twitterアプリが近いイメージだと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8708/3e0f5806-fa06-f66a-ca7f-67eff76f18a5.png)


## バックエンド環境

バックエンド（APIサーバ）側では、アカウントは独立して管理されており、マルチログインを行ってもデータ的には独立しています。

ログイン時にアカウントごとの API token が発行され、 HTTP Header にそれを設定することで認証を行う形です。
かんたんに図にすると、以下のイメージ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8708/d452071e-30bc-9e8a-7967-86b6bfbdaf0d.png)

<!--
@startuml

Client -> Backend: login API
Client <- Backend: token
Client -> Backend: call any API with token
Client <- Backend: response data

@enduml
-->


## アプリ内のデータ構造

### API tokenの管理

API token は [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) に格納しました。

API Client クラスでは、リクエスト発行時にそこから読み込み、 header に設定しました。
（Clientとしては [dio](https://github.com/flutterchina/dio) を利用する前提です。）

これで、アプリの動作途中に API token を変更したい場合（アカウントをスイッチした場合）にも、 `FlutterSecureStorage.write` で差し替えるだけで対応できます。

```dart
class _AuthInterceptor extends InterceptorsWrapper {
  _AuthInterceptor();

  @override
  Future onRequest(RequestOptions options) async {
    const storage = FlutterSecureStorage();
    final token = await storage.read(key: 'api_token');
    if (token != null) {
      options.headers.addAll(<String, String>{'Authorization': token});
    }
    return super.onRequest(options);
  }
}
```

ただ、非アクティブなアカウントの API token も保存しておく必要があります。
こちらは、ログインIDをベースに保存することとしました。
以下のようなイメージです。

```dart
Future<String> getTokenByLoginId(String loginId) async {
  return FlutterSecureStorage().read(key: 'api_token_${loginId}');
}
```

### その他アカウント情報の管理

アカウント一覧を表示するために、ユーザ名やプロフィール画像などのアカウント情報が必要です。
それらは、[Shared preferences plugin](https://pub.dev/packages/shared_preferences) にJSON文字列として格納しました。
以下のようなイメージです。

```json
{
    "ID_0001": {
        "login_id": "ID_0001",
        "user_name": "foo",
        "profile_image_url": "https://example.com/user1.jpg"
    },
    "ID_0002": {
        "login_id": "ID_0002",
        "user_name": "bar",
        "profile_image_url": "https://example.com/user2.jpg"
    }
}
```

これを、Dart objectにマッピングして返却する処理は、以下のようなイメージです。
（[Freezed](https://pub.dev/packages/freezed) と [json_serializable](https://pub.dev/packages/json_serializable) を利用しています）

```dart
Future<Map<String, Account>> loadSessions() async {
  final prefs = await SharedPreferences.getInstance();
  final value = prefs.getString('session_list');
  final decoded = jsonDecode(value) as Map<String, dynamic>;
  return Map<String, Account>.from(
    decoded.map<String, Account>(
      (key, dynamic value) =>
          MapEntry(key, Account.fromJson(value as Map<String, dynamic>)),
    ),
  );
}

@freezed
abstract class Account with _$Account {
  const factory Account({
    String loginId,
    String userName,
    String profileImageUrl,
  }) = _Account;

  factory Account.fromJson(Map<String, dynamic> json) =>
      _$AccountFromJson(json);
}
```

また、アクティブなアカウントの情報を利用する場面は多いので、上記とは別のキーで SharedPreferences に `session` にも保存しました。[^0]

[^0]: あとで考えると、「ログイン中のユーザID」を保存しておき、アクセスしやすいメソッドがあればよかったかもです。

### アカウントの切り替え処理

2つのアカウントがログインしている状態で、アカウントを切り替える場合のデータの動きは以下のイメージです。

まずは、 API token を管理している FlutterSecureStorage のイメージです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8708/f8205589-07dc-ee99-89c0-5bf4536ec0bb.png)


<details><summary>code</summary><div>

```
@startuml

left to right direction

database "FlutterSecureStorage - before" {
  frame "api_token" as 1 {
    [token for ID_0001] as before_1
  }
  frame "api_token_ID_0001" as 2 {
    [token for ID_0001] as before_2
  }
  frame "api_token_ID_0002" as 3 {
    [token for ID_0002] as before_3
  }
}

database "FlutterSecureStorage - after" {
  frame "api_token" as 4 {
    [token for ID_0002] as after_1
  }
  frame "api_token_ID_0001" as 5 {
    [token for ID_0001] as after_2
  }
  frame "api_token_ID_0002" as 6 {
    [token for ID_0002] as after_3
  }
}

before_3 --> after_1

@enduml
```

</div></details>

SharedPreferencesの方も、同じような感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8708/2cfbf4c1-6fdd-243c-9311-c0601bcc81c4.png)


<details><summary>code</summary><div>

```
@startuml

left to right direction

database "SharedPreferences - before" {
  frame "session" as 1 {
    [detail for ID_0001] as before_1
  }
  frame "session_list" as 2 {
    [detail for ID_0001] as before_2
    [detail for ID_0002] as before_3
  }
}

database "SharedPreferences - after" {
  frame "session" as 3 {
    [detail for ID_0002] as after_1
  }
  frame "session_list" as 4 {
    [detail for ID_0001] as after_2
    [detail for ID_0002] as after_3
  }
}

before_3 --> after_1

@enduml
```

</div></details>

## 難しかったところ

### 中途半端なログイン状態の管理

会員登録直後の初回ログイン時には、ユーザ情報を登録してもらったり、規約などに同意してもらう必要がありました。
情報の登録や同意には、他のAPIと同様に API token が必要となります。
2つ目のアカウントで、その途中で中断されてしまった場合の考慮が必要です。

今回は、同意などが完了するまでを仮ログイン状態として、アプリが途中で kill された場合などはセッション情報を破棄することとしました。
そのため、 flutter_secure_storage に `tmp_token` という形で保存することとし、 `_AuthInterceptor` を以下のように変更しました。

```dart
class _AuthInterceptor extends InterceptorsWrapper {
  _AuthInterceptor();

  @override
  Future onRequest(RequestOptions options) async {
    const storage = FlutterSecureStorage();
     // tmp_token を優先して利用する
    final token = await storage.read(key: 'tmp_token') ??
        await storage.read(key: 'api_token');
    if (token != null) {
      options.headers.addAll(<String, String>{'Authorization': token});
    }
    return super.onRequest(options);
  }
}
```

また、アプリ起動時に、 `tmp_token` のクリア処理を追加しました。

```dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await FlutterSecureStorage().delete(key: 'tmp_token');

  return runApp(
    // ...
  );
}
```

### 非アクティブなアカウントに対する更新

アカウントの一覧ページに、それぞれのアカウントでの未読件数を表示する必要がありました。

#### バックエンドAPIから取得可能なデータ

前述の通り、APIを実行するときには、アカウントに応じた API token を送信する必要があります。
そのため、任意の API token を受け取る API Client の実装も必要になりました。[^1]

[^1]: 上で利用した `tmp_token` を利用することも考えましたが、非同期で同時に情報を取得すると競合する可能性があったのでやめました。

いくつかやり方はあると思いますが、 `_AuthInterceptor` をさらに拡張する場合は以下のようなイメージです。
API Client インスタンス自体も、別で作成することとしました。[^2]

[^2]: 今考えると、リクエスト実行時にパラメータで渡したほうがスマートな気もします。

```dart
class _AuthInterceptor extends InterceptorsWrapper {
  _AuthInterceptor({
    this.overrideToken,
  });

  final String overrideToken;

  @override
  Future onRequest(RequestOptions options) async {
    const storage = FlutterSecureStorage();
     // overrideToken -> tmp_token -> 通常のtoken の順に利用する
    final token = overrideToken ??
        await storage.read(key: 'tmp_token') ??
        await storage.read(key: 'api_token');
    if (token != null) {
      options.headers.addAll(<String, String>{'Authorization': token});
    }
    return super.onRequest(options);
  }
}
```

#### Firestoreから取得可能なデータ

バックエンドAPIとは別に、Firestoreから直接参照しているデータもありました。（今回は、チャットの未読件数でした。）
Firebase Authentication と Firestore security rule によって取得を制限しているため、そのままでは非アクティブなアカウントの情報は取得できません。[^3]

[^3]: [firebase_auth](https://pub.dev/packages/firebase_auth) にマルチログインの仕組みがあれば、非アクティブなアカウントについても並行でログイン状態を保持できたのですが、調べた感じだと実装されてなさそうでした。

そのため、 Cloud Functions を作成して、アカウント毎の未読件数を取得することにしました。
API token と login_id のペアを送信して、バックエンドAPIを利用してその正当性を確認、問題なければ情報を返却する形にしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8708/ca26448b-9b0e-e90f-332e-9f80fa95878d.png)

<!--
@startuml

Client -> "Cloud Functions": some pair of API token and login_id

loop each pair
  "Cloud Functions" -> Backend: `me` API with token
  "Cloud Functions" <- Backend: login_id
end

"Cloud Functions" -> "Cloud Firestore": read unread_count
"Cloud Functions" <- "Cloud Firestore": unread_count

Client <- "Cloud Functions": some pair of login_id and unread_count

@enduml
-->


### PUSH通知のハンドリング

#### 個別のアカウント向けのPUSH通知

ログイン中のアカウントにメッセージが来た場合に、PUSH通知で知らせる機能がありました。
非アクティブなアカウントについてもPUSH通知を受信したく、またその通知をタップした場合には、そのアカウントとしてログインした状態でメッセージ画面を表示します。

今回のアプリでは、「アクティブなアカウントは1つ」としていたので、上記の場合には以下のような流れになります。

1. ユーザのタップしたPUSH通知のペイロードを確認。
2. アクティブなアカウントの login_id と、そのペイロードに含まれる login_id を比較して、一致しない。
3. PUSH通知の login_id に、アクティブなアカウントを切り替える。
4. ペイロードに含まれるメッセージのIDより、メッセージ画面を表示する。

#### 特定のセグメント向けのPUSH通知

今回は、セグメント単位のPUSH通知実装もありました。（年代だったり、所属しているグループだったり。）
送信側処理の効率を考え、ペイロードには login_id を含めず、そのセグメント単位で同一のペイロードが送信されます。
そのため、個別のPUSH通知とは別の判断が必要となりました。

そこで、 `Account` を拡張して、セグメントの判定に利用する情報を保持しておくこととしました。[^4]

[^4]: ただし、このやり方だと、事前に想定されているセグメントに対してしか通知できないですね。


```dart
@freezed
abstract class Account with _$Account {
  const factory Account({
    String loginId,
    String userName,
    String profileImageUrl,
    int age,
    List<String> groupIdList,
  }) = _Account;

  factory Account.fromJson(Map<String, dynamic> json) =>
      _$AccountFromJson(json);
}
```

`FirebaseMessaging.configure` の `onResume` と `onLaunch` にて、判定ロジック・画面遷移ロジックを書きました。
判定は以下のイメージです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8708/253caf5e-8c9e-8ee3-5628-7b0d592ccdba.png)


<details><summary>code</summary><div>

```
@startuml

hide empty description

state "Check payload" as state1
state "Check current active user's login_id" as state1_1
state "Find inactive account by login_id" as state1_2
state "Check current active user's segment" as state2
state "Find match account" as state3
state "Switch user" as state4
state "Show target screen" as state5

[*] --> state1
state1 --> state1_1: has login_id
state1_1 --> state5: same login_id
state1_1 --> state1_2
state1_2 --> [*]: no account
state1_2 --> state4: find account
state1 --> state2: has segment data
state2 --> state5: match segument
state2 --> state3
state3 --> [*]: no matched account
state3 --> state4
state4 --> state5
state5 --> [*]

@enduml
```

</div></details>

### バックエンドでのPUSH通知トークンの管理

（ここは、自分では実装していない部分なので、正確なところは理解していないです。）

マルチログインを実装すると、複数のアカウントが同一の device token を持つ必要が出てきます。
RDB上でユニーク制約をつけられず、破棄するためのAPI（ログアウトAPIなど）が正常に実行されなかった場合には、 device token がRDBに残ってしまう可能性があります。

## まとめ

いくつかのアプリでは、あたりまえのように実装されているマルチログインですが、設計・実装してみると想像していた以上に難しかったです。
特に、今回はシングルログインだったアプリに、後付で設計・実装を行ったため、影響範囲の調査なども時間がかかりました。