1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初心者】JWTまわりがよく分からなかったので、自分のSpringコードを見ながら整理してみる

1
Posted at

前提(開発環境)

今回の内容は以下の構成を前提にしています。

  • フロント:React
  • バックエンド:Spring Boot
  • 認証:Clerk(JWT)

フロントで取得したJWTを、APIリクエスト時に Authorization ヘッダーに付与し、
Spring側で検証する構成です。


この構成で出てくる用語

この構成で開発していると、以下の用語が一気に出てきます。

  • JWT
  • CORS
  • CSRF
  • Spring Security
  • Filter

最初はそれぞれ単体で調べても、
「実際のコードでどう関係しているのか」が分かりづらかったので、
このあたりの関係を整理します。


ざっくりした役割の整理

今回の構成では、それぞれの役割はこうなっています。

  • JWT
    → 認証のためのトークン(誰なのかを判断する材料)

  • CORS
    → ブラウザが別ドメイン通信を許可するかどうかの制御

  • CSRF
    → Cookieベース認証で発生する攻撃への対策(JWT構成では基本無効化)

  • Spring Security
    → 認証・認可の仕組み全体を管理する

  • Filter
    → リクエストごとにJWTを検証する場所


Spring Securityとの関係

Spring Securityの中では、大きく次のように役割が分かれています。

  • Filter
    → 認証(JWTの検証)

  • 設定(SecurityConfig)
    → 認可(どのAPIを許可するか)

つまり、

  • 「このリクエストは誰か?」を判断するのがFilter
  • 「その人はこのAPIにアクセスできるか?」を決めるのが設定

という構造になっています。


このあとやること

このあと、実際のコードをベースに

  • SecurityConfig
  • JWT認証Filter
  • 公開鍵取得処理(JWKS)

の順で分解していきます。

対象コード

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final ClerkJwtAuthFilter clerkJwtAuthFilter;
    @Value("${app.security.allow-public-register:false}")
    private boolean allowPublicRegister;
    @Value("${app.security.allow-public-file-upload:false}")
    private boolean allowPublicFileUpload;
    @Value("${app.security.allow-public-file-myFiles:false}")
    private boolean allowPublicFileMyFiles;
    @Value("${app.security.allow-public-file-public:false}")
    private boolean allowPublicFilePublic;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.cors(Customizer.withDefaults())
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> {
                    auth.requestMatchers("/webhooks/**").permitAll();
                    String[] devPublicPaths = devPublicPaths();
                    if (devPublicPaths.length > 0) {
                        auth.requestMatchers(devPublicPaths).permitAll();
                    }
                    auth.anyRequest().authenticated();
                })
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(clerkJwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return httpSecurity.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        return urlBasedCorsConfigurationSource();
    }


    private UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("*"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    private String[] devPublicPaths() {
        return Stream.of(
                        allowPublicRegister ? "/register" : null,
                        allowPublicFileUpload ? "/files/upload" : null,
                        allowPublicFileMyFiles ? "/files/my" : null,
                        allowPublicFilePublic ? "/files/public/*" : null
                )
                .filter(path -> path != null)
                .toArray(String[]::new);
    }
}

ClerkJwtAuthFilter

@Component
@RequiredArgsConstructor
public class ClerkJwtAuthFilter extends OncePerRequestFilter {


    @Value("${clerk.issuer}")
    private String clerkIssuer;
    @Value("${app.security.allow-public-register:false}")
    private boolean allowPublicRegister;
    @Value("${app.security.allow-public-file-upload:false}")
    private boolean allowPublicFileUpload;
    @Value("${app.security.allow-public-file-myFiles:false}")
    private boolean allowPublicFileMyFiles;
    @Value("${app.security.allow-public-file-public:false}")
    private boolean allowPublicFilePublic;
    @Value("${app.security.dev-public-file-upload-clerk-id:}")
    private String devPublicFileUploadClerkId;

    private final ClerkJwksProvider jwksProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (isWebhookRequest(request) || isDevelopmentPublicRequest(request)) {
            filterChain.doFilter(request,response);
            return;
        }
        if (isDevelopmentMockAuthenticationRequest(request)) {
            setDevelopmentAuthentication();
            filterChain.doFilter(request,response);
            return;
        }
       String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN,"Authorization header missing/invalid");
            return;
        }

        try {
            String token =authHeader.substring(7);
            String[] chunks = token.split("\\.");
            if (chunks.length < 3) {
                response.sendError(HttpServletResponse.SC_FORBIDDEN,"Invalid JWT token ");
                return;
            }

            String headerJson = new String(Base64.getUrlDecoder().decode(chunks[0]));
            ObjectMapper mapper = new ObjectMapper();
            JsonNode node = mapper.readTree(headerJson);

            if (!node.has("kid")) {
                response.sendError(HttpServletResponse.SC_FORBIDDEN,"token had is missing");
                return;
            }
            String kid = node.get("kid").asText();

            PublicKey publicKey = jwksProvider.getPublicKey(kid);

            Claims claims = Jwts.parserBuilder()
                    .setSigningKey(publicKey)
                    .setAllowedClockSkewSeconds(60)
                    .requireIssuer(clerkIssuer)
                    .build()
                    .parseClaimsJws(token)
                    .getBody();

            String clerkId = claims.getSubject();
            String role = claims.get("role", String.class );
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(clerkId, null, Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role)));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            filterChain.doFilter(request,response);

        } catch (Exception e) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN,"invalid JWT token:" + e.getMessage());
            return;
        }

    }

    private void setDevelopmentAuthentication() {
        if (devPublicFileUploadClerkId == null || devPublicFileUploadClerkId.isBlank()) {
            throw new IllegalStateException("app.security.dev-public-file-upload-clerk-id must be set when public file upload is enabled");
        }

        // review-before-edit: inject a dev-only principal so upload services can resolve clerkId
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                devPublicFileUploadClerkId,
                null,
                Collections.singletonList(new SimpleGrantedAuthority("ROLE_DEV"))
        );
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }

    private boolean isWebhookRequest(HttpServletRequest request) {
        return request.getRequestURI().contains("/webhooks");
    }

    private boolean isDevelopmentPublicRequest(HttpServletRequest request) {
        String requestUri = request.getRequestURI();
        return (allowPublicRegister && requestUri.endsWith("/register"))
                || (allowPublicFilePublic && requestUri.contains("/files/public/"));
    }

    private boolean isDevelopmentMockAuthenticationRequest(HttpServletRequest request) {
        String requestUri = request.getRequestURI();
        return (allowPublicFileUpload && requestUri.endsWith("/files/upload"))
                || (allowPublicFileMyFiles && requestUri.endsWith("/files/my"));
    }
}

