20
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

モバイルアプリにおけるJWT認証の実装について

Last updated at Posted at 2025-11-24

はじめに

TRIAL&RetailAI Advent Calendar 2025 の14日目の記事です。

昨日は @WANG_YUQUAN さんの 「問題を自分で解決する」から「チームで問題を解決できるようにする」へ—— クリティカルシンキングで再考するエンジニアチームマネジメント という記事でした。
皆様もぜひご一読ください!

概要

この記事では、家計簿アプリ「OsaifuPlus」のバックエンド(OsaifuPlus_be)とフロントエンド(OsaifuPlus_FE)で採用しているJWT(JSON Web Token)を用いた認証フローをまとめました。自身の学習整理も兼ねて、実装の全体像を把握するための備忘録として残しています。 特に、セキュリティと利便性のバランスを考慮したRefresh Token(リフレッシュトークン)のデータベース保存と、フロントエンドにおけるトークンの自動更新の仕組みに焦点を当てます。

1. アーキテクチャ概要

OsaifuPlusでは、ステートレスな認証を実現するためにJWTを使用しています。

  • Access Token: 短命(約1時間)。APIリソースへのアクセスに使用。
  • Refresh Token: 長寿命(30日)。Access Tokenの再発行に使用。DBに保存され、管理されます。

技術スタック

  • Backend: Kotlin / Quarkus
  • Frontend: Flutter
  • Database: PostgreSQL

2. バックエンド実装 (OsaifuPlus_be)

バックエンドでは、QuarkusのJWTサポートを利用してトークンの生成と検証を行っています。

2.1 トークンの生成 (TokenService.kt)

TokenService クラスで2種類のトークンを生成しています。

  • Access Token: 有効期限は短く設定(3600秒 = 1時間)。ユーザーのRoleやEmailなどのClaimを含みます。
  • Refresh Token: 有効期限は長く設定(30日)。
// TokenService.kt (抜粋)
fun generateAccessToken(user: User): String {
    return Jwt.claims()
        .issuer(issuer)
        .subject(user.user_id.toString())
        .expiresAt(Instant.now().plusSeconds(3600)) // 1時間
        .sign()
}

fun generateRefreshAccessToken(user: User): String {
    return Jwt.claims()
        .issuer(issuer)
        .expiresAt(Instant.now().plusSeconds(43200)) // 30日 (コード上の値は例)
        .sign()
}

2.2 Refresh TokenのDB保存 (User.kt, AuthService.kt)

セキュリティを高めるため、発行したRefresh Tokenはデータベースの users テーブルにも保存します。これにより、サーバー側で特定のRefresh Tokenを無効化(Revoke)することが可能になります。

  • Login/Register時: 新しいRefresh Tokenを生成し、DBの refreshToken カラムを更新します。
  • Logout時: DBの refreshToken カラムを null に更新し、トークンを無効化します。
// AuthService.kt - login処理 (抜粋)
val accessToken = tokenService.generateAccessToken(currentUser)
val refreshAccessToken = tokenService.generateRefreshAccessToken(currentUser)

// DBにRefresh Tokenを保存
currentUser.refreshToken = refreshAccessToken
userRepository.persist(currentUser)

2.3 トークンの更新フロー (AuthService.kt)

/auth/refresh エンドポイントでは、以下の検証を行います。

  1. 送られてきたRefresh Token自体の署名検証(改ざんされていないか)。
  2. DBとの照合: DBに保存されているトークンと一致するか確認。

一致した場合、新しいAccess Tokenに加え、新しいRefresh Tokenも発行します(Refresh Token Rotation)。
これにより、ユーザーがアプリを継続的に利用している限り、ログアウトされることなく使い続けることができます(Sliding Session)。

// AuthService.kt - refreshToken処理 (抜粋)
fun refreshToken(request: RefreshRequest): AuthResponse {
    // 1. JWTとしての検証
    jwtValidator.validateToken(request.refreshToken) ?: throw UnauthorizedException("無効なトークン")

    // 2. DB保存値との照合
    val user = userRepository.findByRefreshToken(request.refreshToken)
        ?: throw UnauthorizedException("トークンが無効です")

    // 3. 新しいトークンペアの生成 (Rotation)
    val newAccessToken = tokenService.generateAccessToken(user)
    val newRefreshToken = tokenService.generateRefreshAccessToken(user)

    // 4. DB更新
    user.refreshToken = newRefreshToken
    userRepository.persist(user)

    return AuthResponse(newAccessToken, newRefreshToken, ...)
}

