Spring Security 5.3.3で Resource Server を構成する

2020.07.02

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

Spring Boot + Spring Security を利用して OAuth 2.0 の Token Introspection を利用した Resource Server を構成してみます。

環境

  • Java: openjdk 11.0.6 2020-01-14 LTS
  • SpringBoot: 2.3.1.RELEASE
  • SpringSecurity: 5.3.3.RELEASE

目的

「Spring Bootで単純なWebアプリケーションを作成し、/health エンドポイント以外すべて TokenIntrospection による AccessToken のチェックを入れて保護する」ことが目的です。それのための最小構成です。

SpringSecurity Current Reference

Spring Bootアプリケーションを構成

ごくありふれたサンプルを作成します。

plugins {
    id 'org.springframework.boot' version '2.3.1.RELEASE'
    id 'io.spring.dependency-management' version '1.0.9.RELEASE'
    id "com.palantir.git-version" version "0.12.3"
    id 'java'
}

group = 'jp.classmethod.sample'
version = gitVersion()
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // spring security
    implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server"
    implementation "com.nimbusds:oauth2-oidc-sdk"

    // lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // for test
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}

test {
    useJUnitPlatform()
}

Spring Initializr で作成したデフォルトみたいなプロジェクトです。適当にエンドポイントを定義します。

@RestController
public class RootController {

    @GetMapping("/health")
    public ResponseEntity<String> health() {
        return ResponseEntity.ok("UP");
    }

    @GetMapping("/hello")
    public ResponseEntity<String> hello () { return ResponseEntity.ok("hello springboot world");}
}

よくあるサンプルです。起動してチェックすると

$ curl  --verbose -X GET 'http://localhost:8080/health'
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /health HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200
< 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-Type: text/plain;charset=UTF-8
< Content-Length: 2
< Date: Tue, 30 Jun 2020 14:23:10 GMT
<
* Connection #0 to host localhost left intact
UP
$ curl  --verbose -X GET 'http://localhost:8080/hello'
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200
< 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-Type: text/plain;charset=UTF-8
< Content-Length: 22
< Date: Tue, 30 Jun 2020 14:24:18 GMT
<
* Connection #0 to host localhost left intact
hello springboot world

どちらも HTTP Status 200 が返却され、何の変哲もない文字列が返却されます。

Resource Server 設定を構成

OAuth 2.0 の TokenIntrospection を行う Resource Server の構成を追加していきます。build.gradle に以下を追加

// spring security
implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server"
implementation "com.nimbusds:oauth2-oidc-sdk"

依存の追加が完了しました。

oauth2-oidc-sdk について

これから設定する jwt, opaqueToken は Option です。そのため、単純に spring-boot-starter-oauth2-resource-server の依存を追加しただけでは足りません。

spring-projects - issue 7883

ここにあるように oauth2-oidc-sdk に含まれる依存クラスが見つからないと起動時にエラーになります。

Security Configuration

次に WebSecurityConfigureAdapter の構成。config パッケージあたりを切って SecurityConfig クラスを作成しました。

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.
                authorizeRequests(s -> s.
                        mvcMatchers("/health") // 1
                        .permitAll()
                        .anyRequest()
                        .authenticated())
                .oauth2ResourceServer()        // 2
                .opaqueToken();                // 3
    }
}
  1. 今回 /health エンドポイントのみ保護を除外します。
  2. ResourceServer を構成するために oauth2ResourceServer() を設定します
  3. oauth2ResourceServerでは jwt, opaqueToken をサポートしています。TokenIntrospection を利用する場合は opaqueToken() を指定します

これで構成は完了しました。

application.yaml

最後に、TokenIntrospection を行うエンドポイント、ClientId, ClientSecret の設定を記述します。

spring:
  security:
    oauth2:
      resourceserver:
        opaque-token:
          introspection-uri: "https://example.com/oauth/introspect"
          client-id: "CLIENT_ID"
          client-secret: "CLIENT_SECRET"

