👣

MVVMアーキテクチャでの認証(ログイン)フロー実装 - アクセストークン・リフレッシュトークン

に公開

MVVMアーキテクチャを用いて、認証(ログイン)フローを実装します。アクセストークンやリフレッシュトークンが絡む認証(ログイン)フローをどのように実装すれば良いかの参考になれば嬉しいです。

APIについて

APIは DummyJson を利用します。
DummyJSONは、商品、カート、ユーザー、ToDoリスト、あるいはJSON形式のダミーデータが必要なあらゆる種類のフロントエンドプロジェクトで利用できます。

https://dummyjson.com/

今回は、認証(ログイン)に関するAPIを利用したいので、Auth - Docs のドキュメントをもとに /auth/login/auth/refreshのエンドポイントを使用します。

/auth/login
  • リクエスト
fetch('https://dummyjson.com/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    username: 'emilys',
    password: 'emilyspass',
    // 任意。指定しない場合はデフォルトで60。
    expiresInMins: 30,
  }),
  // リクエストにクッキー(例:accessToken)を含める
  credentials: 'include'
})
.then(res => res.json())
.then(console.log);
  • レスポンス
{
  "id": 1,
  "username": "emilys",
  "email": "emily.johnson@x.dummyjson.com",
  "firstName": "Emily",
  "lastName": "Johnson",
  "gender": "female",
  "image": "https://dummyjson.com/icon/emilys/128",
  // JWTアクセス・トークン(後方互換用)。レスポンスおよびクッキーに含まれる
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  // リフレッシュ・トークン。レスポンスおよびクッキーに含まれる
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
/auth/refresh
  • リクエスト
fetch('https://dummyjson.com/auth/refresh', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    // 任意。指定しない場合はサーバーがクッキーを使用する
    refreshToken: '/* YOUR_REFRESH_TOKEN_HERE */',
    // 任意(アクセストークン用)。指定しない場合はデフォルトで60
    expiresInMins: 30,
  }),
  // リクエストにクッキー(例:accessToken)を含める
  credentials: 'include'
})
.then(res => res.json())
.then(console.log);
  • レスポンス
{
  // 新しいアクセストークン(レスポンスおよびクッキーの両方に含まれる)
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  // 新しいリフレッシュトークン(レスポンスおよびクッキーの両方に含まれる)
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

アーキテクチャ概要

Flutter公式で紹介されているMVVMアーキテクチャで実装しています。
各層の責務を明確に分離することで、テストが容易で拡張性の高い設計が可能になります。

レイヤー構造

  1. View: ユーザーの操作を受け付け、ViewModel の状態を表示する。
  2. ViewModel: 画面ごとの状態(Loading, Success, Error)を管理し、UseCase を呼び出す。
  3. UseCase: ビジネスルールを定義。複数の Repository を跨ぐ手順や、アプリ全体の共通ロジック(ログアウトなど)を集約する。
  4. Repository: API(Remote)とストレージ(Local)のデータを統合し、抽象化したデータを上位層に提供する。
  5. Service:

https://zenn.dev/humanhacker/articles/bbfb97a0d146bc

認証(ログイン)フロー

① 初回ログイン時

  1. 認証リクエスト: ユーザーがID/パスワードを入力し、ログインAPIを実行。
  2. トークン取得: レスポンスから accessTokenrefreshToken を取得。
  3. セキュア保存: flutter_secure_storage 等の安全なストレージに両方のトークンを保存。
  4. 状態更新と遷移: アプリの状態を「ログイン済み」に更新し、ホーム画面へ遷移。

② 次回アプリ起動時(自動ログイン)

  1. トークン読み込み: アプリ起動時にストレージから accessToken を読み込む。
  2. 有効性確認: トークンが存在すれば、有効性を確認しホーム画面へ。存在しない、または無効であればログイン画面を表示。

③ API 通信時(インターセプターの利用)

通信ライブラリのインターセプター機能を活用し、トークンの運用を自動化します。

  1. Request時: ヘッダーに自動で Authorization: Bearer <accessToken> を付与。
  2. Response時 (401 Unauthorized): トークン期限切れを検知した場合:
    • 通信を一時停止。
    • 保存してある refreshToken を使って「トークン更新API」を実行。
    • 新しい accessToken を受け取り、ストレージを更新。
    • 失敗した元のリクエストを、新しいトークンで自動的に再実行。
  3. 更新失敗時: refreshToken も期限切れなら、強制ログアウトしてログイン画面へ戻す。

認証(ログイン)処理の実装フロー

ログインボタン押下から、自動遷移が行われるまでの処理の流れを可視化します。

  1. View (login_screen.dart): ユーザーがログインボタンを押下すると、ViewModelの login メソッドを呼び出し、ユーザー名とパスワードを渡します。
  2. ViewModel (login_screen_view_model.dart): AuthUseCaselogin を実行します。ログイン成功時は GoRouter のリダイレクト機能に遷移を任せる設計にしており、View側で明示的な画面遷移コードは書いていません。
  3. UseCase (auth_use_case.dart): AuthRepository へログイン処理を委譲するビジネスロジックの橋渡し役です。
  4. Repository (auth_repository.dart): ChangeNotifier を継承しており、アプリ全体のログイン状態を保持します。AuthApiService を使ってリモートログインを行い、成功した場合は AuthSecureStorageService を使ってトークンをセキュアに保存します。
  5. Service: API通信(auth_api_service_impl.dart)とセキュア保存(auth_secure_storage_service_impl.dart)の具体的な実装を担います。ApiClient には、401エラー時のトークンリフレッシュロジックも組み込んでいます。
  6. 状態通知とリダイレクト: 保存完了後、Repositoryが notifyListeners() を呼び出すことで、リッスンしている GoRouterrouter.dart)が状態変化を検知します。redirect 処理が走り、最新のログイン状態に基づいて自動的にホーム画面へ遷移します。

