JWT認証において、アクセストークンの有効期限を短く設定することはセキュリティ上重要です。しかし、頻繁な再ログインはユーザー体験を損ないます。この課題を解決するのがリフレッシュトークンです。本記事では、Spring Security 6.4でリフレッシュトークンを安全に実装する方法を解説します。トークンローテーション、保存戦略、ブラックリストによる無効化まで、本番運用を見据えた設計パターンを網羅します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Security 6.4.x
jjwt 0.13.0
Spring Data JPA 3.4.x
Redis(オプション) 7.x
ビルドツール Maven または Gradle

事前に以下の知識があると理解がスムーズです。

  • JWTの構造と検証の仕組み
  • Spring Securityの認証フロー
  • Spring Data JPAの基本的な使い方

アクセストークンとリフレッシュトークンの役割分担

JWT認証では、2種類のトークンを使い分けることでセキュリティと利便性を両立します。

トークンの特性比較

特性 アクセストークン リフレッシュトークン
目的 APIアクセスの認可 アクセストークンの更新
有効期限 短い(15分〜1時間) 長い(7日〜30日)
保存場所(クライアント) メモリ/HTTPOnly Cookie HTTPOnly Cookie
サーバー保存 不要(ステートレス) 必要(ステートフル)
漏洩時のリスク 中(短期間で無効化) 高(長期間有効)

なぜ2種類のトークンが必要なのか

アクセストークンの有効期限を短くすると、漏洩した場合の被害を限定できます。しかし、毎回ログインを要求するとユーザー体験が悪化します。リフレッシュトークンは、ユーザーの再認証なしにアクセストークンを更新する仕組みを提供します。

sequenceDiagram
    participant Client as クライアント
    participant API as APIサーバー
    participant Auth as 認証サーバー
    
    Client->>Auth: ログイン(ID/パスワード)
    Auth-->>Client: アクセストークン + リフレッシュトークン
    
    loop APIアクセス
        Client->>API: リクエスト + アクセストークン
        API-->>Client: レスポンス
    end
    
    Note over Client,API: アクセストークン期限切れ
    
    Client->>API: リクエスト + 期限切れトークン
    API-->>Client: 401 Unauthorized
    
    Client->>Auth: リフレッシュトークン
    Auth-->>Client: 新しいアクセストークン
    
    Client->>API: リクエスト + 新しいアクセストークン
    API-->>Client: レスポンス

セキュリティ上の考慮事項

リフレッシュトークンは長期間有効なため、以下の対策が必須です。

  1. サーバー側での保存: リフレッシュトークンをDBまたはRedisに保存し、無効化を可能にする
  2. トークンローテーション: リフレッシュ時に新しいトークンを発行し、古いトークンを無効化する
  3. HTTPOnly Cookie: XSS攻撃からトークンを保護する
  4. 使用回数制限: 同一トークンの使用回数を制限する

プロジェクト構成と依存関係

Maven依存関係

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<dependencies>
    <!-- Spring Boot Starters -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    
    <!-- JWT -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.13.0</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.13.0</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.13.0</version>
        <scope>runtime</scope>
    </dependency>
    
    <!-- Database -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    
    <!-- Redis(オプション) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
</dependencies>

アプリケーション設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# application.yml
jwt:
  secret-key: ${JWT_SECRET_KEY:your-256-bit-secret-key-for-hs256-algorithm-must-be-at-least-32-bytes-long}
  access-token-expiration: 900000      # 15分(ミリ秒)
  refresh-token-expiration: 604800000  # 7日間(ミリ秒)

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
  # Redis設定(オプション)
  data:
    redis:
      host: localhost
      port: 6379

プロジェクト構造

src/main/java/com/example/security/
├── config/
│   ├── JwtProperties.java
│   └── SecurityConfig.java
├── controller/
│   └── AuthController.java
├── dto/
│   ├── AuthRequest.java
│   ├── AuthResponse.java
│   └── RefreshTokenRequest.java
├── entity/
│   ├── RefreshToken.java
│   └── User.java
├── exception/
│   ├── TokenException.java
│   └── GlobalExceptionHandler.java
├── repository/
│   ├── RefreshTokenRepository.java
│   └── UserRepository.java
├── service/
│   ├── AuthService.java
│   ├── JwtService.java
│   └── RefreshTokenService.java
└── SecurityApplication.java

リフレッシュトークンエンティティの設計

