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) {
dbInstance.findApplication(accessToken).authentication()
}
@Override
OAuth2AccessToken readAccessToken(String accessToken) {
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
はアクセストークンおよび、その期限に関する情報を持つ
OAuth2AccessToken
にはスコープ(Set<String>
)、リフレッシュトークン(RefreshToken
)、トークンタイプ(Bearerなど)、
expiration(有効期限 Date
)などを保持する
あとはリソースサーバーに必要なものを揃える。まずコントローラー
@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:
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (
> 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
{"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最高