はじめに
ZYYXのアドベントカレンダー企画で7本目の投稿となります
案件でSpringSecurityについて初めて触れ、SpringSecurityでのJWT形式トークンの検証処理の実装と検証失敗時の制御の実装を行ったため、備忘録として記します
サンプルコード
今回ではAPIのリクエストに対して、JWTの検証を行いその検証失敗時の動作を制御することを考えます
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
@Order(0)
@Bean
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.requestMatcher(new AndRequestMatcher(
new AntPathRequestMatcher("/api/**"),
new ApiAppRequestMatcher()
))
.authorizeRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth
.bearerTokenResolver(request -> "access_token"))
.jwt(jwt -> jwt.decoder(jwtDecoderUsingJwkSetUri()))
.authenticationEntryPoint(customJwtAuthenticationEntryPoint())
)
return http.build();
}
@Bean
JwtDecoder jwtDecoderUsingJwkSetUri() {
var decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(audience);
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
decoder.setJwtValidator(validator);
return decoder;
}
@Bean
public AuthenticationEntryPoint customJwtAuthenticationEntryPoint() {
return new CustomJwtAuthenticationEntryPoint();
}
}
SecurityConfigのFilterChainの設定
SecutityConfigクラスにて、特定のパスに適用するFilterChainを登録します
requestMatcherはAntPathRequestMatcherを利用して定義しています
パスごとに適用するFilterChainがある場合、FilterChainを複数登録することになりますが、その際の適用順序は@Orderで決定することができ、若い番号ほど優先されます
@Order(0)
@Bean
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
requestMatcher(new AndRequestMatcher(
new AntPathRequestMatcher("/api/**"),
new ApiRequestMatcher()
))
.authorizeRequests(auth -> auth.anyRequest().authenticated())
// 省略
return http.build();
}
RequestMatcherクラスをインプリメントしてヘッダーのAPIキーや、UserAgentを判定する自作のRequestMatcherクラスを適用することもできます
public class ApiRequestMatcher implements RequestMatcher {
@Override
public boolean matches(HttpServletRequest request) {
String userAgent = request.getHeader("userAgent");
boolean isApi = // userAgentに対する判定処理
return isApi;
}
}
また、古いSpringSecurityのバージョンであれば以下の様にWebSecurityConfigurerAdapterを継承して、configureメソッドから作成する様な形でしたが、最近は非推奨となっています
おそらく、5.4からがFilterChainを利用できるようになり、5.7から非推奨となり6.0で完全に削除されたかと思います
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 省略
}
公開鍵の取得
JWTをデコードする際の公開鍵を取得します
今回では、公開鍵のパスをjwtSetUriで直接指定して、取得しています
var decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
以下の様にissuerUriを指定して、公開鍵を自動的に取得する方法もあります
この場合はiss検証については合わせて自動でやってくれます
var decoder = JwtDecoders.fromIssuerLocation(issuerUri);
JWTの検証設定
今回はJWT検証時にiss,exp,nbf,audを検証することを考えます
標準のJwtValidatorsを利用すると、クレームのiss,exp,nbfについてはデフォルトで設定されています
// デフォルトのiss,exp/nbfのValidator
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
// audienceの検証用のValidator
OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(audience);
// デフォルトのValidatorとaudienceのValidatorを組み合わせたValidator
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
decoder.setJwtValidator(validator);
audについては、デフォルトのJwtValidatorsには含まれていないため、以下のように自作のValidatorを作成し、DelegatingOAuth2TokenValidatorからデフォルトのJwtValidatorsとAudienceValidatorを組み合わせたOAuth2TokenValidatorを作成します
@RequiredArgsConstructor
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {
private final String audience;
public OAuth2TokenValidatorResult validate(Jwt jwt) {
List<String> audiences = jwt.getAudience();
if (audiences.contains(audience)) {
return OAuth2TokenValidatorResult.success();
} else {
OAuth2Error error = new OAuth2Error(
"invalid_token",
"The required audience is missing",
null
);
return OAuth2TokenValidatorResult.failure(error);
}
}
}
また、今回出てきたクレームは以下です
他にもJWTトークンにクレームはありますが、一般的によく検証される項目はこの4つなのかなと
| クレーム名 | 説明 | 検証 |
|---|---|---|
| iss(Issuer) | 発行者の識別子 | 本来の発行先であるかをチェックする |
| exp(ExpirationTime) | JWTが無効になる日時 | いわゆるトークンの有効期限で期限切れでないかをチェックする |
| nbf(NotBefore) | JWTが有効になる日時 | トークンが有効になる前に利用されていないかをチェックする |
| aud(Audience) | アクセスが許可されたリソースの識別子 | 自身のシステムに対してアクセスを許可されたものかチェックする |
FilterChainへのJWT検証の登録
以下によって、検証対象となるトークンの対象の設定・JWTデコーダーの登録・検証失敗時の動作の設定を行なっています
bearerTokenResolverでは、トークンの検証対象となる文字列を設定しています
今回は、access_tokenというヘッダにある値を検証対象としています
デフォルトでは、Authorizationヘッダーとなっているため、Authorizationヘッダーを利用している場合は設定不要です
authenticationEntryPointでは、トークン検証が失敗した場合に実行されるメソッドで、SpringSecurity標準動作でなく、カスタムのレスポンスボディなどを返却したり指定の動作を実装する場合に利用します
詳しくは次項で解説します
.oauth2ResourceServer(oauth -> oauth
.bearerTokenResolver(request -> request.getHeader("access_token"))
.jwt(jwt -> jwt.decoder(jwtDecoderUsingJwkSetUri()))
.authenticationEntryPoint(customJwtAuthenticationEntryPoint())
AuthenticationEntryPointの登録
AuthenticationEntryPointを登録していないデフォルトの動作では、レスポンスのステータスが401でボディがない状態となっています
システムで定義している共通のレスポンスボディに変換してレスポンスを返したい場合があると思いますが、その場合は以下のAuthenticationEntryPointをインプリメントした自作クラスを作成します
これによって、システム共通のレスポンス形式に合わせることもできます
また、ログ出力なども実行することができます
public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
int status = HttpServletResponse.SC_UNAUTHORIZED;
ErrorResponse errorResponse = new ErrorResponse(
status,
authException.getMessage()
);
response.setContentType("application/json;charset=UTF-8");
response.setStatus(status);
objectMapper.writeValue(response.getWriter(), errorResponse);
}
}
public class ErrorResponse {
private final int status;
private final String message;
public ErrorResponse(int status, String message) {
this.status = status;
this.message = message;
}
}
まとめ
SpringSecurityについては、初めは理解が乏しくどこのクラスでどう処理を実装したらいいのか分からず苦労していましたが、最終的に整理された実装を見ると実装量も少なく便利だなという印象を受けます