リフレッシュトークンをデータベースで管理するためのエンティティを設計します。

RefreshTokenエンティティ

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
package com.example.security.entity;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "refresh_tokens", indexes = {
    @Index(name = "idx_token", columnList = "token"),
    @Index(name = "idx_user_id", columnList = "user_id"),
    @Index(name = "idx_expiry_date", columnList = "expiryDate")
})
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 500)
    private String token;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(nullable = false)
    private Instant expiryDate;

    @Column(nullable = false)
    private Instant createdAt;

    @Column
    private String userAgent;

    @Column
    private String ipAddress;

    @Column(nullable = false)
    private boolean revoked = false;

    @Column
    private Instant revokedAt;

    @Column
    private String revokedReason;

    // 使用回数を追跡(ローテーション検証用)
    @Column(nullable = false)
    private int usageCount = 0;

    @PrePersist
    protected void onCreate() {
        this.createdAt = Instant.now();
    }

    // Getters and Setters
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public User getUser() {
        return user;
    }

    public void setUser(User user) {
        this.user = user;
    }

    public Instant getExpiryDate() {
        return expiryDate;
    }

    public void setExpiryDate(Instant expiryDate) {
        this.expiryDate = expiryDate;
    }

    public Instant getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(Instant createdAt) {
        this.createdAt = createdAt;
    }

    public String getUserAgent() {
        return userAgent;
    }

    public void setUserAgent(String userAgent) {
        this.userAgent = userAgent;
    }

    public String getIpAddress() {
        return ipAddress;
    }

    public void setIpAddress(String ipAddress) {
        this.ipAddress = ipAddress;
    }

    public boolean isRevoked() {
        return revoked;
    }

    public void setRevoked(boolean revoked) {
        this.revoked = revoked;
    }

    public Instant getRevokedAt() {
        return revokedAt;
    }

    public void setRevokedAt(Instant revokedAt) {
        this.revokedAt = revokedAt;
    }

    public String getRevokedReason() {
        return revokedReason;
    }

    public void setRevokedReason(String revokedReason) {
        this.revokedReason = revokedReason;
    }

    public int getUsageCount() {
        return usageCount;
    }

    public void setUsageCount(int usageCount) {
        this.usageCount = usageCount;
    }

    public void incrementUsageCount() {
        this.usageCount++;
    }

    public boolean isExpired() {
        return Instant.now().isAfter(this.expiryDate);
    }

    public boolean isValid() {
        return !isRevoked() && !isExpired();
    }

    public void revoke(String reason) {
        this.revoked = true;
        this.revokedAt = Instant.now();
        this.revokedReason = reason;
    }
}

エンティティ設計のポイント

フィールド 目的
token リフレッシュトークン文字列(UUID等)
user トークン所有者との関連
expiryDate 有効期限
userAgent 発行時のブラウザ情報(不正検知用)
ipAddress 発行時のIPアドレス(不正検知用)
revoked 無効化フラグ
usageCount 使用回数(リプレイ攻撃検知用)

リフレッシュトークンリポジトリ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.security.repository;

import com.example.security.entity.RefreshToken;
import com.example.security.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.util.List;
import java.util.Optional;

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

    Optional<RefreshToken> findByToken(String token);

    List<RefreshToken> findByUserAndRevokedFalse(User user);

    @Query("SELECT rt FROM RefreshToken rt WHERE rt.user = :user AND rt.revoked = false AND rt.expiryDate > :now")
    List<RefreshToken> findValidTokensByUser(@Param("user") User user, @Param("now") Instant now);

    @Modifying
    @Query("UPDATE RefreshToken rt SET rt.revoked = true, rt.revokedAt = :now, rt.revokedReason = :reason WHERE rt.user = :user AND rt.revoked = false")
    int revokeAllUserTokens(@Param("user") User user, @Param("now") Instant now, @Param("reason") String reason);

    @Modifying
    @Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < :now")
    int deleteExpiredTokens(@Param("now") Instant now);

    @Query("SELECT COUNT(rt) FROM RefreshToken rt WHERE rt.user = :user AND rt.revoked = false AND rt.expiryDate > :now")
    long countValidTokensByUser(@Param("user") User user, @Param("now") Instant now);

    boolean existsByTokenAndRevokedFalse(String token);
}

リフレッシュトークンサービスの実装

基本的なRefreshTokenService

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package com.example.security.service;

