Spring Bootアプリケーションにおいてステートレスな認可制御を構築する際、JSON Web Token(JWT)は標準的なアプローチの一つです。本稿では、カスタムアノテーションとSpring MVCのハンドラインターセプタを組み合わせ、特定のパスに対してトークン検証を適用する実装パターンを解説します。
1. 依存ライブラリの追加
ビルド管理ツールにJWTの符号化・検証を処理するライブラリを導入します。ここではMavenの依存関係定義を示します。
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
2. 認可制御用のカスタムアノテーション定義
エンドポイントごとに検証の適用可否を宣言するために、2つのメタデータアノテーションを作成します。
package com.example.security.meta;
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequireAuth {
boolean enforce() default true;
}
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IgnoreAuth {
boolean bypass() default true;
}
3. トークン発行サービスの実装
認証情報を基にJWTペイロードを構築し、署名を付与して文字列化する処理をサービスタグで管理します。
package com.example.security.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.security.model.Account;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.time.Instant;
@Component
public class TokenFactory {
private static final String HMAC_SECRET = "system-wide-secure-key-change-in-prod";
public String issueToken(Account account) {
Date expiration = Date.from(Instant.now().plusSeconds(7200));
return JWT.create()
.withSubject(String.valueOf(account.getUserId()))
.withExpiresAt(expiration)
.sign(Algorithm.HMAC256(HMAC_SECRET));
}
}
4. リクエスト検証インターセプターの構築
HTTPリクエストのライフサイクル途中でヘッダーを抽出し、アノテーションの付与状況に応じてトークンの復号と署名検証を実行します。
package com.example.security.core;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.example.security.meta.IgnoreAuth;
import com.example.security.meta.RequireAuth;
import com.example.security.model.Account;
import com.example.security.repository.AccountRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
@Component
public class JwtVerificationInterceptor implements HandlerInterceptor {
private static final String AUTH_HEADER = "Authorization";
private static final String HMAC_SECRET = "system-wide-secure-key-change-in-prod";
private final AccountRepository accountRepo;
public JwtVerificationInterceptor(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
Method targetMethod = ((HandlerMethod) handler).getMethod();
if (targetMethod.isAnnotationPresent(IgnoreAuth.class)) {
IgnoreAuth ignore = targetMethod.getAnnotation(IgnoreAuth.class);
return ignore.bypass();
}
if (targetMethod.isAnnotationPresent(RequireAuth.class)) {
RequireAuth require = targetMethod.getAnnotation(RequireAuth.class);
if (require.enforce()) {
String authHeader = request.getHeader(AUTH_HEADER);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new SecurityException("認証ヘッダーが見つかりません");
}
String rawToken = authHeader.substring(7);
String principalId;
try {
principalId = JWT.decode(rawToken).getSubject();
} catch (Exception e) {
throw new SecurityException("トークン形式が不正です");
}
Account profile = accountRepo.findById(Integer.parseInt(principalId)).orElse(null);
if (profile == null) {
throw new SecurityException("該当アカウントが見つかりません");
}
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(HMAC_SECRET)).build();
try {
verifier.verify(rawToken);
} catch (JWTVerificationException e) {
throw new SecurityException("署名の検証に失敗しました");
}
return true;
}
}
return true;
}
}
5. インターセプターの登録設定
Spring MVCの設定クラスにおいて、先ほど定義したインターセプタをパスパターンにバインドします。
package com.example.security.config;
import com.example.security.core.JwtVerificationInterceptor;
import com.example.security.repository.AccountRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcAccessConfig implements WebMvcConfigurer {
private final AccountRepository repo;
public MvcAccessConfig(AccountRepository repo) {
this.repo = repo;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor())
.addPathPatterns("/api/**");
}
@Bean
public JwtVerificationInterceptor jwtInterceptor() {
return new JwtVerificationInterceptor(repo);
}
}
6. コントローラーでの適用と動作確認
エンドポイントに定義したアノテーションを付与することで、インターセプタが自動的に検証フローを適用します。
package com.example.security.web;
import com.example.security.meta.IgnoreAuth;
import com.example.security.meta.RequireAuth;
import com.example.security.model.Account;
import com.example.security.repository.AccountRepository;
import com.example.security.service.TokenFactory;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1")
public class SessionController {
private final AccountRepository accountRepo;
private final TokenFactory tokenIss;
public SessionController(AccountRepository accountRepo, TokenFactory tokenIss) {
this.accountRepo = accountRepo;
this.tokenIss = tokenIss;
}
@PostMapping("/signin")
public Map<String, Object> authenticate(@RequestBody Account credentials) {
Optional<Account> found = accountRepo.findByUsername(credentials.getUsername());
if (found.isEmpty() || !found.get().getPassword().equals(credentials.getPassword())) {
return Map.of("status", 401, "message", "認証情報が一致しません");
}
Account validUser = found.get();
String accessToken = tokenIss.issueToken(validUser);
return Map.of("status", 200, "token", accessToken, "user", validUser);
}
@RequireAuth
@GetMapping("/secure/profile")
public String accessProtectedResource() {
return "認可済みルートへのアクセスが許可されました";
}
@IgnoreAuth
@GetMapping("/public/status")
public String accessPublicResource() {
return "認証不要の公開ルートです";
}
}
7. エンドポイント検証手順
実装が正常に動作しているか確認するために、以下のリクエストフローを適用します。
a) トークン取得リクエスト
POST /api/v1/signin HTTP/1.1
Content-Type: application/json
{
"username": "dev_user",
"password": "secure_pass"
}
レスポンスには署名付きトークンとユーザー情報が返却されます。
{
"status": 200,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": { "userId": 101, "username": "dev_user" }
}
b) 認証スキップパスの確認
`/api/v1/public/status`に対してGETリクエストを送信すると、トークンヘッダーの有無にかかわらず正常なテキストが返却されます。
c) 認証必須パスの確認
GET /api/v1/secure/profile HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
有効なトークンがヘッダーに含められている場合、認可成功メッセージが返却されます。ヘッダーが欠落しているか署名が一致しない場合、インターセプタ内で定義した`SecurityException`がスローされ、アプリケーションレベルのエラーハンドリングによってHTTP 401ステータスが返却される仕組みとなります。