mike-neckのブログ

JavaかJavaFXかJavaEE(なんかJava8が多め)

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
    • 認可したユーザーに関する情報を保持するクラス
    • OAuth2RequestAuthentication の二つのオブジェクトを要求する
    • 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://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最高