import com.example.security.config.JwtProperties;
import com.example.security.entity.RefreshToken;
import com.example.security.entity.User;
import com.example.security.exception.TokenException;
import com.example.security.repository.RefreshTokenRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.UUID;

@Service
public class RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;
    private final JwtProperties jwtProperties;

    // 同時アクティブセッション数の上限
    private static final int MAX_ACTIVE_SESSIONS = 5;

    public RefreshTokenService(
            RefreshTokenRepository refreshTokenRepository,
            JwtProperties jwtProperties) {
        this.refreshTokenRepository = refreshTokenRepository;
        this.jwtProperties = jwtProperties;
    }

    /**
     * 新しいリフレッシュトークンを作成する
     */
    @Transactional
    public RefreshToken createRefreshToken(User user, String userAgent, String ipAddress) {
        // アクティブセッション数をチェック
        long activeCount = refreshTokenRepository.countValidTokensByUser(user, Instant.now());
        if (activeCount >= MAX_ACTIVE_SESSIONS) {
            // 最も古いトークンを無効化
            revokeOldestToken(user);
        }

        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setUser(user);
        refreshToken.setToken(generateTokenString());
        refreshToken.setExpiryDate(Instant.now().plusMillis(jwtProperties.getRefreshTokenExpiration()));
        refreshToken.setUserAgent(userAgent);
        refreshToken.setIpAddress(ipAddress);

        return refreshTokenRepository.save(refreshToken);
    }

    /**
     * トークン文字列を生成する
     */
    private String generateTokenString() {
        return UUID.randomUUID().toString() + "-" + UUID.randomUUID().toString();
    }

    /**
     * 最も古いアクティブトークンを無効化する
     */
    private void revokeOldestToken(User user) {
        refreshTokenRepository.findValidTokensByUser(user, Instant.now())
                .stream()
                .findFirst()
                .ifPresent(token -> {
                    token.revoke("Max sessions exceeded");
                    refreshTokenRepository.save(token);
                });
    }

    /**
     * リフレッシュトークンを検証する
     */
    @Transactional(readOnly = true)
    public RefreshToken verifyRefreshToken(String token) {
        RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
                .orElseThrow(() -> new TokenException("Refresh token not found"));

        if (refreshToken.isRevoked()) {
            // 無効化されたトークンが使用された場合、全トークンを無効化(セキュリティ対策)
            revokeAllUserTokens(refreshToken.getUser(), "Revoked token reuse detected");
            throw new TokenException("Refresh token has been revoked");
        }

        if (refreshToken.isExpired()) {
            throw new TokenException("Refresh token has expired");
        }

        return refreshToken;
    }

    /**
     * トークンローテーションを実行する
     */
    @Transactional
    public RefreshToken rotateRefreshToken(RefreshToken oldToken, String userAgent, String ipAddress) {
        // 古いトークンを無効化
        oldToken.revoke("Token rotated");
        refreshTokenRepository.save(oldToken);

        // 新しいトークンを作成
        return createRefreshToken(oldToken.getUser(), userAgent, ipAddress);
    }

    /**
     * ユーザーの全トークンを無効化する
     */
    @Transactional
    public void revokeAllUserTokens(User user, String reason) {
        refreshTokenRepository.revokeAllUserTokens(user, Instant.now(), reason);
    }

    /**
     * 特定のトークンを無効化する
     */
    @Transactional
    public void revokeToken(String token, String reason) {
        refreshTokenRepository.findByToken(token)
                .ifPresent(refreshToken -> {
                    refreshToken.revoke(reason);
                    refreshTokenRepository.save(refreshToken);
                });
    }

    /**
     * 期限切れトークンをクリーンアップする
     */
    @Transactional
    public int cleanupExpiredTokens() {
        return refreshTokenRepository.deleteExpiredTokens(Instant.now());
    }
}

トークンローテーションの重要性

トークンローテーションとは、リフレッシュトークン使用時に新しいトークンを発行し、古いトークンを無効化する仕組みです。

