ちょっとタイトルが分かりづらいですが、Spring Security で作ったリソースサーバーがJWTから Granted Authorities を作る件についての話です。
Spring Security が JWT から Authentication を作る仕組み
公式リファレンスにわかりやすい図とともに載ってました。
ここで重要なのが次のインターフェイス、クラスです。
JwtDecoder
: JWTの検証と、文字列のJWTからJwt
インスタンスを生成する。JwtAuthenticationConverter
:Jwt
インスタンスから Granted Authorities へ変換する。
順番は JwtDecoder
から JwtAuthenticationConverter
の順で適用されるみたいですね。これらのクラスは Spring Security の JwtAuthenticationProvider で使用され、リクエストごとに処理されるようです。処理結果は JwtAuthenticationToken
として SecurityContext
にセットされます。
で、今回詳しく見てみたいのは JwtAuthenticationConverter
の方になります。
では実際に動かして見ようかなと思います。
認可サーバーはKeycloak、クライアントはcurlコマンドでエンドポイントを叩きます。
何も設定しなければ scope が Granted Authorities に変換される。
結論から言うと、何も設定しない場合は JWT の scope の値が Granted Authorities に変換されるようです。
Keycloakの設定は省略しますが、前回の記事とほとんど同じです。
リソースサーバーを作ります。pom.xml も前回と同じですね。一応再掲しておきます。
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.4.2</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>demo</name> <description>Demo project for Spring Boot</description> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
Configuration クラスです。ここで、 hasAuthority
メソッドでJWTのスコープに基づいてアクセスできるエンドポイントを指定します。今回は以下のように設定しました。
package com.example.demo.config; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); http.authorizeRequests() .mvcMatchers(HttpMethod.GET, "document/**").hasAuthority("SCOPE_read") .mvcMatchers(HttpMethod.POST, "document/**").hasAuthority("SCOPE_write") .anyRequest().permitAll(); } }
では、認可サーバーからアクセストークンを取得します。取得したJWTのペイロードをBase64でデコードした中身はこちら。
{ "exp": 1613198516, "iat": 1613194916, "jti": "3b73d569-7858-4f60-b48d-164e72897c11", "iss": "http://localhost:8081/auth/realms/DocumentService", "sub": "1e04c173-b80d-4bef-8bd3-9083cd2f8d8e", "typ": "Bearer", "azp": "documentservice", "session_state": "f997e799-0e5c-474f-b292-e7ddc76d818d", "acr": "1", "scope": "read" }
scope に read が設定されています。
このJWTを使ってリソースサーバーへアクセスしてみます。
まずはGETから。
curl localhost:8080/document -H 'Authorization: Bearer eyJhbGc...' document-1
read スコープを持っているので正しく許可されたのがわかりますね。
ではPOSTも試してみます。
curl localhost:8080/document -v -XPOST -H 'Authorization: Bearer eyJhbGciOiJSUz...' * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > POST /document HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.64.1 > Accept: */* > Authorization: Bearer eyJhbGciOiJSUzI... > < HTTP/1.1 403 < WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" < X-Content-Type-Options: nosniff < X-XSS-Protection: 1; mode=block < Cache-Control: no-cache, no-store, max-age=0, must-revalidate < Pragma: no-cache < Expires: 0 < X-Frame-Options: DENY < Content-Length: 0 < Date: Sat, 13 Feb 2021 05:47:38 GMT < * Connection #0 to host localhost left intact * Closing connection 0
アクセスが拒否されました。うまくいきましたね。
Keycloak の Role を Granted Authorities に変換してみる
一般的にはスコープの値を用いて Granted Authorities に変換する事ができればいい気がしますが、その他のクレームを元に変換したい場合も結構あるんじゃないかと思います。そんなときのためにSpring Securityには独自のクレームを変換できるようにする拡張ポイントが用意されています。
Spring SecurityをいじるまえにKeycloakにRoleを設定しましょう。Roleというのは文字のままの通り、ユーザが所属する権限みたいなものかと思います。
Administrator Console からロールを追加します。ロール名は admin
としました。
このロールをユーザー bob
にアサインします。
このロール情報をJWT煮含めるように設定していきましょう。 Clients > (特定のクライアント) > Mappers からAdd Builtinを選択します。その中から realm role
を選択して追加します。追加するとこんな感じで表示されます。
ここで改めてJWTを取得してみるとペイロードに先程指定したロール値がセットされています。realm_access
がキー名になってますね。
{ "exp": 1613199629, "iat": 1613196029, "jti": "bf3dd427-e70e-410a-8a19-295874bd1afc", "iss": "http://localhost:8081/auth/realms/DocumentService", "sub": "1e04c173-b80d-4bef-8bd3-9083cd2f8d8e", "typ": "Bearer", "azp": "documentservice", "session_state": "592948e8-3251-4540-bc8f-f0381b96c869", "acr": "1", "realm_access": { "roles": [ "admin" ] }, "scope": "read" }
では、こいつを Granted Authorities に変換するようにSpring Securityを設定していきましょう。
JWTからGranted Authoritiesを作るのはJwtAuthenticationConverterなので、これを設定する形になります。Configurationクラスはこちら。
package com.example.demo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); http.authorizeRequests() .mvcMatchers(HttpMethod.GET, "document/**").hasAuthority("SCOPE_admin") .mvcMatchers(HttpMethod.POST, "document/**").hasAuthority("SCOPE_write") .anyRequest().permitAll(); } @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter); return jwtAuthenticationConverter; } }
エンドポイントのGETリクエストを admin
スコープがないとアクセスできないようにしました。SCOPE
とプレフィックスがついているのはGranted Authorities に変換したときのデフォルトのプレフィックスが SCOPE
だからですね。
jwtAuthenticationConverter
メソッドでJWTからGranted Authoritiesの変換設定を行っています。JwtGrantedAuthoritiesConverter
クラスでJWT内のクレームのキーを指定してあげて、そのインスタンスをJwtAuthenticationConverter
に渡します。
実際に動かしてみましょう。
curl localhost:8080/document -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIy' -v * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > GET /document HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.64.1 > Accept: */* > Authorization: Bearer eyJhbGciOiJSUzI1NiIs > < HTTP/1.1 403 < WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" < X-Content-Type-Options: nosniff < X-XSS-Protection: 1; mode=block < Cache-Control: no-cache, no-store, max-age=0, must-revalidate < Pragma: no-cache < Expires: 0 < X-Frame-Options: DENY < Content-Length: 0 < Date: Sat, 13 Feb 2021 06:30:54 GMT < * Connection #0 to host localhost left intact * Closing connection 0
あれ、403で拒否されてしまいました。
調べてみたんですが、Granted Authorities が空になっていたのでうまく変換できていないのが原因みたいです。おそらくロール値がJSONの入れ子構造になっていて、うまく変換できないみたいです。
というわけで、Keycloak側でロールを入れ子構造にしないように設定してあげます。
先程設定した Mapper を開き、Token Claim Name を roles と書き換えます。
これで再度JWTを取得してみるとペイロードはこの様になっています。入れ子ではなくなっているのがわかります。
{ "exp": 1613272549, "iat": 1613268949, "jti": "f418ae17-8f8c-4523-ac72-ee1c43279a78", "iss": "http://localhost:8081/auth/realms/DocumentService", "sub": "1e04c173-b80d-4bef-8bd3-9083cd2f8d8e", "typ": "Bearer", "azp": "documentservice", "session_state": "0397c643-9a5b-4afb-ae6c-119fded0b549", "acr": "1", "scope": "read", "roles": [ "admin" ] }
Configuration クラスで roles を変換元クレームとして指定してあげます。
@Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter); return jwtAuthenticationConverter; }
では再度動かしてみましょう。
curl localhost:8080/document -H 'Authorization: Bearer eyJhbGciOiJSUzI...' document-1
問題なく GET できました。
では POST も試してみます。
curl localhost:8080/document -H 'Authorization: Bearer eyJhbGci...' -XPOST -v * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > POST /document HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.64.1 > Accept: */* > Authorization: Bearer eyJhbGci... > < HTTP/1.1 403 < WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1" < X-Content-Type-Options: nosniff < X-XSS-Protection: 1; mode=block < Cache-Control: no-cache, no-store, max-age=0, must-revalidate < Pragma: no-cache < Expires: 0 < X-Frame-Options: DENY < Content-Length: 0 < Date: Sun, 14 Feb 2021 02:21:12 GMT < * Connection #0 to host localhost left intact * Closing connection 0
弾かれました。うまくいきましたね。