これらの設定は必須です。introspeciton-uri をうっかりキーごと削除して起動すると以下のエラーが出力されます

Bean method 'opaqueTokenIntrospector' in 'OAuth2ResourceServerOpaqueTokenConfiguration.OpaqueTokenIntrospectionClientConfiguration' not loaded because @ConditionalOnProperty (spring.security.oauth2.resourceserver.opaquetoken.introspection-uri) did not find property 'spring.security.oauth2.resourceserver.opaquetoken.introspection-uri'

動作確認

動作を確認しましょう。

$ ./gradlew -is clean bootRun
...
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.3.1.RELEASE)
...

/health を確認

$ curl  --verbose -X GET 'http://localhost:8080/health'
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /health HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200
< 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-Type: text/plain;charset=UTF-8
< Content-Length: 2
< Date: Tue, 30 Jun 2020 14:39:57 GMT
<
* Connection #0 to host localhost left intact
UP

/hello を確認

$ curl  --verbose -X GET 'http://localhost:8080/hello'
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 401
< Set-Cookie: JSESSIONID=EE9DC4286639C278A05F13553A73244B; Path=/; HttpOnly
< WWW-Authenticate: Bearer
< 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: Tue, 30 Jun 2020 14:42:37 GMT
<
* Connection #0 to host localhost left intact

HTTP Status Code 401が帰りました。WWW-Authenticate ヘッダも付与され Bearer が値に設定されています。

有効な AccessToken を Authorization ヘッダに Bearer Token として付与することで保護された /hello へアクセスすることが可能です。

$ aurl -p komuro-profile -X GET 'http://localhost:8080/hello'
hello springboot world

aurl は都元ダイスケが開発していた Go 製の OAuth 2.0 のサポートCLIツールです。 classmethod/aurl

これを利用することで指定された profile の設定の Token エンドポイント、フローを使って Token エンドポイントから有効な AccessToken を取得し、 Authorization ヘッダへ設定。指定されたURLへアクセスするという動作をサポートしています。詳細は github リポジトリを御覧ください。

Under the hood

このままでは、単にドキュメントに沿って Hello World を記述しただけなので生産性がありません。コードを追ってなぜこのような動作になっているかを深追いします。

Spring Security の基本的な話は全部飛ばします。細かく他人説明できるほど理解できてるか怪しいとも言う

公式ドキュメントに非常にわかりやすい図があります。

oauth2ResourceServer()

先程 SecurityConfig で記述した .oauth2ResourceServer() から中を見ていきます。

このメソッドは org.springframework.security.config.annotation.web.builders.HttpSecurity.java に定義されています。

spring-security - HttpSecurity.java

public OAuth2ResourceServerConfigurer<HttpSecurity> oauth2ResourceServer() throws Exception {
  OAuth2ResourceServerConfigurer<HttpSecurity> configurer = getOrApply(new OAuth2ResourceServerConfigurer<>(getContext()));
  this.postProcess(configurer);
  return configurer;
}

OAuth2ResourceServerConfigurer が作成されているのがわかります。

OAuth2ResourceServerConfigure

クラスの中にインナークラスとしてJwtConfigure, OpaqueTokenConfigure が定義されています。今回は opaqueToken() を呼び出しているので opaqueTokenConfigure が利用されます。

spring-security - OAuth2ResourceServerConfigurer.java

public OpaqueTokenConfigurer opaqueToken() {
  if (this.opaqueTokenConfigurer == null) {
    this.opaqueTokenConfigurer = new OpaqueTokenConfigurer(this.context);
  }

  return this.opaqueTokenConfigurer;
}

OpaqueTokenConfigure

OpaqueTokenConfigureTokenIntrospectionUri, ClientId, ClientSecret といった AccessToken の Introspection に必要な情報に加え OpaqueTokenIntrospector というインタフェースを持ちます。

OpaqueTokenIntrospector インタフェースは後述する AuthenticationProvider で管理されるようです。