sequenceDiagram
    participant Client as クライアント
    participant Server as サーバー
    participant DB as データベース
    
    Client->>Server: リフレッシュトークンA
    Server->>DB: トークンA検証
    DB-->>Server: 有効
    Server->>DB: トークンAを無効化
    Server->>DB: 新トークンBを保存
    Server-->>Client: アクセストークン + リフレッシュトークンB
    
    Note over Client,DB: 攻撃者がトークンAを盗んだ場合
    
    Client->>Server: リフレッシュトークンB(正規ユーザー)
    Server->>DB: トークンB検証
    DB-->>Server: 有効
    Server-->>Client: 新しいトークン
    
    rect rgb(255, 200, 200)
        Note over Client,DB: 攻撃者の試行
        Client->>Server: リフレッシュトークンA(攻撃者)
        Server->>DB: トークンA検証
        DB-->>Server: 無効化済み
        Server->>DB: 全トークンを無効化
        Server-->>Client: 401 Unauthorized
    end

ローテーションによるセキュリティ効果

シナリオ ローテーションなし ローテーションあり
トークン漏洩 有効期限まで悪用可能 正規ユーザー使用で無効化
リプレイ攻撃 検知困難 無効化済みトークン使用で検知
セッション管理 制御困難 使用履歴で追跡可能

認証サービスの実装

AuthService

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
package com.example.security.service;

import com.example.security.dto.AuthRequest;
import com.example.security.dto.AuthResponse;
import com.example.security.entity.RefreshToken;
import com.example.security.entity.User;
import com.example.security.exception.TokenException;
import com.example.security.repository.UserRepository;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuthService {

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;
    private final RefreshTokenService refreshTokenService;
    private final UserDetailsService userDetailsService;
    private final UserRepository userRepository;

    public AuthService(
            AuthenticationManager authenticationManager,
            JwtService jwtService,
            RefreshTokenService refreshTokenService,
            UserDetailsService userDetailsService,
            UserRepository userRepository) {
        this.authenticationManager = authenticationManager;
        this.jwtService = jwtService;
        this.refreshTokenService = refreshTokenService;
        this.userDetailsService = userDetailsService;
        this.userRepository = userRepository;
    }

    /**
     * ログイン処理
     */
    @Transactional
    public AuthResponse authenticate(AuthRequest request, String userAgent, String ipAddress) {
        // 認証実行
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(),
                        request.getPassword()
                )
        );

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        User user = userRepository.findByUsername(userDetails.getUsername())
                .orElseThrow(() -> new RuntimeException("User not found"));

        // アクセストークン生成
        String accessToken = jwtService.generateAccessToken(userDetails);

        // リフレッシュトークン生成
        RefreshToken refreshToken = refreshTokenService.createRefreshToken(user, userAgent, ipAddress);

        return new AuthResponse(accessToken, refreshToken.getToken());
    }

    /**
     * トークンリフレッシュ処理
     */
    @Transactional
    public AuthResponse refreshToken(String refreshTokenString, String userAgent, String ipAddress) {
        // リフレッシュトークン検証
        RefreshToken refreshToken = refreshTokenService.verifyRefreshToken(refreshTokenString);

        User user = refreshToken.getUser();
        UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUsername());

        // 新しいアクセストークン生成
        String accessToken = jwtService.generateAccessToken(userDetails);

        // トークンローテーション実行
        RefreshToken newRefreshToken = refreshTokenService.rotateRefreshToken(
                refreshToken, userAgent, ipAddress
        );

        return new AuthResponse(accessToken, newRefreshToken.getToken());
    }

    /**
     * ログアウト処理
     */
    @Transactional
    public void logout(String refreshTokenString) {
        refreshTokenService.revokeToken(refreshTokenString, "User logout");
    }

    /**
     * 全デバイスからログアウト
     */
    @Transactional
    public void logoutAllDevices(String username) {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new RuntimeException("User not found"));
        refreshTokenService.revokeAllUserTokens(user, "Logout from all devices");
    }
}

DTOクラス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.example.security.dto;

import jakarta.validation.constraints.NotBlank;

public class AuthRequest {

    @NotBlank(message = "Username is required")
    private String username;

    @NotBlank(message = "Password is required")
    private String password;

    public AuthRequest() {}

