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

Feignでレスポンスボディによる例外処理を行う

REST APIクライアントのSpring Cloud Feignを使う場合に異常系のステータスコードをハンドリングする方法を調べたのでメモです。

デフォルトの振る舞い

APIが400番台や500番台のステータスコードを返した場合、FeignはFeignExceptionをスローします。これは下記の記事に書いた通りです。

int128.hatenablog.com

FeignExceptionにはステータスコードが含まれるので、例外オブジェクトのstatusプロパティで例外処理ができます。しかし、レスポンスボディで例外処理するのはこの仕組みではできません。

https://github.com/OpenFeign/feign/blob/master/core/src/main/java/feign/FeignException.java

public class FeignException extends RuntimeException {
  private static final long serialVersionUID = 0;
  private int status;

  //(中略)
  public static FeignException errorStatus(String methodKey, Response response) {
    String message = format("status %s reading %s", response.status(), methodKey);
    try {
      if (response.body() != null) {
        String body = Util.toString(response.body().asReader());
        message += "; content:\n" + body;
      }
    } catch (IOException ignored) { // NOPMD
    }
    return new FeignException(response.status(), message);
  }
}

レスポンスボディによる例外処理

レスポンスボディの内容を元に例外処理を行いたい場合を想定します。例えば、バリデーションエラーの時にステータスコード400でエラー理由がレスポンスボディに入るといった場合です。

このような場合、既存のFeignExceptionではレスポンスボディを読み取れないので、ErrorDecoderを使う必要があります。ErrorDecoderを使うと、APIが400番台や500番台を返した場合にどのような例外を発生させるか設定できます。詳しくは Custom error handling · OpenFeign/feign Wiki · GitHub に説明があります。

ここでは、ErrorDecoderでレスポンスボディをPOJOに変換してみましょう。Spring Cloud Feignに含まれるSpringDecoderを使うと、Springで用意されているConvertでレスポンスボディをオブジェクトに変換できます。

import feign.Response
import feign.codec.Decoder
import feign.codec.ErrorDecoder

class ErrorResponseDecoder implements ErrorDecoder {
    // Spring Cloud FeignのSpringDecoderが注入される
    @Inject
    Decoder decoder

    @Override
    Exception decode(String methodKey, Response response) {
        def errorResponse = decoder.decode(response, ErrorResponse) as ErrorResponse
        new ErrorResponseException(response.status(), errorResponse)
    }
}

// レスポンスボディの型
@Immutable
class ErrorResponse {
    int code
    String message
}

// レスポンスボディを含む例外クラス
class ErrorResponseException extends FeignException {
    final ErrorResponse errorResponse

    def ErrorResponseException(int status, ErrorResponse errorResponse1) {
        super(status, errorResponse1.toString())
        errorResponse = errorResponse1
    }
}

ErrorDecoderを有効にするにはFeignのConfigurationを使います。

@Configuration
class HelloClientConfiguration {
    @Bean
    ErrorDecoder errorDecoder() {
        new ErrorResponseDecoder()
    }
}

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

上記の仕組みを使うと、ErrorResponseExceptionをキャッチすることでレスポンスボディによる例外処理ができます。

レスポンスボディのパススルー

外部APIのエラーレスポンスをそのままクライアントに返したい場合は、@ControllerAdviceで共通処理を入れるとよいでしょう。

@Slf4j
@ControllerAdvice
class ResponseStatusPropagation {
    @ExceptionHandler(ErrorResponseException)
    ResponseEntity handleErrorResponseException(ErrorResponseException e) {
        log.error("Handled error response from API", e)
        def cause = e.cause as ErrorResponseException
        ResponseEntity
            .status(cause.status())
            .contentType(MediaType.APPLICATION_JSON)
            .body(cause.errorResponse)
    }
}

APIゲートウェイとして振る舞う場合に有用と思います。

Feignのエラーハンドリングに関する記事がとても少ないので書いてみました。ご参考まで。

Jenkins PipelineでGitリポジトリにpushする

JenkinsのジョブでGitリポジトリにブランチやタグをpushしたい場合があります。Jenkinsfileでどのように実装するか調べてみました。

実装例

お急ぎの方は下記のコードを参考にしてください。

def userRemoteConfig = scm.userRemoteConfigs.head()
withCredentials([[$class: 'UsernamePasswordMultiBinding', 
  credentialsId: userRemoteConfig.credentialsId,
  usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PASSWORD']]) {
  def url = userRemoteConfig.url.replace('://', "://${env.GIT_USER}:${env.GIT_PASSWORD}@")
  sh "git push $url --tags"
}

PipelineでSCMにGitが指定されている必要があります。また、上記は git push しか書いていませんが、実際にはユーザ名やメールアドレスを設定したり、 git add したりするコードも必要です。

どのように実現しているのか

Jenkins PipelineのJenkinsfileでは以下のコードをよく使います。

node {
  stage('Fetch') {
    checkout scm
  }
}

では、ここに出てくる scm 変数とは何者なんでしょうか。調べてみたらCloudbeeの記事に書いてありました。

support.cloudbees.com

SCMにGitを指定した場合、 scm 変数は GitSCM クラスインスタンスになります。GitSCM クラスには以下のメソッドが用意されています。

    public List<UserRemoteConfig> getUserRemoteConfigs() {...}

ここでいう UserRemoteConfig クラス こそがGitの接続情報になります。UserRemoteConfig クラスには以下のプロパティが定義されています。

  • name
  • refspec
  • url
  • credentialsId

ということは、GitSCM クラス→UserRemoteConfig クラス→credentialsIdプロパティ→withCredentialsの流れでGitの接続情報を取得できます。

ただし、Jenkins Pipelineの標準では scm 変数のプロパティに対するアクセスは禁止されています。このため、Jenkinsの設定から scm 変数のプロパティに対するアクセスを許可する必要があります。これは「Jenkinsの設定」で指定できます。

いつの日になるか分かりませんが、PipelineにGitアクセス機能が追加されるのが待ち遠しいですね。