GeekFactory

int128.hatenablog.com

FeignでOAuth 2.0クライアントを使う

RESTクライアントのSpring Cloud Netflix FeignでOAuth 2.0を使う方法を説明します。

  • @FeignClientconfigurationOAuth2FeignRequestInterceptorを設定する。
  • OAuth2FeignRequestInterceptorにクライアントIDやクライアントクレデンシャルを渡す。

具体的な設定

@EnableOAuth2Clientアノテーションを付けます。

@EnableOAuth2Client
@EnableFeignClients
@SpringBootApplication
class App {
    static void main(String[] args) {
        SpringApplication.run(App, args)
    }
}

@FeignClientアノテーションにConfigurationクラスを指定します。

@FeignClient(name = 'hello', url = '${hello.service.url}', configuration = HelloClientConfiguration)
interface HelloClient {
}

ConfigurationクラスでOAuth2FeignRequestInterceptorを設定します。OAuth2FeignRequestInterceptorクラスはspring-cloud-securityに含まれるクラスで、OAuth 2.0アクセストークンの取得や利用をまるっとお任せできます。

@Configuration
class HelloClientConfiguration {
    @Value('${security.oauth2.hello.access-token-uri}')
    private String accessTokenUri

    @Value('${security.oauth2.hello.client-id}')
    private String clientId

    @Value('${security.oauth2.hello.client-secret}')
    private String clientSecret

    @Value('${security.oauth2.hello.scope}')
    private String scope

    @Bean
    RequestInterceptor oauth2FeignRequestInterceptor(OAuth2ClientContext oAuth2ClientContext) {
        new OAuth2FeignRequestInterceptor(oAuth2ClientContext, resource())
    }

    OAuth2ProtectedResourceDetails resource() {
        def details = new ClientCredentialsResourceDetails()
        details.accessTokenUri = accessTokenUri
        details.clientId = clientId
        details.clientSecret = clientSecret
        details.scope = [scope]
        details
    }

    @Bean
    Logger.Level feignLoggerLevel() {
        Logger.Level.BASIC
    }
}

application.ymlを設定します。

logging.level:
  # アクセストークンリクエストのログを有効にします
  org.springframework.web.client: DEBUG
  # Feignのリクエスト/レスポンスログを有効にします
  example.client.HelloClient: DEBUG

hello.service:
  url: http://localhost:8081

# Hystrixを無効化またはSEMAPHOREに変更する必要がある(後述)
feign.hystrix.enabled: false

security:
  oauth2:
    hello:
      access-token-uri: http://localhost:8081/oauth/token
      client-id: theId
      client-secret: theSecret
      scope: theScope
    # (参考) Twitter APIの場合はこんな感じ
    twitter:
      access-token-uri: https://api.twitter.com/oauth2/token

既知の問題

アクセストークンはセッション単位で管理する必要があるため、OAuth2ClientContextはセッションスコープで管理されます(OAuth2ClientContextConfiguration)。これにより、以下の問題があります。

まず、複数のFeignClientを利用すると同一のアクセストークンを利用してしまう問題があります。例えば、TwitterClientとFacebookClientがあって先にTwitterClientを実行した場合、OAuth2ClientContextにTwitter APIのアクセストークンが保存されてしまうため、Facebook APIはエラーになります。自分でセッションスコープのBeanを定義すれば解決しそうですが、まだ試していません。解決策を知っていたら教えてください。

また、FeignとHystrixを組み合わせて使えない問題があります。これはHystrixが別スレッドでHTTPリクエストを実行するため、セッションスコープのOAuth2ClientContextを参照できないエラーが発生するためです。Hystrixを無効化するか、SEMAPHOREモードに切り替える必要があります。(参考: Spring @FeignClient with OAuth2FeignRequestInterceptor not working - Stack Overflow

実行時にOAuth2ProtectedResourceDetailsを切り替える

上記の実装ではDIコンテナの起動時にOAuth2ProtectedResourceDetailsが生成されるため、複数のOAuth2ProtectedResourceDetailsを動的に切り替えるには工夫が必要です。例えば、ログイン状態に応じてClient Credentials GrantとResource Owner Password Grantを使い分けたいといった場合があります。

実行時にOAuth2ProtectedResourceDetailsを切り替えたい場合、FeignのRequestInterceptorを自分で実装します。

@Slf4j
class MultiGrantOAuth2FeignRequestInterceptor implements RequestInterceptor {
    //@Valueは省略

    @Inject
    private OAuth2ClientContext oAuth2ClientContext

    @Override
    void apply(RequestTemplate template) {
        new OAuth2FeignRequestInterceptor(oAuth2ClientContext, resource()).apply(template)
    }

    OAuth2ProtectedResourceDetails resource() {
        def authentication = SecurityContextHolder.context.authentication
        if (!authentication || authentication instanceof AnonymousAuthenticationToken) {
            // 未ログインの場合はClient Credentials Grantを使う
            def details = new ClientCredentialsResourceDetails()
            details.accessTokenUri = accessTokenUri
            details.clientId = clientId
            details.clientSecret = clientSecret
            details.scope = [scope]
            log.debug("ClientCredentialsResourceDetails")
            details
        } else {
            // ログイン済みの場合はResource Owner Password Grantを使う
            def details = new ResourceOwnerPasswordResourceDetails()
            details.accessTokenUri = accessTokenUri
            details.clientId = clientId
            details.clientSecret = clientSecret
            details.scope = [scope]
            // ユーザ情報からユーザ名を取得
            details.username = (authentication.principal as UserDetails).username
            details.password = 'theResourceOwnerPassword'  // TODO: 固定値ではなく動的に設定
            log.debug("ResourceOwnerPasswordResourceDetails: username=${details.username}")
            details
        }
    }
}

とりあえず、簡単に試すには以下のようなWebSecurityConfigを用意するとよいでしょう。

@Configuration
@EnableWebSecurity
class AppSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) {
        // ログイン後にセッションを再作成する
        http.sessionManagement().sessionFixation().newSession()

        // 未ログインでもアクセスできるようにする
        http.authorizeRequests().anyRequest().permitAll()

        // フォームログインを有効にする
        http.formLogin()
    }
}

注意点ですが、未ログイン時に取得したアクセストークンはClient Credentials Grantのものなので、ログイン後にResource Owner Password Grantのものを再取得する必要があります。そのため、sessionFixation().newSession()でログイン後にセッションを再作成するように設定しています。

この実装では以下のような挙動になります。

  1. http://localhost:8082/hello にアクセスすると、Client CredentialsのクライアントIDが表示される
  2. http://localhost:8082/login にアクセスしてフォームでログインする
  3. http://localhost:8082/hello にアクセスすると、リソースオーナーのユーザ名が表示される

下記にサンプルがあるので参考までにどうそ。

github.com