    public AuthRequest(String username, String password) {
        this.username = username;
        this.password = password;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.security.dto;

public class AuthResponse {

    private String accessToken;
    private String refreshToken;
    private String tokenType = "Bearer";

    public AuthResponse() {}

    public AuthResponse(String accessToken, String refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }

    public String getAccessToken() {
        return accessToken;
    }

    public void setAccessToken(String accessToken) {
        this.accessToken = accessToken;
    }

    public String getRefreshToken() {
        return refreshToken;
    }

    public void setRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }

    public String getTokenType() {
        return tokenType;
    }

    public void setTokenType(String tokenType) {
        this.tokenType = tokenType;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.security.dto;

import jakarta.validation.constraints.NotBlank;

public class RefreshTokenRequest {

    @NotBlank(message = "Refresh token is required")
    private String refreshToken;

    public RefreshTokenRequest() {}

    public RefreshTokenRequest(String refreshToken) {
        this.refreshToken = refreshToken;
    }

    public String getRefreshToken() {
        return refreshToken;
    }

    public void setRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }
}

認証コントローラーの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.example.security.controller;

import com.example.security.dto.AuthRequest;
import com.example.security.dto.AuthResponse;
import com.example.security.dto.RefreshTokenRequest;
import com.example.security.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    /**
     * ログインエンドポイント
     */
    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(
            @Valid @RequestBody AuthRequest request,
            HttpServletRequest httpRequest) {
        
        String userAgent = httpRequest.getHeader("User-Agent");
        String ipAddress = getClientIpAddress(httpRequest);
        
        AuthResponse response = authService.authenticate(request, userAgent, ipAddress);
        return ResponseEntity.ok(response);
    }

    /**
     * トークンリフレッシュエンドポイント
     */
    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refresh(
            @Valid @RequestBody RefreshTokenRequest request,
            HttpServletRequest httpRequest) {
        
        String userAgent = httpRequest.getHeader("User-Agent");
        String ipAddress = getClientIpAddress(httpRequest);
        
        AuthResponse response = authService.refreshToken(
                request.getRefreshToken(), userAgent, ipAddress
        );
        return ResponseEntity.ok(response);
    }

    /**
     * ログアウトエンドポイント
     */
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@RequestBody RefreshTokenRequest request) {
        authService.logout(request.getRefreshToken());
        return ResponseEntity.noContent().build();
    }

    /**
     * 全デバイスからログアウト
     */
    @PostMapping("/logout-all")
    public ResponseEntity<Void> logoutAll(@AuthenticationPrincipal UserDetails userDetails) {
        authService.logoutAllDevices(userDetails.getUsername());
        return ResponseEntity.noContent().build();
    }