挙動

① 初回ログイン時

ログイン画面にてログインボタンを押下してログイン処理を実行し、ログインに成功したらホーム画面へ遷移します。

② 次回アプリ起動時(自動ログイン)

初回ログイン成功時にアクセストークンがローカルに保存されているため、アプリ起動とともにアクセストークンを読み込みます。アクセストークンが空でなければログイン済みとみなし、ログイン画面をスキップして自動でホーム画面に遷移します。

具体的な実装コード(実践)

MVVMアーキテクチャの階層順に、実際のコードを解説します。

View (UI)

役割と責務:

  • ユーザーインターフェースの構築: ログインボタンや入力フィールドを配置。
  • イベントの検知: ユーザーの操作(ボタン押下など)を検知。
  • ViewModelへの委譲: 検知したイベントを ViewModel のメソッド呼び出しとして伝達。
// lib/ui/login/view/login_screen.dart

class LoginScreen extends StatelessWidget {
  /// コンストラクタ
  const LoginScreen({super.key});

  /// パス
  static const path = '/login';

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      appBar: CoreAppBar(title: 'LoginScreen'),
      body: SafeArea(child: _Body()),
    );
  }
}

class _Body extends HookConsumerWidget {
  const _Body();
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier = ref.watch(loginScreenProvider.notifier);
    return Center(
      child: ElevatedButton(
        onPressed: () async {
          // 本来はTextField等から値を取得する
          await notifier.login(
            username: 'emilys',
            password: 'emilyspass',
          );
        },
        child: const Text('Login', style: AppTextStyle.t26),
      ),
    );
  }
}

ViewModel (UIロジック)

役割と責務:

  • 画面状態の保持: ログイン中か、エラーが発生したか等の画面固有の状態(State)を管理。
  • UIロジックの実行: ボタン押下後の処理フローを制御。
  • UseCaseの呼び出し: ビジネスロジックが必要な場合、UseCaseに処理を依頼。
  • 例外ハンドリングとフィードバック: 下位レイヤーからのエラーを受け取り、UIへ通知するための準備。
// lib/ui/login/view_model/login_screen_view_model.dart

