Spring BootでJWTを用いたリクエスト認可制御の実装

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ステータスが返却される仕組みとなります。

タグ: Spring Boot JWT Java authentication Interceptor

9月30日 09:23 投稿