    /**
     * クライアントIPアドレスを取得する
     */
    private String getClientIpAddress(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

ブラックリスト方式によるアクセストークン無効化

リフレッシュトークンはサーバー側で管理するため無効化が容易ですが、アクセストークンはステートレスなため即座に無効化できません。セキュリティ要件が厳しい場合は、ブラックリスト方式を採用します。

ブラックリストの設計パターン

flowchart TD
    A[トークン検証リクエスト] --> B{ブラックリスト確認}
    B -->|登録済み| C[401 Unauthorized]
    B -->|未登録| D{署名検証}
    D -->|不正| C
    D -->|正常| E{有効期限確認}
    E -->|期限切れ| C
    E -->|有効| F[認証成功]

Redis実装(推奨)

Redisを使用したブラックリスト実装は、高速なルックアップとTTL(自動有効期限切れ)により最も効率的です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.example.security.service;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Date;

@Service
public class TokenBlacklistService {

    private static final String BLACKLIST_PREFIX = "blacklist:";
    
    private final RedisTemplate<String, String> redisTemplate;

    public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * トークンをブラックリストに追加する
     */
    public void blacklistToken(String token, Date expiration) {
        String key = BLACKLIST_PREFIX + token;
        long ttlMillis = expiration.getTime() - System.currentTimeMillis();
        
        if (ttlMillis > 0) {
            redisTemplate.opsForValue().set(key, "blacklisted", Duration.ofMillis(ttlMillis));
        }
    }

    /**
     * トークンがブラックリストに登録されているか確認する
     */
    public boolean isBlacklisted(String token) {
        String key = BLACKLIST_PREFIX + token;
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    /**
     * JTI(JWT ID)でブラックリストに追加する
     */
    public void blacklistByJti(String jti, Date expiration) {
        String key = BLACKLIST_PREFIX + "jti:" + jti;
        long ttlMillis = expiration.getTime() - System.currentTimeMillis();
        
        if (ttlMillis > 0) {
            redisTemplate.opsForValue().set(key, "blacklisted", Duration.ofMillis(ttlMillis));
        }
    }

    /**
     * JTIがブラックリストに登録されているか確認する
     */
    public boolean isJtiBlacklisted(String jti) {
        String key = BLACKLIST_PREFIX + "jti:" + jti;
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }
}

DB実装(Redis非使用環境向け)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package com.example.security.entity;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "token_blacklist", indexes = {
    @Index(name = "idx_token_hash", columnList = "tokenHash"),
    @Index(name = "idx_expiry", columnList = "expiryDate")
})
public class BlacklistedToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String tokenHash;  // トークンのハッシュ値を保存(セキュリティ向上)

    @Column(nullable = false)
    private Instant expiryDate;

    @Column(nullable = false)
    private Instant blacklistedAt;

    @Column
    private String reason;

    @PrePersist
    protected void onCreate() {
        this.blacklistedAt = Instant.now();
    }

    // Getters and Setters
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTokenHash() {
        return tokenHash;
    }

    public void setTokenHash(String tokenHash) {
        this.tokenHash = tokenHash;
    }

    public Instant getExpiryDate() {
        return expiryDate;
    }

    public void setExpiryDate(Instant expiryDate) {
        this.expiryDate = expiryDate;
    }

    public Instant getBlacklistedAt() {
        return blacklistedAt;
    }

    public void setBlacklistedAt(Instant blacklistedAt) {
        this.blacklistedAt = blacklistedAt;
    }

    public String getReason() {
        return reason;
    }

    public void setReason(String reason) {
        this.reason = reason;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.security.repository;

import com.example.security.entity.BlacklistedToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.Instant;

@Repository
public interface BlacklistedTokenRepository extends JpaRepository<BlacklistedToken, Long> {

    boolean existsByTokenHash(String tokenHash);

    @Modifying
    @Query("DELETE FROM BlacklistedToken bt WHERE bt.expiryDate < :now")
    int deleteExpiredTokens(@Param("now") Instant now);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package com.example.security.service;

import com.example.security.entity.BlacklistedToken;
import com.example.security.repository.BlacklistedTokenRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;

@Service
public class DbTokenBlacklistService {

    private final BlacklistedTokenRepository blacklistedTokenRepository;

    public DbTokenBlacklistService(BlacklistedTokenRepository blacklistedTokenRepository) {
        this.blacklistedTokenRepository = blacklistedTokenRepository;
    }

    /**
     * トークンをブラックリストに追加する
     */
    @Transactional
    public void blacklistToken(String token, Date expiration, String reason) {
        String tokenHash = hashToken(token);
        
        BlacklistedToken blacklistedToken = new BlacklistedToken();
        blacklistedToken.setTokenHash(tokenHash);
        blacklistedToken.setExpiryDate(expiration.toInstant());
        blacklistedToken.setReason(reason);
        
        blacklistedTokenRepository.save(blacklistedToken);
    }

    /**
     * トークンがブラックリストに登録されているか確認する
     */
    @Transactional(readOnly = true)
    public boolean isBlacklisted(String token) {
        String tokenHash = hashToken(token);
        return blacklistedTokenRepository.existsByTokenHash(tokenHash);
    }

    /**
     * 期限切れのブラックリストエントリを削除する
     */
    @Transactional
    public int cleanupExpiredEntries() {
        return blacklistedTokenRepository.deleteExpiredTokens(Instant.now());
    }

    /**
     * トークンをハッシュ化する
     */
    private String hashToken(String token) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("SHA-256 algorithm not available", e);
        }
    }
}

ブラックリストを統合したJWT認証フィルター

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.example.security.filter;

import com.example.security.service.DbTokenBlacklistService;
import com.example.security.service.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;
    private final DbTokenBlacklistService blacklistService;

    public JwtAuthenticationFilter(
            JwtService jwtService,
            UserDetailsService userDetailsService,
            DbTokenBlacklistService blacklistService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
        this.blacklistService = blacklistService;
    }

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {
        
        final String authHeader = request.getHeader("Authorization");
        
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String jwt = authHeader.substring(7);
        
        // ブラックリストチェック
        if (blacklistService.isBlacklisted(jwt)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\": \"Token has been revoked\"}");
            return;
        }

        try {
            final String username = jwtService.extractUsername(jwt);
            
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                
                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (Exception e) {
            // トークン検証失敗
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\": \"Invalid token\"}");
            return;
        }
        
        filterChain.doFilter(request, response);
    }
}

スケジューラによる定期クリーンアップ