/// LoginScreenViewModelのプロバイダ
final AsyncNotifierProvider<LoginScreenViewModel, LoginScreenState>
loginScreenProvider =
    AsyncNotifierProvider.autoDispose<LoginScreenViewModel, LoginScreenState>(
      LoginScreenViewModel.new,
    );
    
/// ログイン画面のViewModel
class LoginScreenViewModel extends AsyncNotifier<LoginScreenState> {
  @override
  Future<LoginScreenState> build() async {
    return const LoginScreenState(
      isLoggedIn: false,
    );
  }

  /// ログイン
  Future<void> login({
    required String username,
    required String password,
  }) async {
    final authUseCase = ref.read(authUseCaseProvider);
    final result = await authUseCase.login(
      username: username,
      password: password,
    );

    switch (result) {
      case SuccessResult<void>():
        // ログイン状態は AuthRepository の通知によって GoRouter が検知し、
        // 自動的にホーム画面へリダイレクトされるため、ここで state を変更する必要はないが、
        // 必要に応じて UI のフィードバック処理を行う。
        return;
      case FailureResult<void>():
        throw Exception();
    }
  }
}

UseCase (ビジネスロジック)

役割と責務:

  • ドメイン知識の集約: 「ログインとは何か」「ログアウト時に何をすべきか」というアプリケーションの関心事を定義。
  • 複数のRepositoryの調整: 必要に応じて、AuthRepositoryとUserRepositoryなどを組み合わせて一つの操作を完結させる。
  • ViewModelに対する抽象化: 具体的な保存先(APIかDBか)を ViewModel から隠蔽。
// lib/domain/use_cases/auth/auth_use_case.dart

/// AuthUseCaseのプロバイダ
final authUseCaseProvider = Provider<AuthUseCase>((ref) {
  return AuthUseCase(ref: ref);
});

/// 認証関連のビジネスロジックを担当するUseCase
class AuthUseCase {
  /// コンストラクタ
  AuthUseCase({required this.ref});

  /// Provider参照用のref
  final Ref ref;

  /// ログイン済みかどうかを確認する
  Future<bool> checkIsLoggedIn() async {
    return ref.read(authRepositoryProvider).isLoggedIn;
  }

  /// ログインを実行する
  Future<Result<void>> login({
    required String username,
    required String password,
  }) async {
    return ref.read(authRepositoryProvider).login(
          username: username,
          password: password,
        );
  }

  /// ログアウトを実行する
  ///
  /// 認証情報のクリア、キャッシュの破棄など、ログアウト時に必要な
  /// 一連の手順をここに集約する。
  Future<void> logout() async {
    await ref.read(authRepositoryProvider).logout();
    // 他のリポジトリのクリア処理などが必要になればここに追加する
  }
}

Repository (データ集約・状態通知)

役割と責務:

  • データソースの隠蔽: API(Remote)と Storage(Local)のどちらからデータを取得するかを決定。
  • ログイン状態(真実のソース)の管理: アプリ全体で共有されるべき「ログイン済みか」という状態を保持。
  • 変更の通知: ChangeNotifier を通じて、状態が変化したことをアプリ全体(特に Router)に知らせる。
  • 永続化と同期: APIで取得したトークンを即座にストレージに保存する等の整合性を維持。
// lib/data/repositories/auth/auth_repository.dart

/// AuthRepositoryのプロバイダ
final authRepositoryProvider = Provider<AuthRepository>(
  (ref) => AuthRepository(
    authApiService: ref.read(authApiServiceImplProvider),
    authSecureStorageService: ref.read(authSecureStorageServiceImplProvider),
  ),
);

/// リポジトリクラス
class AuthRepository extends ChangeNotifier {
  /// コンストラクタ
  AuthRepository({
    required this.authApiService,
    required this.authSecureStorageService,
  });

  /// Authに関連するAPI通信を抽象化したサービスインターフェース。
  final AuthApiService authApiService;

  /// Authに関連するデータをセキュアに永続化するサービスインターフェース。
  final AuthSecureStorageService authSecureStorageService;

  /// ログイン済みかどうか
  bool? _isLoggedIn;

