Spring Boot CLI にて Spring Security OAuth2 によるリソースサーバーの実験
Spring Security OAuth2 を用いたリソースサーバーを、データベース連携で実装するパターンの実験をしてみた。
spring-security-oauth のドキュメント の Resource Server Configuration の部分を読むとリソースサーバーの実装については、次のように書いてある。
tokenServicesというResourceServerToeknServicesの実装クラスのビーンを登録する- リソースを識別するための
resourceIdを設定する(ただしオプショナル) Authorization: Bearer XXXX形式以外のトークンを使いたい場合はtokenExtractorというビーンを登録するHttpSecurityを設定する
というわけで、これから、 ResourceServerTokenServices の実装クラスを作ることにする。また、アクセストークンからユーザーを還元できたことを確認したいので、ユーザー名を返すAPIを作ることにする。
実行環境
実行環境はタイトルの通り、Spring Boot CLIを用いてGroovyで記述した。
- Spring Boot : 1.5.9.RELEASE
- JVM : 1.8
データ型
事前準備として以下の型を用意する
// アプリケーションに対して許可したユーザー class AuthenticatedUser { List<String> authorities String username } // アプリケーションとユーザーとスコープなど、アクセストークンから還元したデータ class AuthorizedApp { String clientId AuthenticatedUser user Set<String> scopes String redirectUri ZonedDateTime expiration String refresh String accessToken }
データベース(の代わり)
スキーマ等を作るのが面倒だったので、次のような固定データを持つリポジトリーを作る
@Repository class DbInstance { // データベースの代わりにマップを使う def map = [ AAA: new AuthorizedApp( clientId: 'aaa', user: new AuthenticatedUser(authorities: ['USER', 'ADMIN'], username: 'Mr.A'), scopes: ['items.read', 'items.write'] as Set, expiration: ZonedDateTime.now(ZoneId.of('Asia/Tokyo')).plusDays(100L), refresh: '636363', accessToken: 'AAA', redirectUri: 'https://example.com/app'), BBB: new AuthorizedApp( clientId: 'bbb', user: new AuthenticatedUser(authorities: ['USER'], username: 'Sr. BBB'), scopes: ['foo.read'] as Set, expiration: ZonedDateTime.now(ZoneId.of('Asia/Tokyo')).plusDays(80L), refresh: 'qwertyuiop', accessToken: 'BBB', redirectUri: 'https://example.com/bpp') ] // アクセストークンから認証・認可情報を返す AuthorizedApp findApplication(String accessToken) { def app = map[accessToken] if (app == null) { throw new InvalidTokenException("invalid token[$accessToken]") } return app } }
これらの準備をした後に、 tokenService を作る
@Component class TokenService implements ResourceServerTokenServices { final DbInstance dbInstance TokenService(DbInstance dbInstance) { this.dbInstance = dbInstance } @Override OAuth2Authentication loadAuthentication(String accessToken) { // AuthorizedApp クラスに OAuth2Authentication を返すようなメソッドを生やしておく dbInstance.findApplication(accessToken).authentication() } @Override OAuth2AccessToken readAccessToken(String accessToken) { // AuthorizedApp クラスに OAuth2AccessToken を返すようなメソッドを生やしておく dbInstance.findApplication(accessToken).accessToken() } }
ここで、コメントにもあるように AuthorizedApp クラスにメソッドを作成した。それぞれが返すオブジェクトは次のとおり。
OAuth2Authentication- 認可したユーザーに関する情報を保持するクラス
OAuth2RequestとAuthenticationの二つのオブジェクトを要求するOAuth2Requestには認可とトークンのリクエストの二つの情報を持つ。必要なパラメーターは、 リクエストパラメーター(Map<String, String>)、クライアントID(String)、認可したユーザーの権限(Collection)、認可の可否( boolean)、スコープ(Set<String>)、リソースID(Set<String>)、リダイレクトURI(String)、レスポンスタイプ(Set<String>)、拡張プロパティ(Set<String,Serializable>)Authenticationはユーザーの情報。ユーザーの権限(Collection<GrantedAuthority>)、クレデンシャル(パスワード等)、ディテール(IPアドレスなどのクレデンシャルへの付帯情報)、プリンシパル(主にUserDetails)、認証の状態(boolean)を持つ。
OAuth2AccessTokenはアクセストークンおよび、その期限に関する情報を持つ
あとはリソースサーバーに必要なものを揃える。まずコントローラー
@RestController @RequestMapping(path = '/name') class NameResource { @GetMapping Map<String, String> name(@AuthenticationPrincipal AuthenticatedUser user) { [name: user.username] } }
アクセストークンから、おそらくはユーザーが復元できることはここまでの実装でわかっているので、実際に狙ったユーザーがコントローラーのパラメーターとして渡ってくるかを確認している。そして、そのままレスポンスにしている。
次にセキュリティ設定
@EnableResourceServer class ResourceAppConfig extends ResourceServerConfigurerAdapter { final ResourceServerTokenServices tokenService ResourceAppConfig(ResourceServerTokenServices tokenService) { this.tokenService = tokenService } @Override void configure(HttpSecurity http) { http.authorizeRequests() .antMatchers(HttpMethod.GET, '/name/**').access("#oauth2.hasScope('items.read')") } @Override void configure(ResourceServerSecurityConfigurer resources) { resources.tokenServices(tokenService) } }
configure(HttpSecurity) にてアクセスするリソースに対して、どのようなスコープが必要かを設定できる。スコープの指定に使えるオブジェクトに oauth2 というオブジェクトがあるので、そちらでスコープを指定する。なお、ここの SpEL は IntelliJ だと補完が使える。
起動する
以下のコマンドを実行する
$ spring run oauth.groovy
この後におなじみの画面が表示されてアプリケーションが起動する
確認
まずは、ちゃんとアクセストークンを指定してリクエストしてみる。
$ curl -v http://localhost:8080/name -H "Authorization: Bearer AAA"
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /name HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Authorization: Bearer AAA
>
< 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: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Wed, 24 Jan 2018 16:30:35 GMT
<
* Connection #0 to host localhost left intact
{"name":"Mr.A"}
狙ったとおり、認可してAAAのアクセストークンをアプリケーションに渡したユーザーの名前が出ているので、こちらは成功した模様。
$ curl -v http://localhost:8080/name
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /name HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 401
< 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
< Cache-Control: no-store
< Pragma: no-cache
< WWW-Authenticate: Bearer realm="oauth2-groovy", error="unauthorized", error_description="Full authentication is required to access this resource"
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Wed, 24 Jan 2018 14:30:24 GMT
<
* Connection #0 to host localhost left intact
{"error":"unauthorized","error_description":"Full authentication is required to access this resource"}
こちらは401が返ってきたので、成功した模様。
$ curl -v http://localhost:8080/name -H "Authorization: Bearer hoge"
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /name HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Authorization: Bearer hoge
>
< HTTP/1.1 401
< 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
< Cache-Control: no-store
< Pragma: no-cache
< WWW-Authenticate: Bearer realm="oauth2-resource", error="invalid_token", error_description="invalid token[hoge]"
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Wed, 24 Jan 2018 14:32:21 GMT
<
* Connection #0 to host localhost left intact
{"error":"invalid_token","error_description":"invalid token[hoge]"}
こちらも401が返ってきたので、成功した模様。
最後に必要なスコープが認可されていないリソースへのアクセスをしてみる
$ curl -v http://localhost:8080/name -H "Authorization: Bearer BBB" * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 8080 (#0) > GET /apps HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.54.0 > Accept: */* > Authorization: Bearer BBB > < HTTP/1.1 403 < 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 < Cache-Control: no-store < Pragma: no-cache < WWW-Authenticate: Bearer error="insufficient_scope", error_description="Insufficient scope for this resource", scope="items.read" < Content-Type: application/json;charset=UTF-8 < Transfer-Encoding: chunked < Date: Wed, 24 Jan 2018 18:08:54 GMT < * Connection #0 to host localhost left intact {"error":"insufficient_scope","error_description":"Insufficient scope for this resource","scope":"items.read"}
こちらは403の insufficient scope と返ってきたので、こちらも成功した模様。
まとめ
以上、リソースサーバーの実験を Spring Boot CLI + Groovy で行ってみた。
- Spring Boot CLI 便利
- リソースサーバーを自前で実装する場合は
ResourceServerTokenServicesを実装して、tokenServiceというビーン名で登録する - Groovy最高