Spring Security のリソースサーバーでJWTから Granted Authorities を作る

ちょっとタイトルが分かりづらいですが、Spring Security で作ったリソースサーバーがJWTから Granted Authorities を作る件についての話です。

Spring Security が JWT から Authentication を作る仕組み

公式リファレンスにわかりやすい図とともに載ってました。

https://docs.spring.io/spring-security/site/docs/5.4.2/reference/html5/#oauth2resourceserver-jwt-architecture

ここで重要なのが次のインターフェイス、クラスです。

  • JwtDecoder: JWTの検証と、文字列のJWTから Jwt インスタンスを生成する。
  • JwtAuthenticationConverter: Jwt インスタンスから Granted Authorities へ変換する。

順番は JwtDecoder から JwtAuthenticationConverter の順で適用されるみたいですね。これらのクラスは Spring Security の JwtAuthenticationProvider で使用され、リクエストごとに処理されるようです。処理結果は JwtAuthenticationToken として SecurityContext にセットされます。

で、今回詳しく見てみたいのは JwtAuthenticationConverter の方になります。

https://github.com/spring-projects/spring-security/blob/master/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationConverter.java

では実際に動かして見ようかなと思います。

認可サーバーはKeycloak、クライアントはcurlコマンドでエンドポイントを叩きます。

何も設定しなければ scope が Granted Authorities に変換される。

結論から言うと、何も設定しない場合は JWT の scope の値が Granted Authorities に変換されるようです。

Keycloakの設定は省略しますが、前回の記事とほとんど同じです。

baubaubau.hatenablog.com

リソースサーバーを作ります。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のスコープに基づいてアクセスできるエンドポイントを指定します。今回は以下のように設定しました。

  • read スコープ: document へのGETリクエストを許可
  • write スコープ: document へのPOSTリクエストを許可
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 としました。

f:id:bau1537:20210213145446p:plain

このロールをユーザー bobアサインします。

f:id:bau1537:20210213145620p:plain

このロール情報をJWT煮含めるように設定していきましょう。 Clients > (特定のクライアント) > Mappers からAdd Builtinを選択します。その中から realm role を選択して追加します。追加するとこんな感じで表示されます。

f:id:bau1537:20210213145929p:plain

ここで改めて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 と書き換えます。

f:id:bau1537:20210214111455p:plain

これで再度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

弾かれました。うまくいきましたね。