期限切れのトークンとブラックリストエントリを定期的に削除します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.security.scheduler;

import com.example.security.service.DbTokenBlacklistService;
import com.example.security.service.RefreshTokenService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@EnableScheduling
public class TokenCleanupScheduler {

    private static final Logger logger = LoggerFactory.getLogger(TokenCleanupScheduler.class);

    private final RefreshTokenService refreshTokenService;
    private final DbTokenBlacklistService blacklistService;

    public TokenCleanupScheduler(
            RefreshTokenService refreshTokenService,
            DbTokenBlacklistService blacklistService) {
        this.refreshTokenService = refreshTokenService;
        this.blacklistService = blacklistService;
    }

    /**
     * 毎日午前3時に期限切れトークンをクリーンアップ
     */
    @Scheduled(cron = "0 0 3 * * ?")
    public void cleanupExpiredTokens() {
        logger.info("Starting scheduled token cleanup");
        
        int deletedRefreshTokens = refreshTokenService.cleanupExpiredTokens();
        logger.info("Deleted {} expired refresh tokens", deletedRefreshTokens);
        
        int deletedBlacklistEntries = blacklistService.cleanupExpiredEntries();
        logger.info("Deleted {} expired blacklist entries", deletedBlacklistEntries);
        
        logger.info("Token cleanup completed");
    }
}

Security設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package com.example.security.config;

import com.example.security.filter.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final UserDetailsService userDetailsService;

    public SecurityConfig(
            JwtAuthenticationFilter jwtAuthFilter,
            UserDetailsService userDetailsService) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.userDetailsService = userDetailsService;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
                .anyRequest().authenticated()
            )
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

保存戦略の比較とベストプラクティス

DB vs Redis の比較

観点 データベース Redis
読み取り速度 遅い(ディスクI/O) 高速(インメモリ)
永続性 高い 設定による
スケーラビリティ 垂直/水平 クラスタリング
TTL管理 手動クリーンアップ 自動有効期限切れ
運用複雑性 低い やや高い
コスト 通常のDBコスト 追加のRedisサーバー

推奨構成

flowchart TD
    subgraph 推奨構成
        A[クライアント] --> B[APIサーバー]
        B --> C{トークン種別}
        C -->|リフレッシュトークン| D[(PostgreSQL)]
        C -->|アクセストークンブラックリスト| E[(Redis)]
    end
    
    subgraph シンプル構成
        F[クライアント] --> G[APIサーバー]
        G --> H[(PostgreSQL)]
    end

構成別の推奨事項

規模 推奨構成 理由
小規模(〜1000 DAU) DB単独 運用の簡素化
中規模(〜10万 DAU) DB + Redis パフォーマンスとセキュリティの両立
大規模(10万+ DAU) DB + Redis Cluster 高可用性と水平スケーリング

例外ハンドリング

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.security.exception;

public class TokenException extends RuntimeException {
    
    private final String errorCode;
    
    public TokenException(String message) {
        super(message);
        this.errorCode = "TOKEN_ERROR";
    }
    
    public TokenException(String message, String errorCode) {
        super(message);
        this.errorCode = errorCode;
    }
    
    public String getErrorCode() {
        return errorCode;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example.security.exception;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(TokenException.class)
    public ResponseEntity<Map<String, Object>> handleTokenException(TokenException ex) {
        logger.warn("Token exception: {}", ex.getMessage());
        
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", HttpStatus.UNAUTHORIZED.value());
        body.put("error", "Unauthorized");
        body.put("message", ex.getMessage());
        body.put("errorCode", ex.getErrorCode());
        
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body);
    }

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<Map<String, Object>> handleBadCredentials(BadCredentialsException ex) {
        logger.warn("Bad credentials: {}", ex.getMessage());
        
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", HttpStatus.UNAUTHORIZED.value());
        body.put("error", "Unauthorized");
        body.put("message", "Invalid username or password");
        
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body);
    }
}

テスト実装

RefreshTokenServiceのテスト

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
package com.example.security.service;