OpaqueTokenIntrospector は Introspection を行うためのインタフェースです。何も設定がない場合は以下のメソッドを通して Bean からデフォルト実装が選択され、採用されます。

OpaqueTokenIntrospector getIntrospector() {
  if (this.introspector != null) {
    return this.introspector.get();
  }
  return this.context.getBean(OpaqueTokenIntrospector.class);
}

Bean の定義は OAuth2ResourceServerOpaqueTokenConfiguration にあります。

@Configuration(proxyBeanMethods = false)
class OAuth2ResourceServerOpaqueTokenConfiguration {

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnMissingBean(OpaqueTokenIntrospector.class)
    static class OpaqueTokenIntrospectionClientConfiguration {

        @Bean
        @ConditionalOnProperty(name = "spring.security.oauth2.resourceserver.opaquetoken.introspection-uri")
        NimbusOpaqueTokenIntrospector opaqueTokenIntrospector(OAuth2ResourceServerProperties properties) {
            OAuth2ResourceServerProperties.Opaquetoken opaqueToken = properties.getOpaquetoken();
            return new NimbusOpaqueTokenIntrospector(opaqueToken.getIntrospectionUri(), opaqueToken.getClientId(),
                    opaqueToken.getClientSecret());
        }
...

OpaqueTokenIntrospector の Bean が他に存在しないかつ、 spring.security.oauth2.resourceserver.opaquetoken.introspection-uri が設定されている場合、 OpaqueTokenIntrospector のデフォルト実装クラスである NimbusOpaqueTokenIntrospector が採用されます。

OAuth2ResourceServerConfigure#init(H http)

AuthenticaitonProviderの設定が行われます。AuthenticaionProvider はこれもまたインタフェースで実際の認証処理を行うメソッドが定義されています。ここでは以下のように jwtConfigureopaqueTokenConfigure のどちらが設定されているかによって返却される AuthenticationProvider が変更されます。

jwtConfigure の方が優先しているように見えますが、いずれかしか設定できないため、実質どちらか一方のみが自ずと決定するようです。

AuthenticationProvider getAuthenticationProvider() {
  if (this.jwtConfigurer != null) {
    return this.jwtConfigurer.getAuthenticationProvider();
  }

  if (this.opaqueTokenConfigurer != null) {
    return this.opaqueTokenConfigurer.getAuthenticationProvider();
  }

  return null;
}

opaqueTokenConfiguregetAuthenticationProvider() が呼ばれ、OpaqueuTokenAuthenticationProvider  が返却されます。

AuthenticationProvider getAuthenticationProvider() {
  if (this.authenticationManager != null) {
    return null;
  }
  OpaqueTokenIntrospector introspector = getIntrospector();
  return new OpaqueTokenAuthenticationProvider(introspector);
}

OpaqueTokenIntrospector は Introspection の実際の動作を制御するインタフェースです。

OAuth2ResourceServerConfigure#configure(H http)

ここで BearerTokenAuthenticationFilter の登録が行われます。

これにより以降 Authorizatioin ヘッダに記述された Bearer トークンがあればそれを取り出して、然るべき処理を流すためのエントリーポイントとなる Filter が作られています。

また BearerToken を認識して解決するための BearerTokenResolverBearerTokenRequestMatcher に設定し、受けたリクエストの中に Bearer Token にマッチするものがあるかを確認する処理が追加されています。

Beanを探索して BearerTokenResolver インタフェースを実装したものが登録されていなければ DefaultBearerTokenResolver クラスがセットされます。

public final class DefaultBearerTokenResolver implements BearerTokenResolver {

    private static final Pattern authorizationPattern = Pattern.compile(
        "^Bearer (?<token>[a-zA-Z0-9-._~+/]+)=*$",
        Pattern.CASE_INSENSITIVE);

    private boolean allowFormEncodedBodyParameter = false;