ClerkJwksProvider


@Component

public class ClerkJwksProvider {

    private static final long CACHE_TTL = 3600000; //1 hour
    private final Map<String, PublicKey> keyCache = new ConcurrentHashMap<>();
    private long lastFetchTime = 0;

    @Value("${clerk.jwks-url}")
    private String jwksUrl;

    public PublicKey getPublicKey(String kid) throws Exception {
        boolean expired = System.currentTimeMillis() - lastFetchTime >= CACHE_TTL;

        if (!keyCache.containsKey(kid) || expired) {
            synchronized (this) {
                expired = System.currentTimeMillis() - lastFetchTime >= CACHE_TTL;

                if (!keyCache.containsKey(kid) || expired) {
                    refreshKeys();
                }
            }
        }

        PublicKey key = keyCache.get(kid);

        if (key == null) {
            throw new RuntimeException("Public key not found for kid: " + kid);
        }
        return key;
    }

    private void refreshKeys() throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        JsonNode jwks = mapper.readTree(new URL(jwksUrl));

        JsonNode keys = jwks.get("keys");

        Map<String, PublicKey> newCache = new HashMap<>();

        for (JsonNode node : keys) {
            String kid = node.get("kid").asText();
            String kty = node.get("kty").asText();
            String alg = node.get("alg").asText();

            if ("RSA".equals(kty) && "RS256".equals(alg)) {
                String n = node.get("n").asText();
                String e = node.get("e").asText();

                PublicKey key = createPublicKey(n, e);
                newCache.put(kid, key);
            }
        }

        keyCache.clear();
        keyCache.putAll(newCache);

        lastFetchTime = System.currentTimeMillis();
    }

    private PublicKey createPublicKey(String modulus, String exponent) throws Exception {
        byte[] modulusBytes = Base64.getUrlDecoder().decode(modulus);
        byte[] exponentBytes = Base64.getUrlDecoder().decode(exponent);

        BigInteger modulusBigInt = new BigInteger(1, modulusBytes);
        BigInteger exponentBigInt = new BigInteger(1, exponentBytes);

        RSAPublicKeySpec spec = new RSAPublicKeySpec(modulusBigInt, exponentBigInt);
        KeyFactory factory = KeyFactory.getInstance("RSA");
        return factory.generatePublic(spec);

    }

}



全体構造

この構成は以下の3つに分かれている。

  • SecurityConfig:認可と全体設定
  • Filter:JWTの検証
  • JwksProvider:公開鍵の取得

全体構造

この構成は以下の3つに分かれている。

  • SecurityConfig:認可と全体設定
  • Filter:JWTの検証
  • JwksProvider:公開鍵の取得

認可とセッション設定(SecurityConfig)

.authorizeHttpRequests(auth -> {
    auth.requestMatchers("/webhooks/**").permitAll();
    auth.anyRequest().authenticated();
})
  • 一部のAPIは公開
  • それ以外は認証必須
.sessionManagement(session ->
    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
  • セッションは使わない
  • 毎回JWTで認証する構成

JWT検証(Filter)

String authHeader = request.getHeader("Authorization");
  • Authorizationヘッダーからトークン取得
String[] chunks = token.split("\\.");
  • JWTを分解(header.payload.signature)
String kid = node.get("kid").asText();
PublicKey publicKey = jwksProvider.getPublicKey(kid);
  • kidから公開鍵を取得
Claims claims = Jwts.parserBuilder()
        .setSigningKey(publicKey)
        .requireIssuer(clerkIssuer)
        .build()
        .parseClaimsJws(token)
        .getBody();
  • 署名検証と発行元チェック
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
  • 認証済みとして登録

公開鍵の取得(JwksProvider)

JsonNode jwks = mapper.readTree(new URL(jwksUrl));
  • JWKSエンドポイントから鍵一覧取得
Map<String, PublicKey> newCache = new HashMap<>();
  • kidごとにキャッシュ
private static final long CACHE_TTL = 3600000;
  • キャッシュ有効期限は1時間

開発用の切り替え

if (isWebhookRequest(request) || isDevelopmentPublicRequest(request)) {
    filterChain.doFilter(request,response);
    return;
}
  • 一部リクエストはJWT検証をスキップ
if (isDevelopmentMockAuthenticationRequest(request)) {
    setDevelopmentAuthentication();
}
  • 開発中は擬似的にログイン状態を作る

まとめ

  • 認証はFilterで実行される
  • 認可はSecurityConfigで制御される
  • JWTは毎リクエスト検証される
  • 公開鍵はJWKSから取得してキャッシュする
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?