  /// ストレージからロード済みかどうか
  bool _isLoaded = false;

  /// ログイン済みかどうか
  Future<bool> get isLoggedIn async {
    // キャッシュがあり、かつロード済みならそのまま返す
    if (_isLoggedIn != null && _isLoaded) {
      return _isLoggedIn!;
    }
    // ストレージからのロード
    await _ensureLoaded();
    return _isLoggedIn ?? false;
  }

  /// ストレージからのロードを保証する
  Future<void> _ensureLoaded() async {
    if (_isLoaded) {
      return;
    }
    // 初期化(非同期)
    await authSecureStorageService.init();

    final accessToken = authSecureStorageService.getAccessToken();
    _isLoggedIn = accessToken.isNotEmpty;
    _isLoaded = true;
  }

  /// ログイン
  Future<Result<void>> login({
    required String username,
    required String password,
  }) async {
    try {
      final result = await authApiService.login(
        username: username,
        password: password,
      );

      switch (result) {
        case SuccessResult<LoginDto>():
          final loginDto = result.value;
          final accessToken = loginDto.accessToken ?? '';
          final refreshToken = loginDto.refreshToken ?? '';

          await authSecureStorageService.setAccessToken(accessToken);
          await authSecureStorageService.setRefreshToken(refreshToken);

          _isLoggedIn = accessToken.isNotEmpty;
          _isLoaded = true;

          // ログイン状態の変更通知
          notifyListeners();
          return const SuccessResult(null);
        case FailureResult<LoginDto>():
          return FailureResult(result.error);
      }
    } on Exception catch (error) {
      return FailureResult(error);
    }
  }

  /// ログアウト
  Future<void> logout() async {
    await authSecureStorageService.clearAuthData();
    _isLoggedIn = false;
    notifyListeners();
  }
}

Service: Remote API (外部通信の具体化)

役割と責務:

  • エンドポイントの定義: 通信先のパス(auth/login 等)を管理。
  • リクエストパラメータの構築: APIが要求するデータ形式(JSON等)に整形。
  • レスポンスのデシリアライズ: rawデータ(Map)を Dart の DTO オブジェクトに変換。
  • 通信失敗の具体化: 400系エラー等をアプリケーションで扱いやすい形式(Result型)にラップ。
// lib/data/services/remote/api/auth/auth_api_service_impl.dart

/// AuthApiServiceImplのプロバイダ
final authApiServiceImplProvider = Provider<AuthApiServiceImpl>(
  (ref) => AuthApiServiceImpl(apiClient: ref.read(apiClientProvider)),
);

/// APIサービス実装クラス
class AuthApiServiceImpl implements AuthApiService {
  /// コンストラクタ
  AuthApiServiceImpl({required this.apiClient});

  /// ApiClient
  final ApiClient apiClient;

  /// エンドポイント
  static const endpoint = 'auth';

  @override
  Future<Result<LoginDto>> login({
    required String username,
    required String password,
  }) async {
    try {
      final response = await apiClient.post(
        endpoint: '$endpoint/login',
        body: {
          'username': username,
          'password': password,
          'expiresInMins': 30,
        },
      );
      final loginDto = LoginDto.fromJson(response);
      return SuccessResult(loginDto);
    } on ApiClientException catch (error) {
      return FailureResult(error);
    } on Exception catch (error) {
      return FailureResult(error);
    }
  }
}

Infra: ApiClient & SecureStorage (共通基盤)

役割と責務:

  • 共通通信ロジック(ApiClient):
    • すべてのリクエストに対する Authorization ヘッダーの自動付与。
    • 401エラー(期限切れ)を全APIで一括監視。
    • トークンリフレッシュの実行と、元のリクエストへのリトライ。
    • 二重リフレッシュ防止(Completerを用いたキューイング)。
  • 具体的永続化(SecureStorage):
    • flutter_secure_storage 等のプラグインを用いた低レイヤーの保存。
    • キー名の管理(access_token 等)。
    • メモリキャッシュへの展開(init() によるロード)。

ApiClient (インターセプター)

