Java 21/Spring Boot 3で実装する最新JWT認証ガイド
Spring Boot 3でのJWT認証実装:Spring Security標準機能の活用
こんにちは!今回はJava 21とSpring Boot 3を使った最新のJWT認証実装について詳しく解説します。Spring Securityの標準機能を活用した、セキュアな認証システムの構築方法を紹介します。ぜひ最後までご覧ください。
サンプルコードはjwt-sampleに動作するSpring Boot 3アプリケーションとしてプロジェクト一式を上げてありますので、ぜひご参照ください。
目次
- JWTとは何か
- 従来の認証との違い
- JWTの構造
- Spring Boot 3でのJWT実装(Spring Security標準機能使用)
- Java 21の新機能を活用したコード改善
- セキュリティベストプラクティス
- 実装チェックリスト
JWTとは何か
JWTはJSON Web Tokenの略で、クロスドメイン認証のためのオープンスタンダード(RFC 7519)です。ウェブアプリケーションにおいて、安全で便利な認証と情報転送を可能にする重要な役割を果たします。特にマイクロサービスアーキテクチャで広く採用されています。
JWTが解決する問題
従来の認証はクライアント側のクッキーとサーバー側のセッションに依存していました。これは単一サーバーのアプリケーションでは問題なく機能しますが、モダンなアプリケーションアーキテクチャでは以下のような課題があります。
- セッション共有の問題
マイクロサービスや分散システムでは、各サーバーが独立したセッションを維持します。ユーザーがサービス間を移動すると、ログイン状態の一貫性が失われる可能性があります。
- スケーラビリティの制限
セッションベースの認証は、特にクラウド環境や自動スケーリングを行う環境では効率的にスケールできません。
- クロスドメイン認証の難しさ
クッキーはドメインに紐づいており、異なるドメイン間で直接共有できないため、マルチドメインシステムでの統一認証が難しくなります。
JWTの利点
JWTはこれらの問題を以下のように解決します。
- ステートレス認証
JWTはアプリケーションをステートレスにし、セッション共有の必要性を回避します。必要なユーザー情報がトークン自体に含まれているため、サーバーはセッションを保存する必要がありません。
- スケーラビリティの向上
ステートレスな特性により、水平スケーリングが容易になります。新しいサーバーインスタンスを追加しても、認証情報は常にクライアントから提供されます。
- クロスドメイン認証
JWTはHTTPヘッダーで送信できるため、ドメインの制限を受けずに異なるサービス間で認証情報を共有できます。
- マイクロサービス互換性
各マイクロサービスは共通の秘密鍵または公開/秘密鍵ペアを使用してトークンを検証でき、認証サーバーへの追加のリクエストが不要になります。
従来の認証とJWT認証の比較
従来の認証フロー (セッションベース)
- ユーザーがログイン情報を送信
- サーバーが認証を行い、セッションを生成・保存
- セッションIDをクッキーでクライアントに送信
- 以降のリクエストで、クライアントがクッキーを送信
- サーバーがセッションIDを検証し、対応するセッションを検索
JWT認証フロー (トークンベース)
- ユーザーがログイン情報を送信
- サーバーが認証を行い、JWTを生成
- JWTをクライアントに返す
- 以降のリクエストで、クライアントがJWTをAuthorizationヘッダーに含めて送信
- サーバーがJWTの署名を検証し、トークン内の情報を使用して認証
JWTの構造
JWTは、ドットで区切られた3つの部分から構成されています
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
ヘッダー
ヘッダーは、トークンのタイプと使用する署名アルゴリズムを指定するJSONオブジェクトです。
{
"alg": "HS256", // 使用する暗号化アルゴリズム(HMAC SHA-256など)
"typ": "JWT" // トークンのタイプ
}
このJSONオブジェクトはBase64Urlでエンコードされ、JWTの最初の部分となります。
ペイロード
ペイロードはトークンに含める情報(クレーム)を保持します。クレームには以下の3種類があります。
-
登録済みクレーム: JWT仕様で定義された標準フィールド
-
iss
(発行者) -
sub
(サブジェクト) -
exp
(有効期限) -
iat
(発行時間) -
nbf
(有効開始時間) -
jti
(JWT ID) -
aud
(対象者)
-
-
公開クレーム: JWT使用者が自由に定義できるが、衝突を避けるためにIANA JWT Registryに登録するか、URI形式にすべきもの
-
プライベートクレーム: 当事者間で合意した独自のクレーム
例
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"exp": 1516242622
}
このペイロードもBase64Urlでエンコードされ、JWTの2番目の部分となります。
署名
署名は、エンコードされたヘッダー、エンコードされたペイロード、秘密鍵、そしてヘッダーで指定されたアルゴリズムを使用して作成されます。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
この署名はトークンのデータが改ざんされていないことを検証するために使用されます。
Spring Boot 3でのJWT実装(Spring Security標準機能使用)
Spring Boot 3では、Spring SecurityのOAuth2 Resource Server機能を使用して、JWTベースの認証を簡単に実装できます。この方式では、カスタムフィルターを実装する必要がなく、Spring Securityがトークンの検証や認証フローを処理してくれます。
依存関係の設定
build.gradle
ファイル
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
// スコープの変換や検証に必要
implementation 'org.springframework.security:spring-security-oauth2-jose'
// その他の依存関係
}
またはpom.xml
ファイル
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
</dependencies>
アプリケーション設定
application.properties
ファイルに必要な設定を追加します。
# JWT設定
# 対称鍵を使用する場合
spring.security.oauth2.resourceserver.jwt.secret-key=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
# または非対称鍵を使用する場合
# spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:public.pem
# または外部の認証サーバーを使用する場合
# spring.security.oauth2.resourceserver.jwt.issuer-uri=https://your-auth-server.com
# spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://your-auth-server.com/.well-known/jwks.json
# JWT有効期限設定(ミリ秒)
jwt.expiration=86400000
jwt.refresh-expiration=604800000
セキュリティのために、実際の秘密鍵は環境変数から取得することをお勧めします。
JWTプロパティ設定クラス
package com.example.security.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;
/**
* JWT設定
*/
@Component
@ConfigurationProperties(prefix = "jwt")
@Data
public class JwtConfig {
// トークンの有効期限(ms)
private long expiration;
// リフレッシュトークンの有効期限(ms)
private long refreshExpiration;
}
Spring SecurityとJWTの設定
Spring Security 6(Spring Boot 3)の新しい設定スタイルを使用して、OAuth2 Resource Serverとしての設定を行います。
package com.example.security.config;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor;
/**
* セキュリティ設定
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserDetailsService userDetailsService;
private final JwtKeyProperties jwtKeyProperties;
/**
* セキュリティフィルターチェーン
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Java 21のTextBlock機能を使用して、URL許可リストを定義
String[] publicUrls = """
/api/v1/auth/**
/v3/api-docs/**
/swagger-ui/**
/swagger-ui.html
/swagger-resources/**
/actuator/**
""".trim().split("\\s+");
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(publicUrls).permitAll()
.anyRequest().authenticated())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.build();
}
/**
* JWT認証コンバーター
*/
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
/**
* JWT デコーダー(トークン検証用)
*/
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(jwtKeyProperties.getPublicKey()).build();
}
/**
* JWT エンコーダー(トークン生成用)
*/
@Bean
public JwtEncoder jwtEncoder() {
JWK jwk = new RSAKey.Builder(jwtKeyProperties.getPublicKey())
.privateKey(jwtKeyProperties.getPrivateKey())
.build();
JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwkSource);
}
/**
* パスワードエンコーダー
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 認証マネージャー
*/
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(authenticationProvider);
}
}
JWTキー設定
認証に使用する鍵ペアの管理
package com.example.security.config;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import lombok.Data;
/**
* JWT鍵設定
*/
@Component
@ConfigurationProperties(prefix = "jwt.key")
@Data
public class JwtKeyProperties {
private RSAPublicKey publicKey;
private RSAPrivateKey privateKey;
}
JWTサービスの実装
Spring SecurityのJWT機能を使用したトークン生成・検証サービス
package com.example.security.service.jwt;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
import com.example.security.config.JwtConfig;
import lombok.RequiredArgsConstructor;
/**
* JWTサービス
*/
@Service
@RequiredArgsConstructor
public class JwtService {
private final JwtEncoder jwtEncoder;
private final JwtConfig jwtConfig;
/**
* トークンレスポンス
*/
public record JwtToken(String token, String refreshToken, Date expiresAt) {
}
/**
* 認証情報からトークンを生成
*/
public JwtToken generateToken(Authentication authentication) {
return generateToken(authentication.getName(),
authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
}
/**
* UserDetailsからトークンを生成
*/
public JwtToken generateToken(UserDetails userDetails) {
return generateToken(userDetails.getUsername(),
userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
}
/**
* ユーザー名と権限リストからトークンを生成
*/
public JwtToken generateToken(String username, Iterable<String> roles) {
// 現在の時刻
Instant now = Instant.now();
// アクセストークンの有効期限
Instant accessTokenExpiry = now.plus(jwtConfig.getExpiration(), ChronoUnit.MILLIS);
// リフレッシュトークンの有効期限
Instant refreshTokenExpiry = now.plus(jwtConfig.getRefreshExpiration(), ChronoUnit.MILLIS);
// アクセストークンに含めるクレーム
Map<String, Object> claims = new HashMap<>();
claims.put("roles", roles);
// アクセストークンの生成
String accessToken = createToken(username, now, accessTokenExpiry, claims);
// リフレッシュトークンの生成(権限情報は含めない)
String refreshToken = createToken(username, now, refreshTokenExpiry, new HashMap<>());
return new JwtToken(accessToken, refreshToken, Date.from(accessTokenExpiry));
}
/**
* トークンの生成
*/
private String createToken(String subject, Instant issuedAt, Instant expiresAt, Map<String, Object> claims) {
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.subject(subject)
.issuedAt(issuedAt)
.expiresAt(expiresAt);
// カスタムクレームの追加
claims.forEach(claimsBuilder::claim);
return jwtEncoder.encode(JwtEncoderParameters.from(claimsBuilder.build())).getTokenValue();
}
}
認証サービスとコントローラー
認証を処理するサービスとコントローラーの実装
package com.example.security.service.auth;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import com.example.security.model.Role;
import com.example.security.model.User;
import com.example.security.repository.user.UserRepository;
import com.example.security.service.jwt.JwtService;
import com.example.security.service.jwt.JwtService.JwtToken;
import lombok.RequiredArgsConstructor;
/**
* 認証サービス
*/
@Service
@RequiredArgsConstructor
public class AuthenticationService {
private final UserRepository repository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
/**
* 認証リクエスト
*
* @param email メールアドレス
* @param password パスワード
*/
public record AuthenticationRequest(String email, String password) {
}
/**
* 登録リクエスト
*
* @param firstname 名前
* @param lastname 姓
* @param email メールアドレス
* @param password パスワード
*/
public record RegisterRequest(String firstname, String lastname, String email, String password) {
}
/**
* 認証レスポンス
*
* @param accessToken アクセストークン
* @param refreshToken リフレッシュトークン
*/
public record AuthenticationResponse(String accessToken, String refreshToken) {
}
/**
* 登録
*
* @param request 登録リクエスト
* @return 認証レスポンス
*/
public AuthenticationResponse register(RegisterRequest request) {
var user = User.builder()
.firstname(request.firstname())
.lastname(request.lastname())
.email(request.email())
.password(passwordEncoder.encode(request.password()))
.role(Role.USER)
.build();
repository.save(user);
JwtToken jwtToken = jwtService.generateToken(user);
return new AuthenticationResponse(jwtToken.token(), jwtToken.refreshToken());
}
/**
* 認証
*
* @param request 認証リクエスト
* @return 認証レスポンス
*/
public AuthenticationResponse authenticate(AuthenticationRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.email(),
request.password()));
SecurityContextHolder.getContext().setAuthentication(authentication);
JwtToken jwtToken = jwtService.generateToken(authentication);
return new AuthenticationResponse(jwtToken.token(), jwtToken.refreshToken());
}
}
コントローラー
package com.example.security.controller.auth;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.security.service.auth.AuthenticationService;
import com.example.security.service.auth.AuthenticationService.AuthenticationRequest;
import com.example.security.service.auth.AuthenticationService.AuthenticationResponse;
import com.example.security.service.auth.AuthenticationService.RegisterRequest;
import lombok.RequiredArgsConstructor;
/**
* 認証関連のコントローラー
*/
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {
/**
* 認証サービス
*/
private final AuthenticationService service;
/**
* 新規登録
*
* @param request 登録リクエスト
* @return 認証レスポンス
*/
@PostMapping("/register")
public ResponseEntity<AuthenticationResponse> register(
@RequestBody RegisterRequest request) {
return ResponseEntity.ok(service.register(request));
}
/**
* 認証
*
* @param request 認証リクエスト
* @return 認証レスポンス
*/
@PostMapping("/authenticate")
public ResponseEntity<AuthenticationResponse> authenticate(
@RequestBody AuthenticationRequest request) {
return ResponseEntity.ok(service.authenticate(request));
}
}
リフレッシュトークンを処理するエンドポイント
package com.example.security.controller.token;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.security.service.jwt.JwtService;
import com.example.security.service.jwt.JwtService.JwtToken;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
/**
* トークンリフレッシュコントローラー
*/
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class TokenRefreshController {
/**
* JWTサービス
*/
private final JwtService jwtService;
/**
* JWTデコーダー
*/
private final JwtDecoder jwtDecoder;
/**
* ユーザー詳細サービス
*/
private final UserDetailsService userDetailsService;
/**
* トークンリフレッシュレスポンス
*
* @param accessToken アクセストークン
* @param refreshToken リフレッシュトークン
*/
public record TokenRefreshResponse(String accessToken, String refreshToken) {
}
/**
* トークンリフレッシュ
*
* @param request リクエスト
* @return トークンリフレッシュレスポンス
*/
@PostMapping("/refresh-token")
public ResponseEntity<TokenRefreshResponse> refreshToken(
HttpServletRequest request) {
// Authorizationヘッダーからリフレッシュトークンを取得
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return ResponseEntity.badRequest().build();
}
try {
// トークンの取得と検証
String refreshToken = authHeader.substring(7);
var jwt = jwtDecoder.decode(refreshToken);
// サブジェクトからユーザー名を取得
String username = jwt.getSubject();
// ユーザー情報の取得
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 新しいトークンの生成
JwtToken jwtToken = jwtService.generateToken(userDetails);
return ResponseEntity.ok(
new TokenRefreshResponse(jwtToken.token(), refreshToken));
} catch (JwtException | BadCredentialsException e) {
return ResponseEntity.status(401).build();
}
}
}
Java 21の新機能を活用したコード改善
Java 21では、コードをより簡潔で読みやすくするための多くの新機能が導入されています。JWTの実装において特に役立つ機能をいくつか紹介します。
Record
Recordは、イミュータブルなデータクラスを簡潔に定義できる機能です。DTOやリクエスト/レスポンスモデルに最適です。
// 従来のクラス
public class JwtResponse {
private final String token;
private final String refreshToken;
private final Date expiresAt;
// コンストラクタ、ゲッター、equals、hashCode、toStringなど...多くのボイラープレートコード
}
// Java 21のRecord
public record JwtResponse(String token, String refreshToken, Date expiresAt) {}
Recordを使用すると、コンストラクタ、getterメソッド、equals、hashCode、toStringメソッドが自動的に生成されるため、コードが大幅に簡潔になります。
Pattern Matching for instanceof
型チェックとキャストを一度に行うことができる機能です。
// 従来のコード
if (obj instanceof Authentication) {
Authentication auth = (Authentication) obj;
// authを使用...
}
// Java 21のパターンマッチング
if (obj instanceof Authentication auth) {
// 直接authを使用可能
}
Text Blocks
複数行の文字列を読みやすく記述できる機能です。
// 従来の文字列
String[] publicUrls = new String[] {
"/api/v1/auth/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html"
};
// Java 21のテキストブロック
String[] publicUrls = """
/api/v1/auth/**
/v3/api-docs/**
/swagger-ui/**
/swagger-ui.html
""".trim().split("\\s+");
Switch Expressions
より簡潔で安全なswitch文を記述できる機能です。
// 従来のswitch
String role;
switch (userType) {
case "ADMIN":
role = "ROLE_ADMIN";
break;
case "MANAGER":
role = "ROLE_MANAGER";
break;
default:
role = "ROLE_USER";
}
// Java 21のSwitch Expression
String role = switch (userType) {
case "ADMIN" -> "ROLE_ADMIN";
case "MANAGER" -> "ROLE_MANAGER";
default -> "ROLE_USER";
};
セキュリティベストプラクティス
JWTを使用する際は、以下のセキュリティベストプラクティスを考慮することが重要です。
トークン管理
- 短い有効期限を設定する
アクセストークンの有効期限は15分〜1時間程度に設定し、リスクを最小限に抑えます。
- リフレッシュトークンを実装する
ユーザーエクスペリエンスを向上させるため、より長い有効期限(24時間程度)を持つリフレッシュトークンを使用します。
- セキュアな鍵を使用する
可能な限り、RSAなどの非対称鍵を使用し、秘密鍵を安全に管理します。
トークン設計
- 必要最小限のクレームを使用する
トークンサイズを小さく保ち、必要な情報のみを含めます。
- 機密情報を含めない
パスワードやAPIキーなどの機密情報はJWTに含めるべきではありません。
- 適切な標準クレームを使用する
iss
、sub
、exp
、iat
、jti
などの標準クレームを適切に使用します。
実装のセキュリティ
- HTTPS必須
JWTはHTTPSで送信し、トークンの盗難を防ぎます。
- XSS対策
クライアント側でのトークン保存には、HttpOnly属性を持つクッキーまたはメモリ内ストレージを使用します。
- CSRF対策
Stateless JWT認証を使用する場合でも、CSRF対策を忘れないようにします。
認証フロー
- ブルートフォース対策
ログイン試行回数を制限し、アカウントロックアウトメカニズムを実装します。
- レート制限
APIエンドポイントにレート制限を設け、DoS攻撃を防ぎます。
- トークン無効化メカニズム
ユーザーがログアウトした場合や、セキュリティ侵害が発生した場合にトークンを無効化する仕組みを用意します。
実装チェックリスト
JWT認証を実装する際のチェックリストとして、以下の項目を確認しましょう。
- 依存関係の追加(spring-boot-starter-oauth2-resource-server等)
- JWTキー設定の管理
- Spring Security設定(OAuth2 Resource Server)
- JWTトークン生成・検証サービスの実装
- 認証コントローラーとサービス実装
- リフレッシュトークンメカニズム
- Java 21の新機能活用
- 単体テストの実装
- セキュリティのレビュー
APIエンドポイント設計
JWT認証の実装では、以下のようなAPIエンドポイントを設計することが一般的です。
エンドポイント | メソッド | 説明 | 認証要否 |
---|---|---|---|
/api/v1/auth/register |
POST | 新規ユーザー登録 | 不要 |
/api/v1/auth/authenticate |
POST | ユーザー認証・JWT取得 | 不要 |
/api/v1/auth/refresh-token |
POST | トークン更新 | 必要 (リフレッシュトークン) |
/api/v1/auth/logout |
POST | ログアウト | 必要 |
/api/v1/user/profile |
GET | ユーザー情報取得 | 必要 |
/api/v1/admin/** |
* | 管理者専用API | 必要 (ADMIN権限) |
実装の主要ポイント
-
アクセストークンとリフレッシュトークン
- アクセストークン:短い有効期限(15分〜1時間)
- リフレッシュトークン:長い有効期限(1日程度)
- アクセストークンが期限切れになった場合、リフレッシュトークンを使用して新しいアクセストークンを取得
-
トークンの保存場所
- フロントエンド:HttpOnlyクッキー(推奨)またはlocalStorage/sessionStorage
- バックエンド:トークンのブラックリスト(Redis等)を使用して、無効化されたトークンを管理
-
エラーハンドリング
- トークン検証失敗:401 Unauthorized
- 権限不足:403 Forbidden
- 一般的なエラー:適切なHTTPステータスコードとエラーメッセージ
高度な機能拡張
基本的なJWT認証の実装ができたら、以下のような高度な機能を追加することでセキュリティと使いやすさを向上させることができます。
透過的トークン更新
クライアントに負担をかけない自動リフレッシュの実装
@Component
@RequiredArgsConstructor
public class TokenRefreshInterceptor implements ClientHttpRequestInterceptor {
private final TokenService tokenService;
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
// 現在のアクセストークンを取得
String accessToken = tokenService.getCurrentAccessToken();
// トークンが期限切れに近い場合(例:残り5分以内)
if (tokenService.isTokenNearExpiration(accessToken)) {
// バックグラウンドでトークンを更新
tokenService.refreshTokenAsync();
}
// リクエストにトークンを追加
request.getHeaders().set("Authorization", "Bearer " + accessToken);
return execution.execute(request, body);
}
}
デバイス管理
複数デバイスからのログイン管理
public record DeviceInfo(
String deviceId,
String deviceType,
String ipAddress,
String userAgent,
LocalDateTime lastAccess
) {}
@Service
@RequiredArgsConstructor
public class DeviceManagementService {
private final DeviceRepository deviceRepository;
public List<DeviceInfo> getUserDevices(String userId) {
return deviceRepository.findByUserId(userId);
}
public void revokeDevice(String userId, String deviceId) {
deviceRepository.deleteByUserIdAndDeviceId(userId, deviceId);
// 関連するリフレッシュトークンを無効化
}
public void recordDeviceAccess(String userId, HttpServletRequest request, String deviceId) {
DeviceInfo deviceInfo = new DeviceInfo(
deviceId,
determineDeviceType(request.getHeader("User-Agent")),
request.getRemoteAddr(),
request.getHeader("User-Agent"),
LocalDateTime.now()
);
deviceRepository.save(userId, deviceInfo);
}
}
監査ログ
認証イベントの詳細なログ記録
@Service
@RequiredArgsConstructor
public class AuthAuditService {
private final AuditLogRepository auditLogRepository;
public void logAuthEvent(String userId, String eventType, String details, String ipAddress) {
AuthAuditLog log = AuthAuditLog.builder()
.userId(userId)
.eventType(eventType)
.details(details)
.ipAddress(ipAddress)
.timestamp(LocalDateTime.now())
.build();
auditLogRepository.save(log);
}
public List<AuthAuditLog> getUserAuthHistory(String userId, int limit) {
return auditLogRepository.findByUserIdOrderByTimestampDesc(userId, PageRequest.of(0, limit));
}
}
多要素認証(MFA)
追加の認証レイヤーによるセキュリティ強化
@Service
@RequiredArgsConstructor
public class MfaService {
private final OtpRepository otpRepository;
private final EmailService emailService;
public void sendOtpCode(String email) {
String otpCode = generateOtpCode();
// OTPコードを保存(有効期限付き)
otpRepository.save(email, otpCode, LocalDateTime.now().plusMinutes(10));
// メールでOTPコードを送信
emailService.sendOtpEmail(email, otpCode);
}
public boolean verifyOtpCode(String email, String otpCode) {
return otpRepository.findByEmail(email)
.filter(otp -> otp.getCode().equals(otpCode))
.filter(otp -> otp.getExpiresAt().isAfter(LocalDateTime.now()))
.isPresent();
}
private String generateOtpCode() {
// 6桁のランダムな数字を生成
return String.format("%06d", new Random().nextInt(1000000));
}
}
まとめ
この記事では、Java 21とSpring Boot 3を使用したJWT認証の実装について詳しく解説しました。特に、Spring SecurityのOAuth2 Resource Server機能を活用することで、カスタムフィルターを実装する必要なく、シンプルかつセキュアな認証メカニズムを実現できることを紹介しました。
Java 21の新機能(Record、Pattern Matching、Text Blocks、Switch Expressions)を活用することで、よりクリーンで保守性の高いコードを書くことができます。また、Spring Security 6の新しい設定スタイルを使うことで、セキュリティ設定もより直感的になります。
JWT認証は、特にマイクロサービスアーキテクチャやSPAフロントエンドを持つアプリケーションに最適なソリューションですが、実装する際にはセキュリティに細心の注意を払う必要があります。この記事で紹介したベストプラクティスを適用することで、安全で堅牢な認証システムを構築できるでしょう。
最後に、認証はアプリケーションセキュリティの一部に過ぎないことを忘れないでください。完全なセキュリティソリューションには、適切な認可メカニズム、セキュアなコーディングプラクティス、定期的なセキュリティレビューなど、多層的なアプローチが必要です。
参考リソース
- Spring Security公式ドキュメント
- Spring Security OAuth2 Resource Server
- JWT.IO - JWTのデバッグと検証ツール
- OWASP JWT Cheat Sheet - JWTセキュリティのベストプラクティス
- Spring Boot JWT Authentication with Spring Security - 実装例
Discussion
Spring Securityが提供しているものがあるので、JwtAuthenticationFilterは実装しなくて良いです。実装しているサンプルがたくさんありますが...
こちらがSpring Securityの機能を使ったサンプルです。 https://ik.am/entries/818
ご指摘いただきありがとうございます。
確かにSpring Securityには標準でJWT認証のための機能が組み込まれており、
JwtAuthenticationFilter
を自作する必要はありませんでした。Spring Securityでは、OAuth2 Resource Serverの機能として、JWT認証のためのフィルターやデコーダーが標準で提供されていした。
この機能は主に
spring-security-oauth2-resource-server
とspring-security-oauth2-jose
の2つのライブラリに集約されています。ご紹介いただいたik.amさんの記事では、まさにこの標準機能を使う方法が解説されています。こちらのアプローチでは、設定がシンプルになり、セキュリティ面でも安心です。
私の記事では
JwtAuthenticationFilter
を自作する方法を紹介していましたが、これには以下のような問題がありました。(記事は未修正です。週末に修正予定です)一方、Spring Security標準の機能を使うと、以下の利点があることに気づきました。
Spring Bootを使用する場合、標準機能を利用するには以下の手順が必要でした。
spring-boot-starter-oauth2-resource-server
依存関係の追加これだけでSpring Securityが標準の
BearerTokenAuthenticationFilter
を使用し、トークンの検証や認証処理を行ってくれることがわかりました。ご指摘に重ねて感謝いたします。
繰り返しになりますが、ご指摘いただいた通り、最新のSpring Security環境ではJWT認証のための標準機能を利用するべきです。
これにより、セキュリティ面での信頼性が高まり、コードもシンプルになります。私の記事を改善する機会をいただき、ありがとうございました。今後は学習を重ね、こういった標準機能を優先して紹介していきたいと思います。
※返信の作成にあたっては、ik.amさんの記事やSpring Securityの公式ドキュメントを参照しました。