masato-ka's diary

日々思ったこととか、やったことの備忘録。

RestTemplateでトークン認証なAPIにアクセスした時のトークン有効期限切れに対応してみた。

この記事の概要

 この記事はSpringBoot 1.5.6でRestTemplateを使い、認証トークンの有効期限切れに対応した際の記録を紹介しています。SpringBootとSpringを熟知しているわけではないので内容に多分の誤解がふくまれているかもしれません。  トークン認証が必要なAPIにアクセスする場合に、事前に何らかの方法で認証トークンを取得し、リクエストヘッダにトークンをセットしてAPIにアクセスするの方法が一般的です。また、多くのAPIで認証トークンの漏洩などによる不正利用に対応するため、認証トークンには有効期限が設けられています。  そのため、RestTemplateでAPIにアクセスする直前に都度認証トークンを取得する必要があります。しかし、APIサーバへのリクエストが遅い場合(ネットワーク的に遠くにあるなど)2回アクセスに時間がかかりパフォーマンスを落としてしまいます。そこで、トークンをキャッシュに保存しておき、使い回す方法が考えられます。  しかし、この場合、キャッシュに保存された有効期限を考慮しなければ、期限切れのトークンでアクセスし、リクエストが受け付けられない事態が発生します。 この記事ではトークンの有効期限を考慮せずに、キャッシュに保存したトークンを使い回すため、アクセス時に認証エラーが返ってきた場合に再度トークンを取得し、リクエストをリトライする方法を紹介します。

キャッシュ事態に有効期限を設ける方法も考えられますが、Spring Cacheの利用を考えたばい、キャッシュに有効期限を設定できるかはキャッシュに利用する製品に依存します。(Redisを利用する場合はできるようです)

以下の3つを順を追って説明します。

1.認証トークンの取得とセット

2.認証トークンのキャッシュ化

3.トークン有効期限切れのリクエストリトライ

認証トークンの取得とセット

そもそもリクエストへの認証トークンの設定はRestTemplateのClientHttpRequestInterceptorインタフェースを実装したInterceptorの中で行います。実際のTokenの取得処理はTokenManagerクラスを作り、そちらで取得を行います。

  • ClientHttpRequestInterceptorを実装したRequestTokenInterceptor(ソース1)
@Component
public class RequestTokenInterceptor implements ClientHttpRequestInterceptor {

    private BTokenManager tokenManager;
    
    public TokenInterceptor(TokenManager tokenManager){
        this.tokenManager = tokenManager;
    }
    
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
            throws IOException {

        request.getHeaders().add("Authorization", "Bearer "+tokenManager.getAuthenticationToken());//(1)
        
        ClientHttpResponse response = null;
        
        response = execution.execute(request, body);
        
        return response;
    }

}

(1)リクエストの ヘッダにTokenManagerから取得したトークンをBearerトークンとして設定する。

また、この時、TokenManagerはAPIに対して何らかの認証処理を行い、API Tokenを取得しています。

  • TokenManager(ソース2)
@Component
public class TokenManager {
; 
    @Value("${bing.auth.url:}")
    private String authUrl;
    
    private RestTemplate restTemplate;
    
    public TokenManager(RestTemplateBuilder restTemplateBuilder, BasicAuthRequestInterceptor interceptors){
        restTemplate = restTemplateBuilder.additionalInterceptors(interceptors).build();//(1)
    }
    
    public String getAuthenticationToken(){
        URI uri = UriComponentsBuilder.fromUriString(authUrl).build().toUri();  
        String result = restTemplate.postForObject(uri, null, String.class);//(2)
        return result;
    }
    
}

(1) BasicAuthのためのヘッダを設定するInterceptorを認証処理用のRestTemplateに設定する。今回はToken取得処理の認証情報の設定もInterceptorを利用。 (2) getAuthenticationTokenメソッドが実行されると認証URIにPOSTリクエストを発行し、トークンを取得してからStringとして返す。

