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最高