Spring Security で、アクセストークンを検証するリソースサーバーを実装する
皆さん、こんにちは。技術開発グループのn-ozawanです。
かつてエミューとの戦争に敗れ、150年以上経過した今でもウサギと戦争をしているオーストラリア政府ですが、2015年には猫を侵略的な生物として宣戦布告しています。オーストラリアへ旅行に行った際は、ウサギや猫を見かけても安易に近寄らない方が良いかもしれません。
本題です。
Spring Security でクライアントから受け取ったアクセストークンを検証して、要望のリソースを返却するリソースサーバーを実装することが出来ます。今回はSpring Securityでアクセストークンを検証するやり方をお話しします。
目次
Spring Security でアクセストークンの検証
ゴール
Spring Security でリソースサーバー側を実装します。リソースサーバーではクライアントから受け取ったアクセストークンを検証します。なお、IdPにはKeycloakを利用しています。

アクセストークンの検証に必要なプロパティを定義する
アクセストークンの検証に必要なプロパティをapplication.properties
に定義します。
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8080/realms/myrealm
OIDC認証の時とは違って、アクセストークンの検証に必要なプロパティは1つです。アクセストークンの発行者のURIを定義します。ここで定義した発行者が発行したアクセストークンのみを受け付けるようになります。
アクセストークンを検証するSecurityFilterChainを生成する
アクセストークンを検証する最小のコードは以下となります。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// アクセストークン検証
http
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
http.authorizeHttpRequests(...)
は、クライアントがアクセスしたパスに対して、クライアントが適切な権限を有しているかチェックするためのメソッドです。この場合は、すべてのパスにアクセスする際は、アクセストークンの検証がOKである必要があります。
http.oauth2ResourceServer(...)
は、アクセストークンを検証するためのメソッドです。Spring Securityが対応しているトークンはJWTと、Opaque トークンの2つになります。Opaque トークン とは、そのトークンをキーとして認可サーバーへ問い合わせることで検証するトークンです。Opaque トークン自体は何の情報を持たず、その中身は乱数であることが多いです。keycloakのアクセストークンはJWT形式ですので、上記のコードはJWTを検証するように実装しています。
上記のコードにより、デジタル署名の検証に加えて、以下の検証を行います。
- issuerのチェック
想定していない発行者が発行したアクセストークンはNGとします。 - 有効期限のチェック
有効期限が切れたアクセストークンをNGとします。
アクセストークンのカスタム検証を追加する
特に追加の検証が無ければ、以上のコードで十分です。しかし、中には追加でアクセストークンを検証したいこともあるかと思います。keycloakが発行するアクセストークンには、そのユーザーに与えられたロールが格納されています。今回はこのロールを見て、特定のロール以外の場合は拒否するようにしてみましょう。
まずはSecurityFilterChainの生成のところを修正します。
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// アクセストークン検証
http
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(this.jwtDecoder())));
return http.build();
}
oauth2.jwt(...)
のところで、自前のJWT Decoderを設定してあげるようにしています。メソッドthis.jwtDecoder()
の中身は以下の通りです。
// メンバ変数
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
// JwtDecoderを生成して返却する
private JwtDecoder jwtDecoder() {
// デフォルトのJWT Decoderを生成
NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuerUri);
// アクセストークンのカスタム検証
OAuth2TokenValidator<Jwt> validators = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuerUri), // issuerのチェック、有効期限のチェック
roleValidator() // ロールのチェック
);
jwtDecoder.setJwtValidator(validators);
return jwtDecoder;
}
メソッドの一行目では、デフォルトのJWT Decoderを生成しています。引数には発行者のURIが必要になりますので、application.properties
に定義したissuer-uri
を指定してあげます。
jwtDecoder.setJwtValidator(...)
でアクセストークンの検証処理を指定することが出来ます。指定する検証処理には、本来デフォルトでチェックする「issuerのチェック」と「有効期限のチェック」に加えて、今回追加するチェック処理をまとめて指定する必要があります。
roleValidator()
の中身は以下の通りです。
@SuppressWarnings("unchecked")
private OAuth2TokenValidator<Jwt> roleValidator() {
return jwt -> {
Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
Map<String, Object> clientts = (Map<String, Object>) resourceAccess.get(jwt.getClaim("azp"));
List<String> roles = (List<String>) clientts.get("roles");
if (roles.contains("admin")) {
return OAuth2TokenValidatorResult.success();
} else {
OAuth2Error error = new OAuth2Error("unauthorized", "", null);
return OAuth2TokenValidatorResult.failure(error);
}
};
}
Jwtを受け取るので、その中身を見て、adminロールが付与されていればOK、付与されていなければNGとしています。チェックOKとする場合はOAuth2TokenValidatorResult.success()
を返却します。チェックNGの場合はOAuth2TokenValidatorResult.failure(error)
を返却します。
以上の実装により自前の検証処理を追加することが出来ます。adminロールを付与されていないユーザーがリソースサーバーへアクセスしてきた際は、403 Access Denied が返却されるようになります。
おわりに
リソースサーバー側はアクセストークンの検証ぐらいしかやることないので、OIDC認証を行うクライアント側と比べると簡素でいいですね。
今回は自前でアクセストークンの検証処理を追加してみました。その一例としてロールによるアクセス制御を行っていますが、本来であればロールによるアクセス制御はhttp.authorizeHttpRequests(...)
でやった方が良いです。http.authorizeHttpRequests(...)
を使ったアクセス制御は次回やりたいと思います。
ではまた。