import com.example.security.config.JwtProperties;
import com.example.security.entity.RefreshToken;
import com.example.security.entity.User;
import com.example.security.exception.TokenException;
import com.example.security.repository.RefreshTokenRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.time.Instant;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class RefreshTokenServiceTest {

    @Mock
    private RefreshTokenRepository refreshTokenRepository;

    @Mock
    private JwtProperties jwtProperties;

    private RefreshTokenService refreshTokenService;

    @BeforeEach
    void setUp() {
        refreshTokenService = new RefreshTokenService(refreshTokenRepository, jwtProperties);
    }

    @Test
    @DisplayName("有効なリフレッシュトークンの検証が成功する")
    void verifyRefreshToken_ValidToken_ReturnsToken() {
        // given
        User user = new User();
        user.setId(1L);
        user.setUsername("testuser");

        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setToken("valid-token");
        refreshToken.setUser(user);
        refreshToken.setExpiryDate(Instant.now().plusSeconds(3600));
        refreshToken.setRevoked(false);

        when(refreshTokenRepository.findByToken("valid-token"))
                .thenReturn(Optional.of(refreshToken));

        // when
        RefreshToken result = refreshTokenService.verifyRefreshToken("valid-token");

        // then
        assertThat(result).isNotNull();
        assertThat(result.getToken()).isEqualTo("valid-token");
    }

    @Test
    @DisplayName("存在しないトークンの検証で例外がスローされる")
    void verifyRefreshToken_NotFound_ThrowsException() {
        // given
        when(refreshTokenRepository.findByToken("invalid-token"))
                .thenReturn(Optional.empty());

        // when & then
        assertThatThrownBy(() -> refreshTokenService.verifyRefreshToken("invalid-token"))
                .isInstanceOf(TokenException.class)
                .hasMessageContaining("not found");
    }

    @Test
    @DisplayName("無効化されたトークンの検証で例外がスローされる")
    void verifyRefreshToken_RevokedToken_ThrowsException() {
        // given
        User user = new User();
        user.setId(1L);

        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setToken("revoked-token");
        refreshToken.setUser(user);
        refreshToken.setRevoked(true);

        when(refreshTokenRepository.findByToken("revoked-token"))
                .thenReturn(Optional.of(refreshToken));

        // when & then
        assertThatThrownBy(() -> refreshTokenService.verifyRefreshToken("revoked-token"))
                .isInstanceOf(TokenException.class)
                .hasMessageContaining("revoked");

        // 全トークン無効化が呼ばれることを確認
        verify(refreshTokenRepository).revokeAllUserTokens(eq(user), any(), any());
    }

    @Test
    @DisplayName("期限切れトークンの検証で例外がスローされる")
    void verifyRefreshToken_ExpiredToken_ThrowsException() {
        // given
        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setToken("expired-token");
        refreshToken.setExpiryDate(Instant.now().minusSeconds(3600));
        refreshToken.setRevoked(false);

        when(refreshTokenRepository.findByToken("expired-token"))
                .thenReturn(Optional.of(refreshToken));

        // when & then
        assertThatThrownBy(() -> refreshTokenService.verifyRefreshToken("expired-token"))
                .isInstanceOf(TokenException.class)
                .hasMessageContaining("expired");
    }
}

セキュリティチェックリスト

本番運用前に以下の項目を確認してください。

カテゴリ チェック項目 重要度
トークン設計 アクセストークン有効期限は15分以下
トークン設計 リフレッシュトークンローテーション実装済み
トークン設計 JTI(JWT ID)でトークンを一意に識別
保存 リフレッシュトークンはサーバー側で管理
保存 HTTPOnly Cookieでトークンを保護
保存 秘密鍵は環境変数またはSecrets Managerで管理
無効化 ログアウト時にリフレッシュトークンを無効化
無効化 パスワード変更時に全トークンを無効化
無効化 不正検知時に全トークンを無効化
監視 無効化済みトークンの再利用をログ
監視 異常なトークン発行パターンを監視
運用 期限切れトークンの定期クリーンアップ

まとめ

本記事では、Spring Security 6.4でJWTリフレッシュトークンを安全に実装する方法を解説しました。

主要なポイントを振り返ります。

  1. 役割分担: アクセストークンは短命でステートレス、リフレッシュトークンは長命でステートフル
  2. トークンローテーション: リフレッシュ時に新トークンを発行し、旧トークンを即座に無効化
  3. 保存戦略: リフレッシュトークンはDBで永続化、ブラックリストはRedisで高速化
  4. 無効化: ログアウト、パスワード変更、不正検知時に全トークンを無効化

本番運用では、セキュリティチェックリストを活用し、漏れのない実装を心がけてください。

参考リンク