mike-neckのブログ

Java or Groovy or Swift or Golang

同一の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最高

Spring MVC で日本語URLにリダイレクトするとURLが文字化けしてしまう場合の対処

Spring MVC で日本語を含むURLにリダイレクトすると…

@GetMapping(path = "bar")
String somePage() {
  return "redirect:/to/日本語url";
}
$ curl -v http://localhost:8080/foo/bar
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /it/japanese HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 302 
< Location: http://localhost:8080/to/???url
< Content-Language: ja-JP
< Content-Length: 0
< Date: Wed, 03 Jan 2018 17:03:47 GMT
< 
* Connection #0 to host localhost left intact

日本語urlにリダイレクトされてほしいけど、日本語がURLエンコーディングされてなくて残念なやつ。

ちょっと調べたのでメモ。

仕組み

  1. ViewResolver (実装は UrlBasedViewResolver/AbstractCachingViewResolver) の resolveViewName から createView を経て RedirectView に解決される。
  2. 解決された RedirectViewDispatcherServlet から View#render(Map, HttpServletRequest, HttpServletResponse) が呼び出される。
  3. RedirectView の継承元の AbstractViewrender メソッドの実装があり、そこで必要なモデルを抽出、ヘッダーに値を設定した後に renderMergedOutputModel を呼び出す。
  4. RedirectView#renderMergedOutputModel(Map, HttpServletRequest, HttpServletResponse) にて引数として返したurlにクエリーなどを付加したurlを作る。
  5. RedirectView で先ほどのurlを HttpServletResponse.sendRedirect(String) にそのまま流す。

単純には次のような形で返すURLをエンコーディングすればよいだけ。

@GetMapping(path = "bar")
String somePage() throws Exception {
  return "redirect:/to/" + URLEncoder.encode("日本語", "UTF-8") + "url" ;
}

とはいえ、日本語URLが大量にあるアプリケーションだと、それもつらいので、次のようなクラスを作ってリダイレクトしてみるとよいかもしれない。

public class EncodingRedirectView extends RedirectView {

  public EncodingRedirectView(final String url, final boolean contextRelative) {
    super(encodeUrl(url), contextRelative);
  }

  private static String encodeUrl(final String url) {
    return Arrays.stream(url.split("/")).map(unhandle(p -> URLEncoder.encode(p, "UTF-8"))).collect(joining("/"));
  }

  private interface UnHandleFunction<A, B> {
    B apply(final A a) throws Exception;
  }

  private static <A, B> Function<A, B> unhandle(final UnHandleFunction<? super A, ? extends B> function) {
    Objects.requireNonNull(function);
    return a -> {
      try {
        return function.apply(a);
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    };
  }
}

むしろよい方法あったら、誰か教えて…


2018/01/04 9:42 追記

次のように RedirectAttributes を使う方法があるらしい

@GetMapping
String somePage(final RedirectAttributes attributes) {
  attributes.addAttribute("path", "日本語url");
  return "redirect:/to/{path}";
}