この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
はじめに
Spring Boot + Spring Security を利用して OAuth 2.0 の Token Introspection を利用した Resource Server を構成してみます。
環境
Java:
openjdk 11.0.6 2020-01-14 LTSSpringBoot
: 2.3.1.RELEASESpringSecurity
: 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
の依存を追加しただけでは足りません。
ここにあるように 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
}
}
- 今回
/health
エンドポイントのみ保護を除外します。 - ResourceServer を構成するために
oauth2ResourceServer()
を設定します - 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
OpaqueTokenConfigure
は TokenIntrospectionUri
, 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 はこれもまたインタフェースで実際の認証処理を行うメソッドが定義されています。ここでは以下のように jwtConfigure
か opaqueTokenConfigure
のどちらが設定されているかによって返却される AuthenticationProvider が変更されます。
jwtConfigure の方が優先しているように見えますが、いずれかしか設定できないため、実質どちらか一方のみが自ずと決定するようです。
AuthenticationProvider getAuthenticationProvider() {
if (this.jwtConfigurer != null) {
return this.jwtConfigurer.getAuthenticationProvider();
}
if (this.opaqueTokenConfigurer != null) {
return this.opaqueTokenConfigurer.getAuthenticationProvider();
}
return null;
}
opaqueTokenConfigure
の getAuthenticationProvider()
が呼ばれ、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 を認識して解決するための BearerTokenResolver
を BearerTokenRequestMatcher
に設定し、受けたリクエストの中に 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 として扱います。
作成した際に引数で指定した OpaqueTokenIntrospector
の introspect()
を呼び出し、ここで初めて Token Introspection の実際の動作を実行します。
これで一通りの準備ができました。
ざっくりとした処理フロー
基本的には BearerTokenAuthenticationFilter
の動作を上から下まで読んだだけです。それぞれがインタフェースになっており、実体は Bean で登録されているものによって様々入れ替わります。
- リクエストはFilter Chainの中から
BearerTokenAuthenticationFilter
を通る BearerTokenResolver
を通してAuthorizationヘッダからToken本体を抜き出す- 取得した Token を引数に
BearerTokenAuthenticationToken
を作成 (AuthenticationRequest) AuthenticationManagerResolver
に前段のBearerTokenAuthenticationToken
を入力して、処理可能なAuthenticationManager
を取得AuthenticationManager
(この場合ProviderManager
) のauthenticate()
メソッドを実行し Token の有効性をチェックProviderManager#authentication()
の中を見ると、保持するProviderをfor文で回して対応している場合は処理しているのが分かる- 今回は
OpaqueTokenAuthenticationProvider
が該当 - Providerは保持している
OpaqueTokenIntrospector
インタフェースのintrospect()
を呼び出す - 実体は
NimbusOpaqueTokenIntrospector
実際の処理はこのクラスが担当
- 正常に 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 - 募集要項 からご応募いただければ幸いです。