前提(開発環境)
今回の内容は以下の構成を前提にしています。
- フロント: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から取得してキャッシュする