mike-neckのブログ

Java or Groovy or Swift or Golang

Spring Web Flux を少しさわってみただけの話

Spring 5 から Reactive Web というのになるらしく、サーバーの性能が落ちにくくなると聞いた(違うかもしれない)ので、 家で試してみることにした。

環境

  • Spring Boot: 2.0.0.RC1
  • Java: 1.8

ビルドファイル

以下の curl コマンドで取得できるもの

curl https://start.spring.io/build.gradle -d dependencies=security,webflux -d bootVersion=2.0.0.RC1

なお、依存ライブラリーは次のようになっている

dependencies {
  compile('org.springframework.boot:spring-boot-starter-security')
  compile('org.springframework.boot:spring-boot-starter-webflux')
  testCompile('org.springframework.boot:spring-boot-starter-test')
  testCompile('io.projectreactor:reactor-test')
  testCompile('org.springframework.security:spring-security-test')
}

ハンドラーファンクション

リクエストをさばくために、 org.springframework.web.reactive.function.server.ServerRequest を受け取って、 reactor.core.publisher.Mono または reactor.core.publisher.Flux を返す関数、または メソッドを作る

// クラス名は MyHandler
Mono<ServerResponse> hello(final ServerRequest serverRequest) {
  return ServerResponse.ok()
      .contentType(MediaType.APPLICATION_JSON)
      .body(
          Mono.just(new Message("hello", OffsetDateTime.now(ZoneId.of("Z")))),
          Message.class);
}

なお、jsonマッピングするクラスは次のようなクラス

@Value
static class Message {
  final String text;
  final OffsetDateTime time;
}

ルーターファンクション

ハンドラーファンクションに対して、URLをマッピングする、ルーターファンクション(org.springframework.web.reactive.function.server.RouterFunction)をBean登録する

// クラス名は MyConfig
@Bean
RouterFunction<ServerResponse> routerFunction(
    final SpringWebfluxDemoApplication springWebfluxDemoApplication) {
  return route(GET("/hello"), myHandler::hello);
}

なお、このメソッドのある MyConfig クラスには @org.springframework.web.reactive.config.EnableWebFlux アノテーションをつける

@EnableWebFlux
@Configuration
public class MyConfig {
  // 省略
}

セキュリティ

元々セキュリティをつけようとは思っていなかったのだが、間違えてつけてしまったので、その設定もしてみる

Reactive web ではないアプリケーションの場合は UserDetailsService を Bean 登録していたが、 Reactive web であるアプリケーションの場合には ReactiveUserDetailsService を Bean 登録する

ドキュメントを読んでいると、 MapReactiveUserDetailsService なるクラスがあるので、そちらを利用する

@Bean
ReactiveUserDetailsService userDetailsService() {
  final UserDetails userDetails = User.withDefaultPasswordEncoder()
      .username("foo")
      .password("bar")
      .roles("BAZ", "QUX")
      .build();
  return new MapReactiveUserDetailsService(userDetails);
}

いつもの

あとはいつものとおり、 @SpringBootApplication アノテーションをつけたクラスと、 main メソッドを作る

@SpringBootApplication
public class App {
  public static void main(String... args) {
    SpringApplication.run(App.class, args);
  }
}

動かしてみた

アプリケーションを起動して curl でアクセスしてみる

f:id:mike_neck:20180208030819p:plain

$ curl -v http://localhost:8080/hello -H "Authorization: Basic $(echo -n 'foo:bar' | openssl base64)"
*   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: */*
> Authorization: Basic Zm9vOmJhcg==
> 
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: application/json
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XSS-Protection: 1 ; mode=block
< 
* Connection #0 to host localhost left intact
{"text":"hello","time":1518024505.503000000}

まあ、なんか、動いた


OffsetDateTime のところがタイムスタンプになっていたので Jackson のカスタマイズ方法をドキュメントから探して CodecCustomizer を Bean 登録すればよいことがわかったのだが、これはうまくいかなかった…

@Bean
CodecCustomizer addJacksonJsr310Support() {
  return configurer -> configurer.customCodecs()
      .encoder(new Jackson2JsonEncoder(objectMapper(), MimeTypeUtils.APPLICATION_JSON));
}

おわり

同一のURLに同じHTTPメソッドで異なるパラメーターを送った時に異なるメソッドを割り当てる

同じURL(/foo/bar)に異なるメソッド(baz(Baz baz)qux(Qux qux) の二つのメソッド)を割り当てる方法

@Canonical
class Text {
    String text
}

@Canonical
class Bidirecional {
    String question
    String answer1
    String answer2
}

@Canonical
class Result<T> {
    T content
    int id
}

@RestController
@RequestMapping(path = '/app')
class AppController {

    def map = [:]

    def index = new AtomicInteger(1)

    @PostMapping(consumes = 'application/json', produces = 'application/json', headers = 'Type=text')
    ResponseEntity<Result<Text>> postText(@RequestBody Text text) {
        def i = index.getAndIncrement()
        map[i] = text
        println text
        ResponseEntity.ok(new Result(id: i, content: text))
    }

    @PostMapping(consumes = 'application/json', produces = 'application/json', headers = 'Type=bidirectional')
    ResponseEntity<Result<Bidirecional>> postBidirectional(@RequestBody Bidirecional item) {
        def i = index.getAndIncrement()
        map[i] = item
        println item
        ResponseEntity.ok(new Result(id: i, content: item))
    }
}

ヘッダーを変えると異なるメソッドでも同一のURLに割り当てられるらしい

spring-security-oauth を用いて実装したリソースサーバーでのトークンの有効期限

spring-security-oauth を用いてリソースサーバーを実装する場合、 tokenServices という名前の ResourceServerTokenServices を実装するクラスのビーンを作ります。これを実装する時に気をつける必要があるのが、 ResourceServerTokenServices に、 loadAuthentication(String) というメソッドでアクセストークンからユーザーの認証・認可情報を復元する際に、 アクセストークンの有効期限(expired_in) のチェックをする必要があるということ。 readAccessToken というメソッドで返す OAuth2AccessToken というメソッドに getExpiration とか getExpiresIn とか isExpired とかトークンの有効期限を確認するメソッドがいつ使われているのかデバッグしていたら、特にデバッグされていないようだったので、javadocを読み直している時に気づいた。

例えば、Springが提供している RemoteTokenServices の実装では loadAuthentication メソッドでリモートにある認証・認可サーバーにアクセスして、ユーザーの認証・認可を復元する。その際にリモートの認証・認可サーバーがトークンの有効期限をチェックして、それが切れていた時にエラーを返すので、その段階でアクセストークンが有効ではないとエラーを返せる。

しかし、データベースでの連携などで、この実装を使わない場合は、トークンの有効期限を実装しておかないと、Springは何もチェックしてくれないので、いつまでも同じトークンでアクセスし続けてしまう事態を招く。

おわり

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