// lib/data/services/remote/api/api_client.dart (抜粋)

Future<Map<String, dynamic>> _safeApiCall({ ... }) async {
  // 1. Request時: アクセストークンの自動付与
  final accessToken = authSecureStorageService.getAccessToken();
  final authHeaders = {
    if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
    ...?headers,
  };

  // 2. Response時 (401検知とトークン更新)
  if (response.statusCode == 401 &&
      endpoint != 'auth/refresh' &&
      endpoint != 'auth/login') {
    logger.w('401 Unauthorizedを検知。トークンの更新を試みます。');
    final refreshSuccess = await _handleTokenRefresh();
    if (!refreshSuccess) {
      // トークン更新失敗。
      throw UnauthorizedException('Session expired');
    }
    // トークン更新成功。新しいトークンでリトライ。
    return _safeApiCall(...);
  }
  // ...
}

/// トークンの更新処理
Future<bool> _handleTokenRefresh() async {
  if (_isRefreshing) {
    // 既に更新中の場合は、完了を待つ
    final completer = Completer<void>();
    _refreshCompleters.add(completer);
    await completer.future;
    return true;
  }

  _isRefreshing = true;
  try {
    // ロード済みであることを保証するためにinit()を呼ぶ
    await authSecureStorageService.init();

    final refreshToken = authSecureStorageService.getRefreshToken();
    if (refreshToken.isEmpty) {
      return false;
    }

    final refreshUri = _buildUri('auth/refresh');
    final response = await _client.post(
      refreshUri,
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'refreshToken': refreshToken,
        'expiresInMins': 30,
      }),
    );

    if (response.statusCode == 200) {
      final data = json.decode(response.body) as Map<String, dynamic>;
      final newAccessToken = data['accessToken'] as String;
      final newRefreshToken = data['refreshToken'] as String;

      await authSecureStorageService.setAccessToken(newAccessToken);
      await authSecureStorageService.setRefreshToken(newRefreshToken);

      logger.i('トークンの更新に成功しました。');
      return true;
    } else {
      logger.e('トークンの更新に失敗しました。ステータスコード: ${response.statusCode}');
      // セッション切れとしてデータをクリア
      await authSecureStorageService.clearAuthData();
      return false;
    }
  } on Exception catch (e) {
    logger.e('トークンの更新中にエラーが発生しました: $e');
    return false;
  } finally {
    _isRefreshing = false;
    // 待機していたリクエストを再開させる
    for (final completer in _refreshCompleters) {
      completer.complete();
    }
    _refreshCompleters.clear();
  }
}

Securestorage(ローカル保存)

// lib/data/services/local/secure_storage/auth/auth_secure_storage_service_impl.dart

/// AuthSecureStorageServiceImplのプロバイダ
final authSecureStorageServiceImplProvider = Provider<AuthSecureStorageService>(
  (ref) => AuthSecureStorageServiceImpl(
    secureStorage: ref.read(secureStorageServiceImplProvider),
  ),
);

/// [AuthSecureStorageService] の実装クラス。
/// [SecureStorageService] を利用して、認証情報をセキュアに永続化する。
class AuthSecureStorageServiceImpl implements AuthSecureStorageService {
  /// コンストラクタ
  AuthSecureStorageServiceImpl({required this.secureStorage});

  /// セキュアなデータ保存のためのサービス
  final SecureStorageService secureStorage;

  static const String _accessTokenKey = 'access_token';
  static const String _refreshTokenKey = 'refresh_token';

  String _accessToken = '';
  String _refreshToken = '';

  /// 初期化(ストレージからメモリへロード)
  @override
  Future<void> init() async {
    _accessToken = await secureStorage.read(_accessTokenKey) ?? '';
    _refreshToken = await secureStorage.read(_refreshTokenKey) ?? '';
  }

  @override
  String getAccessToken() => _accessToken;

  @override
  Future<void> setAccessToken(String token) async {
    _accessToken = token;
    await secureStorage.write(_accessTokenKey, token);
  }

  @override
  String getRefreshToken() => _refreshToken;

