はじめに
HTTP 基本認証では、リクエストごとに資格情報を送信する必要がありますが、運用環境の REST API には適していません。 JWT (JSON Web トークン) ではステートレス認証が可能です。サーバーはセッションを保存する必要がなく、すべての情報がトークン内にあります。これは、REST API および SPA で最も一般的な認証標準です。
1. JWT の内部構造
1.1 JWT 構造
Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9.
eyJzdWIiOiJkdXkiLCJyb2xlcyI6WyJVU0VSIl0sImlhdCI6MTcxMTgwMDAwMH0.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
┌─────────────────────────────────┐
│ Header │
│ {"alg": "HS256", "typ": "JWT"} │
├─────────────────────────────────┤
│ Payload (Claims) │
│ { │
│ "sub": "duy", │
│ "roles": ["USER"], │
│ "iat": 1711800000, │
│ "exp": 1711803600 │
│ } │
├─────────────────────────────────┤
│ Signature │
│ HMACSHA256( │
│ base64(header) + "." + │
│ base64(payload), │
│ secret │
│ ) │
└─────────────────────────────────┘
1.2 認証フロー
Client Server
│ │
│ POST /api/auth/login │
│ {username, password} ──────►│
│ │── Validate credentials
│ │── Generate JWT
│ ◄────────────────────────────│
│ {accessToken, refreshToken} │
│ │
│ GET /api/users/me │
│ Authorization: Bearer <JWT> ─►│
│ │── Validate JWT signature
│ │── Extract claims
│ ◄────────────────────────────│── Return protected resource
│ {user data} │
2. 実装
2.1 依存関係
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
2.2 JWT 構成
# application.yaml
app:
security:
jwt:
secret-key: "dGhpcy1pcy1hLXZlcnktc2VjdXJlLWtleS1mb3Itand0LXNpZ25pbmc="
access-token-expiration: 3600000 # 1 hour
refresh-token-expiration: 604800000 # 7 days
@ConfigurationProperties(prefix = "app.security.jwt")
public record JwtProperties(
String secretKey,
long accessTokenExpiration,
long refreshTokenExpiration
) {}
2.3 JWT サービス
@Service
public class JwtService {
private final JwtProperties jwtProperties;
private final SecretKey signingKey;
public JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
this.signingKey = Keys.hmacShaKeyFor(
Decoders.BASE64.decode(jwtProperties.secretKey())
);
}
public String generateAccessToken(UserDetails userDetails) {
Map<String, Object> claims = Map.of(
"roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList()
);
return buildToken(claims, userDetails.getUsername(),
jwtProperties.accessTokenExpiration());
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(Map.of(), userDetails.getUsername(),
jwtProperties.refreshTokenExpiration());
}
private String buildToken(Map<String, Object> extraClaims,
String subject, long expiration) {
return Jwts.builder()
.claims(extraClaims)
.subject(subject)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(signingKey)
.compact();
}
public String extractUsername(String token) {
return extractClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaims(token).getExpiration().before(new Date());
}
private Claims extractClaims(String token) {
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
2.4 JWT 認証フィルター
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService,
UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String jwt = authHeader.substring(7);
try {
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 (JwtException e) {
// Token invalid or expired — continue without authentication
}
filterChain.doFilter(request, response);
}
}
2.5 JWT を使用したセキュリティ構成
@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@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/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
3. 認証コントローラー
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
private final AuthService authService;
public AuthController(AuthenticationManager authManager,
JwtService jwtService,
UserDetailsService userDetailsService,
AuthService authService) {
this.authManager = authManager;
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<UserResponse> register(
@Valid @RequestBody RegisterRequest request) {
UserResponse user = authService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request) {
authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(), request.password()));
UserDetails userDetails = userDetailsService
.loadUserByUsername(request.username());
String accessToken = jwtService.generateAccessToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);
return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(
@RequestBody RefreshTokenRequest request) {
String username = jwtService.extractUsername(request.refreshToken());
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(request.refreshToken(), userDetails)) {
String newAccessToken = jwtService.generateAccessToken(userDetails);
return ResponseEntity.ok(
new AuthResponse(newAccessToken, request.refreshToken()));
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
// DTOs
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {}
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 50) String username,
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password
) {}
public record AuthResponse(String accessToken, String refreshToken) {}
public record RefreshTokenRequest(String refreshToken) {}
4. JWT 認証のテスト
# 1. Register
http POST localhost:8080/api/auth/register \
username=duy [email protected] password=Secret123
# 2. Login
http POST localhost:8080/api/auth/login \
username=duy password=Secret123
# Response: {"accessToken": "eyJ...", "refreshToken": "eyJ..."}
# 3. Access protected resource
http GET localhost:8080/api/users/me \
"Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
# 4. Refresh token
http POST localhost:8080/api/auth/refresh \
refreshToken=eyJ...
概要
- JWT ではステートレス認証が可能です。サーバーはセッションやトークン内の情報を保存しません。
- JwtAuthenticationFilter はリクエストごとにトークンをチェックして検証し、有効な場合は SecurityContext を設定します
- リフレッシュ トークン フローにより、再度ログインすることなくアクセス トークンを更新できます
演習
- JWT 認証フローの完全な実装: 登録、ログイン、リフレッシュ トークン、保護されたエンドポイントへのアクセス
- JWT クレームにロールを追加し、USER エンドポイントと ADMIN エンドポイントを区別するための承認を構成します
- ユーザーのログアウト時にトークンを取り消すためのトークン ブラックリスト (Redis またはメモリ内に保存) を実装します。