3. フロントエンド実装 (OsaifuPlus_FE)

Flutterアプリ側では、ユーザーにログイン期限切れを意識させないよう、Access Tokenが切れた際に自動的に再取得する仕組みを実装しています。

3.1 安全なトークン保存 (auth_storage.dart)

flutter_secure_storage パッケージを使用し、トークンをデバイスの安全な領域(iOSのKeychain, AndroidのKeystore)に保存します。

// auth_storage.dart
class TokenStorage {
  final storage = FlutterSecureStorage();

  Future<void> saveAccessToken(String token) async {
    await storage.write(key: "accessToken", value: token);
  }
  // ... RefreshTokenも同様に保存
}

3.2 自動リフレッシュロジック (auth_service.dart)

APIリクエストを行う際、ラッパー関数 authRequest を経由させることで、401エラー(Unauthorized)をハンドリングしています。

処理の流れ:

  1. 保存されているAccess TokenでAPIリクエストを実行。
  2. 成功 (200 OK): そのままレスポンスを返す。
  3. 失敗 (401 Unauthorized):
    • Access Tokenの期限切れと判断。
    • 保存されているRefresh Tokenを取り出す。
    • /auth/refresh エンドポイントを叩き、新しいトークンペアを取得。
    • 新しいトークン(Access & Refresh)を保存。
    • 元のAPIリクエストを新しいAccess Tokenで再試行
// auth_service.dart (抜粋)
Future<http.Response> authRequest(
  Future<http.Response> Function(String? accessToken) requestFn,
) async {
  String? accessToken = await _tokenStorage.readAccessToken();

  // 1. 初回リクエスト
  var response = await requestFn(accessToken);

  if (response.statusCode != 401) {
    return response;
  }

  // 2. 401の場合、Refresh Tokenで再取得
  final refreshToken = await _tokenStorage.readRfreshAccessToken();
  
  final refreshRes = await http.post(
    Uri.parse('$BASE_URL/auth/refresh'),
    body: jsonEncode({"refreshToken": refreshToken}),
    // ...
  );

  if (refreshRes.statusCode == 200) {
    // 新しいトークンペアを取得・保存
    final newAccessToken = jsonDecode(refreshRes.body)["data"]["accessToken"];
    final newRefreshToken = jsonDecode(refreshRes.body)["data"]["refreshToken"]; // RefreshTokenも更新
    
    await _tokenStorage.saveAccessToken(newAccessToken);
    await _tokenStorage.saveRfreshAccessToken(newRefreshToken);

    // 3. 新しいトークンでリトライ
    return await requestFn(newAccessToken);
  } else {
    // Refresh Tokenも無効な場合は例外(ログアウト扱いなど)
    throw Exception("ログイン期限切れ");
  }
}

4. まとめ

OsaifuPlusの認証システムは、以下の特徴を持っています。

  1. セキュリティ: Refresh TokenをDB管理することで、万が一の漏洩時や強制ログアウト時にサーバー側で無効化できる。
  2. UX: フロントエンドのインターセプター(authRequest)により、ユーザーはトークンの有効期限を気にせずアプリを利用し続けられる。
  3. 利便性: Refresh Token Rotationにより、アプリを継続利用している間はログイン状態が維持される(「使っている限りログアウトされない」)。

この構成は、モバイルアプリにおけるモダンでセキュアな認証実装の標準的なパターンに準拠しています。

5. ソースコード / リポジトリ

本記事で紹介した「OsaifuPlus」のソースコードはGitHubで公開しています。

正直なところ、まだ学習中の身ということもあり、コードが整理されていない(いわゆる「汚い」)部分も多々あります...(笑)。
これからリファクタリングを進めていく予定ですので、「ここはこうした方がいいよ!」といったご指摘やアドバイスをいただけると、飛び上がるほど嬉しいです!

興味のある方は、ぜひ覗いてみてください。

最後に

次回は、@tou_kenichiro さんによる「Kotlinにおける int型 への変換あれこれ」という記事です。お楽しみに!!

RetailAIとTRIALではエンジニアを募集しています!
興味がある方はご連絡ください!
一緒に働けることを楽しみにしています!

20
2
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
20
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?