  @override
  Future<void> setRefreshToken(String token) async {
    _refreshToken = token;
    await secureStorage.write(_refreshTokenKey, token);
  }

  @override
  Future<bool> clearAuthData() async {
    _accessToken = '';
    _refreshToken = '';
    await secureStorage.delete(_accessTokenKey);
    await secureStorage.delete(_refreshTokenKey);
    return true;
  }
}

GoRouter (画面遷移制御)

役割と責務:

  • 遷移のガード: ログインしていないユーザーがホーム画面に入るのを防ぐ(リダイレクト)。
  • ナビゲーションスタックの管理: アプリケーションの画面ツリーを定義。
  • 外部状態の監視: refreshListenableAuthRepository を指定し、ログイン状態の変化に同期して画面を強制的に切り替える。
// lib/router.dart

final routerProvider = Provider<GoRouter>(
  (ref) {
    return GoRouter(
      initialLocation: LoginScreen.path,
      routes: [
        GoRoute(
          path: LoginScreen.path,
          builder: (context, state) => const LoginScreen(),
        ),
        GoRoute(
          path: HomeScreen.path,
          builder: (context, state) => const HomeScreen(),
        ),
      ],
      refreshListenable: ref.read(authRepositoryProvider),
      redirect: (context, state) async {
        final loggedIn = await ref.read(authUseCaseProvider).checkIsLoggedIn();
        final loggingIn = state.matchedLocation == LoginScreen.path;
        // ユーザーがログインしていない場合は、ログイン画面へ
        if (!loggedIn) {
          return LoginScreen.path;
        }
        // ログイン済みのユーザーがまだログイン画面にいる場合は、ホーム画面へ
        if (loggingIn) {
          return HomeScreen.path;
        }
        return null;
      },
      // エラーハンドリング (オプション)
      errorBuilder: (context, state) => Scaffold(
        appBar: AppBar(title: const Text('エラー')),
        body: Center(child: Text('エラー: ${state.error}')),
      ),
    );
  },
);

実装の勘所と設計ポイント

型設計(Result型 vs void)

  • login (Result型): 失敗理由(パスワード間違い、通信エラー)をユーザーに伝える必要があるため、詳細なエラー情報を返します。Result型 によって、成功の場合は SuccessResult、失敗の場合は FailureResult を返します。
  • logout (void型): ログアウトは「強制的な状態リセット」です。通信が失敗しても、クライアント側の情報を消してログイン画面に戻すことが最優先(UXの逃げ道を塞がない)であるため、例外を内部で飲み込み、常に成功として扱う設計が一般的です。

データの安全性(Secure Storage)

  • ポイント: アクセストークンやリフレッシュトークンは、平文で保存される SharedPreferences ではなく、暗号化される FlutterSecureStorage を使用します。
  • 理由: 端末が盗難された際や、悪意のあるアプリによるトークン奪取のリスクを最小限に抑えるためです。

UseCase 層の必要性

  • ポイント: ログイン・ログアウトといった「ビジネス手順」は UseCase に集約します。
  • 理由:
    • DRYの徹底: ログアウト処理(トークン削除 + キャッシュクリア + アナリティクス送信)が複数の画面から呼ばれても、修正箇所を 1 箇所に限定できます。
    • 一貫性: 開発者が「どこにロジックを書くべきか」迷わず、プロジェクト全体の予測可能性が高まります。

401リフレッシュの「多重実行防止」

  • ポイント: ApiClient 内で Completer やフラグを用い、複数の API が同時に 401 を返しても、リフレッシュ API は 1 回だけ叩くように制御します(Mutex ロジック)。
  • 理由: 無駄な通信を防ぎ、サーバー側のリフレッシュトークン回転(Rotation)仕様による意図しないログアウトを回避するためです。

まとめ

このアーキテクチャは、データの流れは単方向・状態の通知はReactiveという原則に基づいています。
インターセプターにより通信の詳細は隠蔽され、Routerにより遷移の整合性が保証されるため、開発者はビジネスロジックに集中できる堅牢な構成となっています。

Discussion