まえがき
Keycloakで生成したIDトークン(JWT)をSpringBootで検証するための設定方法を、自分でOUTPUTして手順化したかったのでこの記事にまとめる。
ソースだけ見たい方向け。
SpringSecurity JWT検証フロー
① リクエストを受け取る
② リクエストのAuthorizationヘッダ内にあるJWTを取得
③ ②のJWTを下記の流れで変換して、SpringSecurity配下でJWT情報を管理する
■ 文字列としてのJWT
・HTTPリクエストで送られてきた文字列
↓
■ クラスとしてのJWT
・SpringSecurityで用意されているJWTクラス
・文字列として受け取ったJWTをオブジェクトとして管理する用のクラス
↓
■ Tokenクラス
・検証済のJWT情報を入れておくクラス
・SpringSecurityの管理対象のクラスがこれ。
・SpringSecurityの仕様で「Authenticationインタフェースの実装クラス」がこれに該当する。
↑の処理フローでは2回の変換処理がある。それぞれ変換処理を担うインタフェースが用意されている。
文字列としてのJWT → クラスとしてのJWT ... JwtDecoderインタフェース
クラスとしてのJWT → Tokenクラス ... Converterインタフェース
必要な依存関係
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
<scope>provided</scope>
</dependency>
用意するクラス
① SpringSecurity設定用クラス
- SecurityConfigクラス
② 検証済のJWTの情報をSpringSecurityで管理するための入れ物クラス
- CustomTokenクラス ※Authenticationインタフェースの実装クラス
- CustomPrincipalクラス // CustomTokenクラスを完成させるために必要なクラス
- CustomRoleクラス // CustomTokenクラスを完成させるために必要なクラス
③ ②を生成するためのクラス
- CustomJwtConverterクラス ※Converterインタフェースの実装クラス
ソースコード
SecurityConfigクラス
・SpringSecurityの設定を行うクラス
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
CustomJwtConverter customJwtConverter;
// 本当は@Bean化してそこでインスタンス化すべき。説明用にここに定義。
// JWTの発行者(issuer)であるKeycloakのURLをフォーマット(${ホスト名}/auth/realms/${realm名})に沿って指定する
JwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation("http://localhost:8180/auth/realms/user");
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors();
http.csrf().disable();
// リクエスト制御
http.authorizeRequests(
requests -> requests
.antMatchers(HttpMethod.GET, "/admin/hello").hasRole("user")
.antMatchers(HttpMethod.GET, "/user/hello").permitAll());
// リソースサーバ関連の設定
http.oauth2ResourceServer(
oauth2ResourceServerCustomizer ->
oauth2ResourceServerCustomizer.jwt(jwtCustomizer ->
// 2つの変換処理で利用するクラス(ConverterIF実装クラス/JwtDecoderIF実装クラス)をここでセットする
jwtCustomizer.jwtAuthenticationConverter(customJwtConverter).decoder(jwtDecoder))
);
}
}
CustomTokenクラス
・検証済のJWT情報を入れておくクラス
・Authenticationインタフェースに沿って実装されている必要がある。
・AbstractAuthenticationToken
クラスは、Authenticationインタフェースの実装クラス/Baseクラスである。
・Authenticationインタフェースの実装クラスを作る際は、BaseクラスであるAbstractAuthenticationTokenをextendsして必要なメソッドを@OverrideすればOK。
public class CustomToken extends AbstractAuthenticationToken {
public CustomToken(Collection<? extends GrantedAuthority> authorities, CustomPrincipal principal) {
// ここで渡したAuthority(文字列のRole)が、hasRole(),hasAuthority()のチェック対象となる
super(authorities);
this.principal = principal;
}
// @AuthenticationPrincipalで参照されるPrincipalとなる
private CustomPrincipal principal;
// Authenticationインタフェースで用意されているgetPrincipal()を@Override
@Override
public CustomPrincipal getPrincipal() {
return principal;
}
// Authenticationインタフェースで用意されているgetCredentials()を@Override
@Override
public Object getCredentials() {
return null;
}
}
CustomPrincipalクラス
・AuthenticationインタフェースのgetPrincipal()で返す入れ物クラスとして利用される
・@AuthenticationPrincipalで参照される
@Builder
@Data
public class CustomPrincipal implements Serializable {
private String id;
private String name;
}
CustomRoleクラス
・AuthenticationインタフェースのgetAuthorities()で返す入れ物クラスとして利用される。
・getAuthorities()の戻り値型がCollection<? extends GrantedAuthority>
であるため「GrantedAuthorityの実装クラス」としてCustomRoleクラスを実装する必要がある
・Keycloakが生成したJWT内の格納されているロール情報はここに格納する。
public class CustomRole implements GrantedAuthority {
private String role;
public CustomRole(String role) {
this.role = role;
}
@Override
public String getAuthority() {
// hasRole()でチェックするときに「ROLE_」をprefixとして付与されるように。
return "ROLE_" + role;
}
}
CustomJwtConverterクラス
・JWTクラス → AuthenticationIFを実装したCustomTokenクラス へ変換する。
・Converterインタフェースをimplements Converter<Jwt, CustomToken>
で型指定&IFとして提供されているconvert()を@Overrideして実装する。
@Component
public class CustomJwtConverter implements Converter<Jwt, CustomToken> {
private static final String CLAIM_KEY_ID = "sub";
private static final String CLAIM_KEY_USERNAME = "preferred_username";
private static final String CLAIM_KEY_ROLES = "roles";
@Override
public CustomToken convert(Jwt token) {
// 1. principal(id,username)の作成
String id = token.getClaimAsString(CLAIM_KEY_ID);
String name = token.getClaimAsString(CLAIM_KEY_USERNAME);
CustomPrincipal principal = CustomPrincipal.builder().id(id).name(name).build();
// 2. authoritiesの作成 AS CustomRoleクラス
Collection<CustomRole> authorities = Collections.emptyList();
if (token.getClaims().containsKey(CLAIM_KEY_ROLES)) {
authorities = token.getClaimAsStringList(CLAIM_KEY_ROLES).stream().map(CustomRole::new)
.toList();
}
// CustomToken生成
CustomToken customToken = new CustomToken(authorities, principal);
// AuthenticationIFで用意されているsetAuthenticated()で更新
// これをtrueにしないと401エラーで怒られる
customToken.setAuthenticated(true);
return customToken;
}
}
動作確認
・動作確認用のControllerを用意
@CrossOrigin
@RestController
public class SampleController {
@GetMapping("/user/hello")
public String userHello() {
return "Any user can call this api.";
}
@GetMapping("/admin/hello")
public String adminHello(@AuthenticationPrincipal CustomPrincipal principal) {
return "HELLO " + principal.getName();
}
}
・アクセス制御の設定
http.authorizeRequests(requests -> requests
.antMatchers(HttpMethod.GET, "/admin/hello").hasRole("user") // Authentication.getPrincipal()で返されるAuthorityの中に「user」があること
.antMatchers(HttpMethod.GET, "/user/hello").permitAll() // 認証無でアクセス可能
);
・クライアントアプリからアクセス
・AuthorizationヘッダにはJWTが格納されている
SpringSecurity仕様関連のMEMO ※後で書き足していく
・Jwtクラスらへんの継承関係
・hasRole, hasAuthorityの違い