    private boolean allowUriQueryParameter = false;

さらに AuthenticationManagerResolver の構成設定が行われています。AuthenticationManager も以下の通り jwtConfigure, oapqueTokenConfigure により選択されます。

AuthenticationManager getAuthenticationManager(H http) {
  if (this.jwtConfigurer != null) {
    return this.jwtConfigurer.getAuthenticationManager(http);
  }

  if (this.opaqueTokenConfigurer != null) {
    return this.opaqueTokenConfigurer.getAuthenticationManager(http);
  }

  return http.getSharedObject(AuthenticationManager.class);
}

また BearerTokenAuthenticationFilter には AuthenticationEntryPoint も設定されています。これは Bearer Token が付与されていなかった場合等に WWW-Authenticate ヘッダを返し、 Bearer Token による認証が必要である未認証エラーを返却するために利用されます。

private AuthenticationEntryPoint authenticationEntryPoint = new BearerTokenAuthenticationEntryPoint();

...

filter.setAuthenticationEntryPoint(this.authenticationEntryPoint);

OpaqueTokenAuthenticationProvider

AuthenticationProvider インタフェースを継承した Bearer Token を処理するためのクラスです。引数で渡された Authentication インタフェースは実際には BearerTokenAuthenticaitonToken (前段の BearerTokenAuthenticationFilter で作成したインスタンス)であるため、キャストを行い以降 BearerToken として扱います。

作成した際に引数で指定した OpaqueTokenIntrospectorintrospect() を呼び出し、ここで初めて Token Introspection の実際の動作を実行します。

これで一通りの準備ができました。

ざっくりとした処理フロー

基本的には BearerTokenAuthenticationFilter の動作を上から下まで読んだだけです。それぞれがインタフェースになっており、実体は Bean で登録されているものによって様々入れ替わります。

  1. リクエストはFilter Chainの中から BearerTokenAuthenticationFilter を通る
  2. BearerTokenResolver を通してAuthorizationヘッダからToken本体を抜き出す
  3. 取得した Token を引数に BearerTokenAuthenticationToken を作成 (AuthenticationRequest)
  4. AuthenticationManagerResolver に前段の BearerTokenAuthenticationToken を入力して、処理可能な AuthenticationManager を取得
  5. AuthenticationManager (この場合 ProviderManager) の authenticate() メソッドを実行し Token の有効性をチェック
    1. ProviderManager#authentication() の中を見ると、保持するProviderをfor文で回して対応している場合は処理しているのが分かる
    2. 今回は OpaqueTokenAuthenticationProvider が該当
    3. Providerは保持している OpaqueTokenIntrospector インタフェースの introspect() を呼び出す
    4. 実体は NimbusOpaqueTokenIntrospector 実際の処理はこのクラスが担当
  6. 正常に TokenIntrospection が完了したら SecurityContextに結果を保存して、後続の Filter へ処理を回す

とりあえず、必要な登場人物、ざっくりとした処理フローが確認できました。

まとめ

Spring Security 5 における Resource Server の設定と使い方、ざっくりとした処理フローを追いました。

汎用性が高く色々なところをカスタマイズ可能だということがわかったのですが、いかんせんインタフェースからコードを追うのはなかなか骨が折れました。

公式リファレンスの図がなければ ProviderManager の存在がわからなかった気がします。ProviderManager がどこで設定されてるかに関しては詳細追いきれませんでした。

We are hiring

自分が所属する事業開発部では、ヘッドレスコマースの prismatix を SpringBoot, Spring Security をメインに開発を行っております。諸事情あり、Spring Boot, Spring Security の深い部分の知見が失われてしまっており、現在数少ないメンバーで基盤の再構築を行っています。現在、各サービスの様々な新機能を開発すると共に、次世代基盤の開発や調査も行っています。SpringBoot, SpringSecurity, Javaバージョンのマイグレーションなど様々な施策が動いている状況です。

Help!

prismatix - recruit または classmethod - 募集要項 からご応募いただければ幸いです。