この方法ですと、APIにアクセスするたびに、認証リクエストが発行され、1回余分にリクエストが実行されることになります。

認証トークンのキャッシュ化

そこで、Spring Cacheの機能を使い、認証トークン取得メソッドの結果をキャッシュし、2回目以降のリクエストはキャッシュを利用するようにします。変更点はTokenManagerのみで、以下のように getAuthenticationTokenメソッドに@Cachableアノテーションを設定します。Spring Cacheを有効にするため、別途クラスの先頭に@CacheEnableを付与する必要があります。

  • トークン取得処理のメソッドのキャッシュ対応(ソース3)
@Cachable("authToken")   
public String getAuthenticationToken(){
    URI uri = UriComponentsBuilder.fromUriString(authUrl).build().toUri();  
    String result = restTemplate.postForObject(uri, null, String.class);//(2)
    return result;
}
    

これで、2回目のアクセス以降はキャッシュからトークンが返され、認証リクエストは実行されなくなります。ですが前述の通り、キャッシュに登録されているトークンの有効期限が切れた場合にAPIサーバからエラーで弾き返されることになります。APIサーバの使用にもよりますが、多くの場合401や403が帰ってくるかと思います。

トークン有効期限切れのリクエストリトライ

続いて、本題のリクエストリトライの方法です。まずTokenManagerにキャッシュをクリアするメソッドを追加します。以下のメソッドをTokenManagerに追加します。 特に処理はないですが、@CacheEvictアノテーションで指定したキャッシュが削除されます。

  • キャッシュクリアするメソッド(ソース4)
@CacheEvict(value="authToken")
public void clearCacheTokenInfo() {
    log.info("Rfresh token cache.");
}

APIへのリクエストが実行された時、RequestTokenInterceptor::interceptメソッドの中で40xのエラーを検知して、キャッシュをクリアし、新しく Tokenを取得して再度、リクエストを投げます。具体的には上記ソース1を以下のソース5のように修正します。

@Component
public class RequestTokenInterceptor implements ClientHttpRequestInterceptor {

    private BTokenManager tokenManager;
    
    public TokenInterceptor(TokenManager tokenManager){
        this.tokenManager = tokenManager;
    }
    
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
            throws IOException {

        request.getHeaders().add("Authorization", "Bearer "+tokenManager.getAuthenticationToken());
        
        ClientHttpResponse response = null;
        
        response = execution.execute(request, body);
        if(response.getStatusCode()==HttpStatus.FORBIDDEN || response.getStatusCode()==HttpStatus.UNAUTHORIZED){//(1)
                tokenManager.clearCacheTokenInfo();//(2)
                List<String> token = new ArrayList<>();
                token.add("Bearer "+tokenManager.getAuthenticationToken());
                request.getHeaders().put("Authorization", token);//(3)
                response = execution.execute(request, body);(4)
        }
        return response;
    }

}

(1) responseのステータスコードを確認して、FORBIDDENまたはUNAUTHORIZEDが帰って来ればリクエストリトライの処理を実行する。トークンエクスパイアの際の実際の挙動については各APIの仕様を調べる必要がある。 (2) リクエストリトライの処理の最初に現在のキャッシュをクリアする。 (3) 今回はAUthorizationヘッダを上書きするため、HttpHeaders::addではなく、HttpHeaders::putを使い値を上書きする。また、この時、TokenManager::getAuthenticationTokenを実行することで、再度トークンがキャッシュされる。 (4) リクエストを実行し、新しい認証トークンを使いAPIにアクセスする。

あとはAPIにアクセスするためのRestTemplateにRestTemplateBuilder経由でinterceptorを追加すれば、トークンの有効期限切れを機にすることなく、透過的にAPIにアクセスすることができます。

まとめ

そもそも認証トークンを一定期間キャッシュで保持するのは一般的なのか知りたいところです。今回の方法ではトークンの有効期限切れを避けるため、キャッシュの残存時間の設定を調整する必要がないのがミソでしょうか。