8
13

More than 3 years have passed since last update.

[Spring Security] 事前認証シナリオ用に提供されたクラスを元に、API キーによる認証認可の実装例を用意・解説した。

Last updated at Posted at 2021-08-07

はじめに

なぜ記事を書いたか?

直近で携わったプロダクトでは、Spring Securityを用いて、APIキーによる認証・認可 を実装しました。

詳しくは後述しますが、Spring Security を通じた APIキーによる認証・認可 は、公式から特に実装例等は公開されていないため、

testtest.png
公式ドキュメント: 事前認証シナリオより引用

実装する際、公式ドキュメントSpring Securityのコード を反復しながら、認証・認可の流れを体系的を理解し、コードに落とし込むという進め方をしました。

上記の進め方の難点は、「体系的に理解をするハードルが高い」ということです。
Github内検索や、Google検索にて site:github.com 句を多様しつつ、各クラスを転々と読み込みながら、処理の流れを体系的に理解するということは、なかなか骨が折れます。

個人的に、処理の流れを体系的な理解をする際は、ミニマムな実装例を用意し、その実装例を元に、公式ドキュメントを読んでいく進め方が効率が良いと感じています。

今回はこのような背景から、Spring Security を通じた、 APIキーによる認証・認可認証・認可の流れを体系的を理解するためのミニマムな実装例と、解説を記事として用意しました。

この記事が、同様のプロジェクトに着手する開発者にとって、体系的な理解を助けるものとなれたら幸いです。

なお、ソースは全てGithub:y-tomimoto/spring-security-demoから閲覧できます。

想定読者

  • Spring Boot でアプリケーションを構築したことがあり、今後Spring Security にも触れたいと思っている人
  • Spring Boot で Spring Security を導入済みだが、どのように動作しているのか把握できている自信がない人
  • Spring Security で認証方式を実装したことがあるが、応用する自信がない人
  • Spring Security を体系的に理解したい人

Spring Security で提供されている認証方式について

Spring Security では、いくつかの認証方式がデフォルトでサポートされており、各方式毎に、それらを容易に実現するクラスが提供されています。

APIキーによる認証方式について

ただし、APIキーによる認証方式は、上記の通りではありません。

APIキーによる認証方式専用のクラスは提供されていない(※1)ため、Spring Security を 体系的に理解した上で、提供されている複数のクラスを適切に組み合わせて認証を実現する必要があります。

(※1) APIキーによる認証方式専用のクラスは提供されていない: 改めて後述しますが、APIキーによる認証方式は、Spring Security では事前認証シナリオのケースに該当します。Spring Securityでは、事前認証用に提供されているクラス群が存在するため、そのクラス群を活用する必要があります。なお、以下に記載の通り、事前認証シナリオを実装する際、概要のみが公開されており、実装に関しては、自身でソースを読みながら実装を進める必要があります。

testtest.png
公式ドキュメント: 事前認証シナリオより再掲

記事の構成

  1. 前提知識
  2. APIキーによる認証の実装例
  3. 認証の実装例の解説
  4. APIキーによる認証・認可の実装例
  5. 認可の実装例の解説
  6. 最後に

1.前提知識はこちら

ここでは、Spring Securityを初めて触る方に向けて、抽象的にSpring Securityの仕組みをおさらいしています。

既にSpring Securityがどのように動作するかイメージできている場合は、Skipしてください。

Spring Security での大まかな認証の流れ

実装例を解説する前に、認証がどのように行われるのかを、かんたんにおさらいしてみます。

Spring Bootで提供されている各認証方式では、大きく以下の2つのフローを実行しています。

  1. リクエストから認証情報を取得
  2. その認証情報が正しいものか確認

これら2つの認証フローは、各認証方式毎に用意された AuthenticaitonFilter と名の付くクラス(※2)で実行されています。

それでは、AuthenticaitonFilter クラス内で、

  1. リクエストから認証情報を取得
  2. その認証情報が正しいものか確認

という2つのフローをどのように実行しているか、具体的に確認していきます。


AuthenticaitonFilter と名の付くクラス内では、おおまかに下記のような処理を行っています。

  1. [認証フロー] リクエストから認証情報を取得

    1. [AuthenticaitonFilter] リクエストから認証情報を抽出する
    2. [AuthenticaitonFilter] 抽出した認証情報を元に AuthenticationToken を作成する
  2. [認証フロー] その認証情報が正しいものか確認

    1. [AuthenticaitonFilter] 生成した AuthenticationToken を用いて AuthenticationManager が認証処理を実行する
    2. [AuthenticaitonFilter] 認証の確認が正常に終了する

ここで初めて登場した

  • AuthenticationToken
  • AuthenticationManager

について簡単に説明します。

AuthenticationToken

リクエストには、様々な形式で認証情報が含まれています。

  • Authorization Header として認証情報が含まれているケース
  • Postパラメータとして認証情報が含まれているケース
  • etc...

抽出したこれらの認証情報が格納し、以降の認証処理で参照するためのオブジェクトが AuthenticationToken です。

AuthenticationManager

AuthenticationManager には、認証情報が正しいかどうかを確認するための認証処理が格納 されています。

抽象的ではありましたが、Spring Security内での認証のフローは以上のようになります。

(※2) AuthenticaitonFilterと名の付くクラスを実装するための基底クラス・インターフェースは複数あります。そして、これらを元に実装されたAuthenticaitonFilterクラス内の認証フローに冠する処理は共通している部分が多いです。

AuthenticaitonFilter を実装するための基底クラス・インターフェース:
AbstractAuthenticationProcessingFilter,OncePerRequestFilter,AbstractPreAuthenticatedProcessingFilter

前提知識のまとめ

Spring Securityでは、AuthenticaitonFilter と名の付くクラスで、以下の手順で認証を行っています。

  1. リクエストから認証情報を抽出する
  2. 抽出した認証情報を元に AuthenticationToken を作成する
  3. 生成した AuthenticationToken を用いて AuthenticationManager が認証処理を実行する
  4. 認証の確認が正常に終了する

抽象的に認証の流れをおさらいしたところで、
実際にAPIキーによる認証の実装例を確認してみましょう。

2. APIキーによる認証の実装例

以下はSpring SecurityでAPIキーによる認証を実装するミニマムな構成です。

認証・認可のうち、このbranchでは、認証のみを実装しています。

認証と認可の違いについて: https://dev.classmethod.jp/articles/authentication-and-authorization-again/

Repository

ディレクトリ構成

demo
├── DemoApplication.java
├── controller
│   └── HelloWorldController.java
└── security
    ├── config
    │   └── MyWebSecurityConfigurer.java
    ├── exception
    │   └── handler
    │       └── authentication
    │           └── MyAuthenticationFailureHandler.java
    ├── filter
    │   └── MyPreAuthenticatedProcessingFilter.java
    ├── model
    │   └── MyUserDetails.java
    └── service
        └── MyAuthenticationUserDetailsService.java

用意したクラス

HelloController.java

package com.example.demo.controller;

...

@RestController
public class HelloWorldController {
  @GetMapping("/hello")
  String hello() {
    return "Hello";
  }
  @GetMapping("/world")
  String world() {
    return "World";
  }
}

MyWebSecurityConfigurer.java

package com.example.demo.security.config;

...
@Configuration
@EnableWebSecurity
class MyWebSecurityConfigurer extends WebSecurityConfigurerAdapter {

  @Override
  public void configure(WebSecurity web) {
    web.ignoring().antMatchers("/error");
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.addFilter(getMyPreAuthenticatedProcessingFilter());
  }

  @Override
  protected void configure(AuthenticationManagerBuilder auth) {
    auth.authenticationProvider(getPreAuthenticatedAuthenticationProvider());
  }

  public AbstractPreAuthenticatedProcessingFilter getMyPreAuthenticatedProcessingFilter() throws Exception {
    var myPreAuthenticatedProcessingFilter = new MyPreAuthenticatedProcessingFilter();
    myPreAuthenticatedProcessingFilter.setAuthenticationManager(authenticationManager());
    myPreAuthenticatedProcessingFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler());
    myPreAuthenticatedProcessingFilter.setCheckForPrincipalChanges(true);
    myPreAuthenticatedProcessingFilter.setInvalidateSessionOnPrincipalChange(true);
    return myPreAuthenticatedProcessingFilter;
  }

  public PreAuthenticatedAuthenticationProvider getPreAuthenticatedAuthenticationProvider() {
    var preAuthenticatedAuthenticationProvider = new PreAuthenticatedAuthenticationProvider();
    preAuthenticatedAuthenticationProvider.setPreAuthenticatedUserDetailsService(new MyAuthenticationUserDetailsService());
    return preAuthenticatedAuthenticationProvider;
  }
}


MyPreAuthenticatedProcessingFilter.java

package com.example.demo.security.filter;

...

public class MyPreAuthenticatedProcessingFilter extends AbstractPreAuthenticatedProcessingFilter {

  @Override
  protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
    return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)).orElse("");
  }

  @Override
  protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
    return "";
  }

}

MyAuthenticationUserDetailsService.java

package com.example.demo.security.service;

...

public class MyAuthenticationUserDetailsService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {

  @Override
  public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) {

    var myUserDetails = new MyUserDetails();

    var key = token.getPrincipal().toString();

    /**
     * Check API Key
     */
    if (!(key.equals("hello") || key.equals("world"))) {
      throw new UsernameNotFoundException("Invalid key.");
    }

    myUserDetails.setKey(key);

    return myUserDetails;
  }
}

MyUserDetails.java

package com.example.demo.security.model;

...

public class MyUserDetails implements UserDetails {

  private String key;

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return null;
  }

  @Override
  public String getPassword() {
    return null;
  }

  @Override
  public String getUsername() {
    return key;
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }
}

MyAuthenticationFailureHandler

package com.example.demo.security.exception.handler.authentication;

...

public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
    /**
     * API Key Reset Link
     */
    response.sendRedirect("https://www.google.com");
  }
}

認証情報が含まれている箇所

今回は、Authorization header に認証情報が含まれているものとします。

curl --location --request GET 'localhost:9800/hello' \
--header 'Authorization: hello'

3.認証の実装例の解説

MyWebSecurityConfigurer.java

まずは、MyWebSecurityConfigurer.java について解説します。

package com.example.demo.security.config;

...
@Configuration
@EnableWebSecurity 
class MyWebSecurityConfigurer extends WebSecurityConfigurerAdapter { ・・・①

  @Override
  public void configure(WebSecurity web) {
    web.ignoring().antMatchers("/error"); ・・・②
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.addFilter(getMyPreAuthenticatedProcessingFilter()); ・・・③
  }

  @Override
  protected void configure(AuthenticationManagerBuilder auth) {
    auth.authenticationProvider(getPreAuthenticatedAuthenticationProvider()); ・・・④
  }

  public AbstractPreAuthenticatedProcessingFilter getMyPreAuthenticatedProcessingFilter() throws Exception {
    var myPreAuthenticatedProcessingFilter = new MyPreAuthenticatedProcessingFilter(); 
    myPreAuthenticatedProcessingFilter.setAuthenticationManager(authenticationManager()); ・・・⑤
    myPreAuthenticatedProcessingFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler()); ・・・⑥
    myPreAuthenticatedProcessingFilter.setCheckForPrincipalChanges(true); ・・・⑦
    myPreAuthenticatedProcessingFilter.setInvalidateSessionOnPrincipalChange(true); ・・・⑦
    return myPreAuthenticatedProcessingFilter;
  }

  public PreAuthenticatedAuthenticationProvider getPreAuthenticatedAuthenticationProvider() {
    var preAuthenticatedAuthenticationProvider = new PreAuthenticatedAuthenticationProvider();
    preAuthenticatedAuthenticationProvider.setPreAuthenticatedUserDetailsService(new MyAuthenticationUserDetailsService()); ・・・⑧
    return preAuthenticatedAuthenticationProvider;
  }
}


EnableWebSecurity アノテーションと WebSecurityConfigurerAdapter クラスについて

@Configuration アノテーションが付与されている WebSecurityConfigurer インターフェースの実装クラスに、@EnableWebSecurity アノテーションを付与することで、実装クラス内に記載した任意の認証方式(※3)をSpring Boot アプリケーションに適用することができます。

Spring Security には WebSecurityConfigurerAdapter という、任意の認証方式を実装のするためのクラスが用意されています。

WebSecurityConfigurer インターフェースを自ら実装するのではなく、WebSecurityConfigurer インターフェースを実装した WebSecurityConfigurerAdapter クラスを拡張し、任意の認証方式(※3)を実装するのが一般的です。このことは、Spring Securityのコード内にもコメントとして記載されています。

ソースコード内のコメントより: Add this annotation to an @Configuration class to have the Spring Security configuration defined in any WebSecurityConfigurer or more likely by extending the WebSecurityConfigurerAdapter base class and overriding individual methods

今回はセオリーに乗っ取り、@Configuration アノテーションが 付与されている WebSecurityConfigurerAdapter クラスを継承したクラスに、@EnableWebSecurity アノテーション を付与しています。

(※3)任意の認証方式デフォルトの認証方式: 今回Spring Securityを利用するために、Spring Initializer を通じて下記をdependencyに追加しています。

implementation 'org.springframework.boot:spring-boot-starter-security'

この段階で、Spring Boot アプリケーション内に、@EnableWebSecurity アノテーションが付与されたWebSecurityConfigurer インターフェースの実装クラスが存在しない場合、デフォルトの認証方式が有効になります。デフォルトの認証方式については後述します。

②ignoreについて

WebSecurityを引数に取るconfigureメソッドでは、主に認証外とするリソースを宣言します。
ここで /error パスを認証外としているのには理由があります。

上記のEnableAutoConfigurationにより、Spring MVCエラーコントローラーが自動構成されます。この機能により、内部エラーは全て /error へリダイレクトされます。


このとき、/error パスが認証対象となっていると、そのリダイレクト自体にも認証処理が実行されてしまい、Spring Securityの動作を把握しづらくなってしまいます。

そのため、運用・開発上の観点から、上記をignore対象としています。

③PreAuthenticatedProcessingFilterを追加する

    http.addFilter(getMyPreAuthenticatedProcessingFilter()); ・・・③

認証処理は、AuthenticationFilterという名のつくクラス内で実行されます。

今回はAPIキーによる認証方式を採用するので、AbstractPreAuthenticatedProcessingFilterインターフェースの実装クラス、MyPreAuthenticatedProcessingFilterを追加(※4)しています。

(※4)なぜ AbstractPreAuthenticatedProcessingFilter の実装クラスを追加しているのか?

 HOT PEPPERは、お店を検索するための グルメサーチAPIを公開しています。このAPIを利用するためには、メールアドレスとパスワードを用いて会員登録する必要があります。会員登録後、ユーザはAPIキーを発行され、そのAPIキーをリクエストに含めることで、グルメサーチAPIを利用することができます。

このように、サービスの利用にAPIキーを要するサービスは、APIキーの発行前に、事前にユーザに対してなんらかの認証を課しているケースがほとんどです。

(事実、HOT PEPPERではAPIキーを不特定多数に発行しているのではなく、有効なメールアドレスを保持するユーザに対してのみ発行を行っていると言えます。)

このように、ユーザがAPIキーを保持している時点で、そのユーザは、なんらかの形式で、サービスの主体から認証済みであると捉えることができます。

よって、APIキーによる認証は、Spring Securityで掲げる、事前認証シナリオに該当すると考えられます。事前認証シナリオに該当する場合に、利用が推奨されているFilterこそ、AbstractPreAuthenticatedProcessingFilterです。

③-1 MyPreAuthenticatedProcessingFilter はどこに addFilter されるのか?

addFilter句について解説する前に、サーブレットフィルターについておさらいします。

Spring Securityは、サーブレットフィルターの仕組みの上で構成されています。

サーブレットフィルター

サーブレットフィルターには、リクエストの前処理やサーバー・レスポンスの後処理を記載することができます。
Webアプリケーション内の複数のエンドポイントに対して、共通の処理を追加したいときに有効です。

Filterをイメージするための例


  public void doFilter(ServletRequest request,ServletResponse response,FilterChain chain)
    throws ServletException,IOException{
    // リソースにリクエストが到達する前にリクエストを加工する
    request.setCharacterEncoding("UTF-8");

    // 次のFilterを実行する or
    // 自身が最後のFilterなら、本来リクエストが投げられるはずのエンドポイントにリクエストする
    chain.doFilter(request,response);

    // リソースから帰ってきたレスポンスを加工する
    response.setContentType("application/json");
  }

共通の処理の例: リクエストやレスポンスの暗号化、認証、ロギング、監査、データ圧縮およびキャッシュ

Spring Security も例に漏れず、複数のエンドポイントに対して 認証 という処理を追加する役割を担うので、サーブレットフィルターの仕組みの上で構築されています。

デフォルトで構成されるSecurity Filter

では早速、Spring Securityを構成するサーブレットフィルターの中身を確認してみましょう。

Spring Securityを利用するにあたり、Spring Initializerを通じて通じて、依存関係に下記を追加しています。

    implementation 'org.springframework.boot:spring-boot-starter-security'

Spring Boot アプリケーション内に、@EnableWebSecurity アノテーションが付与されたWebSecurityConfigurer インターフェースの実装クラスが存在しない場合、Spring Securityはデフォルトの認証方式を自動構成します。

デフォルトで追加されるSpring Securityを構成するサーブレットフィルターにより、いくつかの機能が有効になります。

Spring Bootを起動するとき、デフォルトで構成されるSpring Securityを構成するサーブレットフィルターがコンソールのログから確認できます。

tips: application.propertiesに logging.level.org.springframework.security=TRACE
を配置することで、Spring Securityの流れがより可視化されます。

INFO 177 --- [main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [

  org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@2f84acf7, 
  org.springframework.security.web.context.SecurityContextPersistenceFilter@712cfb63, 
  org.springframework.security.web.header.HeaderWriterFilter@36681447, 
  org.springframework.security.web.csrf.CsrfFilter@50850539, 
  org.springframework.security.web.authentication.logout.LogoutFilter@7e642b88, 
  org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@671d1157, 
  org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@6b350309, 
  org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@291373d3, 
  org.springframework.security.web.authentication.www.BasicAuthenticationFilter@618ff5c2, 
  org.springframework.security.web.savedrequest.RequestCacheAwareFilter@15639440, 
  org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@151bf776, 
  org.springframework.security.web.authentication.AnonymousAuthenticationFilter@372ca2d6, 
  org.springframework.security.web.session.SessionManagementFilter@726aa968, 
  org.springframework.security.web.access.ExceptionTranslationFilter@4d95a72e, 
  org.springframework.security.web.access.intercept.FilterSecurityInterceptor@6f94fb9d

]

デフォルトで構成される機能一覧は、公式ドキュメントからも確認することができます。

任意の認証方式を構成するためのSecurity Filter

今回は、任意の認証方式を実現するために、@EnableWebSecurity でアノテートされたWebSecurityConfigurerAdapter を拡張したクラスを用意しています。

@EnableWebSecurity でアノテートされたWebSecurityConfigurerAdapter を拡張したクラスでは、任意の認証方式の土台となるサーブレットフィルターをデフォルトで構成します。

土台となるサーブレットフィルターには、今後追加される任意の認証方式を受け入れるため、認証部分を実行するサーブレットフィルターは含まれておらず、自動構成のときよりも適用されているサーブレットフィルターが少なくなっています。

INFO 274 --- [main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [

  org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@642505c7, 
  org.springframework.security.web.context.SecurityContextPersistenceFilter@40e60ece, 
  org.springframework.security.web.header.HeaderWriterFilter@5fac521d, 
  org.springframework.security.web.csrf.CsrfFilter@782bf610, 
  org.springframework.security.web.authentication.logout.LogoutFilter@476e8796, 
  org.springframework.security.web.savedrequest.RequestCacheAwareFilter@3a230001, 
  org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@4d654825, 
  org.springframework.security.web.authentication.AnonymousAuthenticationFilter@4339e0de, 
  org.springframework.security.web.session.SessionManagementFilter@706eab5d, 
  org.springframework.security.web.access.ExceptionTranslationFilter@1846579f

]

ここでようやく

Q.MyPreAuthenticatedProcessingFilter はどこにaddFilterされるのか?

について回答することができます。

MyPreAuthenticatedProcessingFilter は下記にaddされます。


  org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@642505c7, 
  org.springframework.security.web.context.SecurityContextPersistenceFilter@40e60ece, 
  org.springframework.security.web.header.HeaderWriterFilter@5fac521d, 
  org.springframework.security.web.csrf.CsrfFilter@782bf610, 
  org.springframework.security.web.authentication.logout.LogoutFilter@476e8796, 

+ com.example.demo.security.filter.MyPreAuthenticatedProcessingFilter@XXXXXXXX,

  org.springframework.security.web.savedrequest.RequestCacheAwareFilter@3a230001, 
  org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@4d654825, 
  org.springframework.security.web.authentication.AnonymousAuthenticationFilter@4339e0de, 
  org.springframework.security.web.session.SessionManagementFilter@706eab5d, 
  org.springframework.security.web.access.ExceptionTranslationFilter@1846579f

なぜこの位置に配置されるのかについて解説したいと思います。

Spring Securityでは、FilterComparator クラスにて、Spring Securityを構成するサーブレットフィルター(以下Security Filterと呼ぶ)の構成順序を各Security Filterのインターフェースにより管理しています。

    FilterComparator() {
        int order = 100;
        put(ChannelProcessingFilter.class, order);
        order += STEP;
        put(ConcurrentSessionFilter.class, order);
        order += STEP;
        put(WebAsyncManagerIntegrationFilter.class, order);
        order += STEP;
        put(SecurityContextPersistenceFilter.class, order);
        order += STEP;
        put(HeaderWriterFilter.class, order);
        order += STEP;
        put(CsrfFilter.class, order);
        order += STEP;
        put(LogoutFilter.class, order);
        ...

FilterComparator: https://github.com/spring-projects/spring-security/blob/ae6af5d73ce77f93926379f96371e84b1e9401f1/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterComparator.java#L61

AbstractPreAuthenticatedProcessingFilter インターフェースも、Comparator内で位置が管理されています。

        put(LogoutFilter.class, order);
        order += STEP;
        put(X509AuthenticationFilter.class, order);
        order += STEP;

        put(AbstractPreAuthenticatedProcessingFilter.class, order);
        order += STEP;

        filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter",order);
        order += STEP;
        put(UsernamePasswordAuthenticationFilter.class, order);
        order += STEP;
        put(ConcurrentSessionFilter.class, order);

MyPreAuthenticatedProcessingFilterAbstractPreAuthenticatedProcessingFilter インターフェースで実装されているため、addFilter句を用いてSecurity Filterを追加した場合、Comparator内で管理されているインターフェースと一致する位置にFilterが挿入されます。

他のSecurity Filterもこの順序どおりに配置されている(※5)ので、気になる方は確認してみてください。

(※5)なぜ位置が決まっているのか?: AuthenticationFilterを実行するにあたり、Filterによっては、AuthenticationFilterより前のFilterで生成されている特定のObjectが必要になるケースもあります。このように、Filterによって事前に作成されているべきObjectがあったり、直後のFilterの動作を監視し、その結果によって処理を行うようなFilterもあります。このような理由から、それぞれのFilterには、固定の位置があるのです。

AbstractAuthenticationProcessingFilterの場合: ... 認証が成功した場合、結果の Authentication オブジェクトは現在のスレッドの SecurityContext に配置されます。これは、以前のフィルターによってすでに作成されていることが保証されています。

④AuthenticationProviderをsetする

    auth.authenticationProvider(getPreAuthenticatedAuthenticationProvider()); ・・・④

抽象的にSpring Securityにおける認証の流れをおさらいしたセクションにて、

生成した AuthenticationToken を用いて AuthenticationManager が認証処理を実行する

という節がありました。

先程は抽象的に解説しましたが、実際に認証処理を行うのは、AuthenticationManager ではありません。

実際に認証処理を行うのは、AuthenticationProvider です。

ここでは、AuthenticationToken を受け取る AuthenticationManager に対して、PreAuthenticatedAuthenticationProvider をsetしています。

AbstractPreAuthenticatedProcessingFilter 内で生成された AuthenticationToken は、このProvider内で認証処理に利用されます。

AuthenticationManagerAuthenticationProvider の関係について

AuthenticationManager 内には、複数の AuthenticationProvider を保持できるようになっています。受け取った AuthenticationToken を用いて実際の認証処理を実行するのは、AuthenticationManager に外部からsetされた AuthenticationProvider です。AuthenticationManager は複数の認証処理を束ね、受け取ったTokenを適切なProviderに流す役割を担っています。

④-1: なぜ PreAuthenticatedAuthenticationProvider をsetしているのか?

前述の通り、AuthenticationManager では複数の AuthenticationProvider を管理します。

AuthenticationManager では、認証時に受け取った AuthenticationToken の型に一致する AuthenticationProvider がHitするまで、認証処理を問い合わせるという仕様になっています。

AuthenticationManagerの実装クラス内で、AuthenticationToken に一致する AuthenticationProvider を検知する処理:

 // ここでTokenの型を取得して
 Class<? extends Authentication> toTest = authentication.getClass(); 
 ...
 int currentPosition = 0;
 int size = this.providers.size();
 // ここでProviderが受け入れる型と一致するか確認している
 for (AuthenticationProvider provider : getProviders()) {
     if (!provider.supports(toTest)) {
         continue;
     }
   ...

今回 AbstractPreAuthenticatedProcessingFilter でリクエストから抽出した認証情報を格納しているのは、

PreAuthenticatedAuthenticationToken

です。

AbstractPreAuthenticatedProcessingFilterにて抽出した認証情報を格納している部分:

PreAuthenticatedAuthenticationToken authenticationRequest = new PreAuthenticatedAuthenticationToken(principal, credentials);

この型を持つTokenを元に認証処理を行うのが、PreAuthenticatedAuthenticationProvider となっているため、今回 AuthenticationManager に当該Providerをsetしています。

⑤FilterにAuthenticaitonManagerをsetする

    myPreAuthenticatedProcessingFilter.setAuthenticationManager(authenticationManager()); ・・・⑤

再掲しますが、MyPreAuthenticatedProcessingFilter 内では、認証情報を抽出して、認証に利用するための PreAuthenticatedAuthenticationToken を生成します。

このTokenを用いて認証処理を開始するための、AuthenticationManager をsetしています。

WebSecurityConfigurerAdapterauthenticationManager() メソッドは、共有の AuthenticationManager を返します

そのため、先程setした PreAuthenticatedAuthenticationProvider がsetされた AuthenticationManager が、myPreAuthenticatedProcessingFilter 内で有効になります。

⑥AuthenticationFailureHandlerをFilterにsetする

    myPreAuthenticatedProcessingFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler()); ・・・⑥

AbstractPreAuthenticatedProcessingFilter 内で、PreAuthenticatedAuthenticationToken を用いて、AuthenticationManager にて認証処理を開始する際、認証処理に失敗し、Manager内で認証例外がthrowされた場合、filterに付与された unsuccessfulAuthentication() が実行されます。

Managerが認証例外をthrowした際にunsuccessfulAuthenticationが実行される部分:

catch (AuthenticationException ex) {     
  unsuccessfulAuthentication(request, response, ex);
  if (!this.continueFilterChainOnUnsuccessfulAuthentication) {
    throw ex;
  }
}

unsuccessfulAuthentication() では、filter内の authenticationFailureHandler を実行します。

各認証方式のFilterは、デフォルトの authenticationFailureHandler として、SimpleUrlAuthenticationFailureHandler が指定されています。

AbstractAuthenticationProcessingFilter はデフォルトで SimpleUrlAuthenticationFailureHandler が指定されている:

private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();

しかし、AbstractPreAuthenticatedProcessingFilter は デフォルトの authenticationFailureHandler はnullなので、明示的に authenticationFailureHandler を指定する必要があります。

AbstractPreAuthenticatedProcessingFilterauthenticationFailureHandler はデフォルトで null:

private AuthenticationFailureHandler authenticationFailureHandler = null;

これにより、APIキーが一致しないケースや、それ以外の認証例外を、自作のハンドラで処理することができます。

ハンドラが発火した際の処理については後述します。

⑦setCheckForPrincipalChangesにtrueを設定する。

    myPreAuthenticatedProcessingFilter.setCheckForPrincipalChanges(true); ・・・⑦
    myPreAuthenticatedProcessingFilter.setInvalidateSessionOnPrincipalChange(true); ・・・⑦

この設定について解説する前に、SecurityContext についておさらいする必要があります。

AuthenticationManager を通じて認証処理が成功した際、SecurityContext には、AuthenticationToken が格納されます。

SecurityContextHttpSession に格納されていますが、HttpSessionは、デフォルトの設定(※5)で、 はステートフルに管理されています。

(※5)SecurityContextはHttpSession内に保存されます。そのHttpSessionの作成するタイミングを SessionCreationPolicy にて操作することができます。DefaultではIF_REQUIREDが指定されており、必要な場合にのみHttpSessionを作成します。(むやみに作り直さないという表現の方が伝わりやすいかもしれません。)

そのため、Spring Security で管理されたSpring Bootアプリケーションへの接続時、ステートフルな HttpSession 内に管理された SecurityContext 内に、AuthenticationToken が格納されているユーザは認証済みと捉えることができます。

AbstractPreAuthenticatedProcessingFilter では、上記の仕組みを利用し、SecurityContext 内に、AuthenticationToken が既に格納されている場合、認証処理をskipする仕様になっています。

AbstractPreAuthenticatedProcessingFilterにて以前認証に成功していた場合にskipする部分:

Authentication currentUser = SecurityContextHolder.getContext().getAuthentication();
  if (currentUser == null) {
    return true;
  }

その場合、デフォルトの設定では、初回の接続に成功したユーザが、2回目で異なる認証情報を利用していても検知することができません。

本来のAPIキー `hello` で疎通した後であれば、

curl --location --request GET 'localhost:9800/hello' \
--header 'Authorization: hello'


不正なAPIキー `he110` でも疎通してしまう。

curl --location --request GET 'localhost:9800/hello' \
--header 'Authorization: he110'

同じ環境にて認証キーを使い分けたいケースでは、認証キーをリクスストの都度確認したいです。

同じ環境にて認証キーを使い分けたいケース: 管理者用アカウントとユーザアカウントで区別する等の、認証キー毎に認可情報を切り替えるケースがあります。

既にSecurityContextAuthenticationToken が格納されていても、認証キーをリクエストの都度確認 するための設定が CheckForPrincipalChanges です。

InvalidateSessionOnPrincipalChange も併用することをおすすめします。(その名の通り、認証キーが異なるケースにて、SessionをInvalidateします。)

SessionCreationPolicy同じ環境にて認証キーを使い分けたいケースに対応する: 下記のように、WebSecurityConfigurerAdapter の拡張クラス内、HttpSecurityを引数とする configure 内で SessionCreationPolicy を以下のように宣言することで、CheckForPrincipalChanges を用いずとも、認証キーを都度確認することが可能です。しかし以下の場合、Session内に含まれている SecurityContext を再利用できないので、都度認証処理のため、認証情報を保持するデータベースに問合せが発生します。認証情報を使い捨てるような、ワンタイムパスワードを用いた認証等の認証方式で無い限りは、基本的に CheckForPrincipalChanges を用いたほうが良さそうです。

@Override
protected void configure(HttpSecurity http) throws Exception {
  ...
+ http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
  ...
}

⑧ProviderにAuthenticationUserDetailsServiceをset

    preAuthenticatedAuthenticationProvider.setPreAuthenticatedUserDetailsService(new MyAuthenticationUserDetailsService()); ・・・⑧

preAuthenticatedAuthenticationProvider に、実際の認証処理をsetします。

この MyAuthenticationUserDetailsService 内で、受け取ったAPIキーが正しいかどうかを認証します。

AuthenticationManagerAuthenticationProvider が、1:n の関係だったのに対して、AuthenticationProviderUserDetailsService は1:1の関係にあります。

MyPreAuthenticatedProcessingFilter.java

次に、MyPreAuthenticatedProcessingFilter について解説します。

AbstractPreAuthenticatedProcessingFilter を採用している理由は前述の通りです。

package com.example.demo.security.filter;

...

public class MyPreAuthenticatedProcessingFilter extends AbstractPreAuthenticatedProcessingFilter {

  @Override
  protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
    return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)).orElse(""); ・・・①
  }

  @Override
  protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
    return ""; ・・・②
  }

}

①リクエストから認証情報を抽出する。

ここでは、Authorization HeaderにAPIキーが含まれているという想定で認証情報を抽出します。

getPreAuthenticatedPrincipal では、Authorization Header から抽出したAPIキーを返却するようにし、
getPreAuthenticatedCredentials では、空文字を返却するようにしています。

これらのメソッドは、PreAuthenticatedAuthenticationToken を生成するために利用されています。

リクエストから認証情報を抽出する部分:

Object principal = getPreAuthenticatedPrincipal(request);
if (principal == null) {
    this.logger.debug("No pre-authenticated principal found in request");
    return;
}
this.logger.debug(LogMessage.format("preAuthenticatedPrincipal = %s, trying to authenticate", principal));
Object credentials = getPreAuthenticatedCredentials(request);
try {
  PreAuthenticatedAuthenticationToken authenticationRequest = new PreAuthenticatedAuthenticationToken(principal, credentials);
...

Q. なぜ getPreAuthenticatedPrincipal でAPIキーを返すのか?

A. 前述した CheckForPrincipalChanges の設定が有効になっている場合、認証情報の一致確認を行うために利用するのが、PreAuthenticatedPrincipal になっています。そのため、getPreAuthenticatedPrincipal にAPIキーを配置しています。

認証情報の一致確認を行う部分

protected boolean principalChanged(HttpServletRequest request, Authentication currentAuthentication) {
  Object principal = getPreAuthenticatedPrincipal(request);
  if ((principal instanceof String) && currentAuthentication.getName().equals(principal)) {
    return false;
  }
...

Q. なぜ getPreAuthenticatedPrincipal にて、認証情報が存在しない場合に空文字を返すのか?

AbstractPreAuthenticatedProcessingFilter では、PreAuthenticatedAuthenticationToken を生成するという話をしました。

この生成する際、リクエストから認証情報を抽出する際に利用されるのが getPreAuthenticatedPrincipal メソッドですが、このメソッドが null を返却した場合、AbstractPreAuthenticatedProcessingFilter では、Tokenを作成できないとみなし、以降の認証処理をskipし、次のFilterへ処理が移ります。

getPreAuthenticatedPrincipal が null を返却した場合、AbstractPreAuthenticatedProcessingFilter では認証処理をskipする部分:

private void doAuthenticate(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
  Object principal = getPreAuthenticatedPrincipal(request);
  if (principal == null) {
    this.logger.debug("No pre-authenticated principal found in request");
     return;
  }
...

②空文字を返す

Q. なぜ getPreAuthenticatedCredentials は空文字を返すのか?

AbstractPreAuthenticatedProcessingFilter クラス内のdocでは、credentials は PreAuthenticatedAuthenticationProvider にて利用されるため、null を返すべきではない点について言及されています。

Subclasses must implement the getPreAuthenticatedPrincipal() and getPreAuthenticatedCredentials()methods. Subclasses of this filter are typically used in combination with a PreAuthenticatedAuthenticationProvider, which is used to load additional data for the user. This provider will reject null credentials, so the getPreAuthenticatedCredentials method should not return null for a valid principal.

PreAuthenticatedAuthenticationProvider にて認証処理の実行時、getPreAuthenticatedCredentials nullの場合に例外をthrowする部分。

ここで例外が発生した場合、UserDetailsService が実行されない。

上記の通り、null以外の文字を返す必要があります。

今回の認証プロセス上、Principal がメインで認証に利用されるため、APIキーを Credentials に格納する必要もないため、空文字を返すよう設定しています。

MyAuthenticationUserDetailsService.java ・ MyUserDetails.java ・ MyAuthenticationFailureHandler

package com.example.demo.security.service;

...

public class MyAuthenticationUserDetailsService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> { ・・・①

  @Override
  public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) { ・・・②

    var myUserDetails = new MyUserDetails(); ・・・③返却用のUserDetailsインターフェースを実装したオブジェクト

    var key = token.getPrincipal().toString();  ・・・④APIキーを抽出

    /**
     * Check API Key
     */
    if (!(key.equals("hello") || key.equals("world"))) { ・・・⑤一致確認: 通常ここでDBアクセス等の処理を追加する
      throw new UsernameNotFoundException("Invalid key.");
    }

    myUserDetails.setKey(key); ・・・⑥

    return myUserDetails;
  }
}

①AuthenticationUserDetailsServiceについて

AuthenticationUserDetailsService は、Provider経由で受け取ったAuthenticationTokenを認証します。

ここでは、AuthenticationToken から認証情報を抽出し、その認証情報が正しいかどうかを検証するために、Databaseへの問合せ等を通じて、認証情報の照合を行います。

照合が成功した場合は UserDetails オブジェクトに認証情報をsetして返却します。

Genericsとして PreAuthenticatedAuthenticationToken が指定されていますが、これは前述の、受け取ったTokenの型から、適切なProviderを推測するというManager内の処理に起因します。

Providerでは受け取るTokenの型が決まっているため、ここでもTokenの型を指定する必要があります。

Providerに返却したUserDetailsObjectを元に、Providerは再度 PreAuthenticatedAuthenticationToken を生成します。

Provider内でUserDetailsを元に再度Tokenを生成する部分:

UserDetails userDetails = this.preAuthenticatedUserDetailsService.loadUserDetails((PreAuthenticatedAuthenticationToken) authentication);
this.userDetailsChecker.check(userDetails);
PreAuthenticatedAuthenticationToken result = new PreAuthenticatedAuthenticationToken(
  userDetails,
  authentication.getCredentials(),
  userDetails.getAuthorities()
);
result.setDetails(authentication.getDetails());

何故 AuthenticationUserDetailsService で認証に成功した PreAuthenticatedAuthenticationToken をそのまま返却せず、UserDetailsObjectを返却後、ProviderにてAuthenticationTokenを再生成するのか については後述します。

②loadUserDetailsメソッドについて

loadUserDetailsメソッドは、Provider内で認証を開始するために呼び出されます。

概要として、AuthenticationTokenを受け取り、UserDetailsオブジェクトを返すという役割を担っています。

このメソッドは UserDetails 以外は返却しない仕様になっており、仮に認証を失敗扱いとしたい場合は、UsernameNotFoundException をthrowすることで、呼び出し元のProviderに認証が失敗したことを伝える仕様になっています。

⑥なぜAPIキーをsetするのか?

setされたAPIキーは、MyUserDetails クラス内の、getUsername メソッドから返却されるよう実装されています。

package com.example.demo.security.model;

...

public class MyUserDetail implements UserDetails {

  private String key;

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return null;
  }

  @Override
  public String getPassword() {
    return null;
  }

  @Override
  public String getUsername() { ・・・ココ
    return key;
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }
}

前述しましたが、AuthenticationUserDetailsServiceからProviderに返却されたUserDetailsは、Provider内で再度Tokenを生成する際に利用されます。

UserDetails userDetails = this.preAuthenticatedUserDetailsService.loadUserDetails((PreAuthenticatedAuthenticationToken) authentication);
this.userDetailsChecker.check(userDetails);
PreAuthenticatedAuthenticationToken result = new PreAuthenticatedAuthenticationToken(
  userDetails,
  authentication.getCredentials(),
  userDetails.getAuthorities()
);
result.setDetails(authentication.getDetails());

この再生成時に生成される PreAuthenticatedAuthenticationTokengetName メソッドは、基本的に UserDetails オブジェクトの getUsername メソッドを元に値を返します。

getUsernameがgetName内でcallされている部分:

public String getName() {
  ...
  return ((AuthenticatedPrincipal) this.getPrincipal()).getName();
  ...

この PreAuthenticatedAuthenticationTokengetName メソッドは、前述の、CheckForPrincipalChanges を通じた都度認証時に利用されます。

以前の認証情報を再利用できるかどうか確認する部分:

protected boolean principalChanged(HttpServletRequest request, Authentication currentAuthentication) {
  Object principal = getPreAuthenticatedPrincipal(request);
  if ((principal instanceof String) && currentAuthentication.getName().equals(principal)) {
    return false;
  }


抽出した値を getUsername で返却するよう UserDetails にsetすることで、
次回以降の接続時、都度APIキーを確認する処理が正常に実行されます。

もしここで getUsername でAPIキーを返すよう指定しない場合、次回の認証で用いられる認証情報が空になったまま、SecurityContextPreAuthenticatedAuthenticationToken が登録されてしまいます。

そうなると、

前回の認証情報 : 空
今回の認証情報 : 正しいAPIキー

が比較され、都度前回と異なるAPIキーとして認証処理が実行されてしまいます。

UserDetailsの他のメソッドについて

getPassword

今回のケースでは、このメソッドが参照されることがないので、nullを返却しています。
ここでは最低限の実装を考慮しているため、nullを返却していますが、自身の認証方式で必要とあらば、任意の値を入れても構いません。

is◯◯◯◯

APIキーは認証情報が一致しないケース以外にも、認証を失敗として扱いたいケースがいくつかあります。

例えば、認証情報の有効期限が切れているときや、事業者側の事情で強制的に該当ユーザを失敗させたいときなどがあります。

このような、認証情報以外の事由で認証を拒否する条件がある場合に利用すると良いのが is を接頭辞に持つメソッドです。

例えば、認証情報の有効期限が切れている際に認証を失敗としたいときは、以下のように記載します。


public class MyAuthenticationUserDetailsService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> { 

  @Override
  public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) { 

    ...

    myUserDetail.setCreatedAt(createdAt) ・・・DBから取得した認証情報の生成された時刻をuserDetailsにset

    ...


  Date createdAt; ・・・ここにsetされる

  public boolean isAccountNonExpired() {

    if (createdAt から2年以上立っている){
      return false
    } else {
      return true;
    }

  }

これらの is◯◯◯◯ メソッドは、Provider上で、再度 AuthenticationTokenを発行する前に、一度検閲されます。

is◯◯◯◯ を実行し、認証失敗としたいケースに該当するか確認する部分

this.userDetailsChecker.check(userDetails);

もし認証失敗のケースを確認した場合は、それぞれに適した例外をthrowします。

認証に失敗したケースごとに例外をthrowする部分

    public void check(UserDetails user) {
        if (!user.isAccountNonLocked()) {
            this.logger.debug("Failed to authenticate since user account is locked");
            throw new LockedException(
                    this.messages.getMessage("AccountStatusUserDetailsChecker.locked", "User account is locked"));
        }
        if (!user.isEnabled()) {
            this.logger.debug("Failed to authenticate since user account is disabled");
            throw new DisabledException(
                    this.messages.getMessage("AccountStatusUserDetailsChecker.disabled", "User is disabled"));
        }
        if (!user.isAccountNonExpired()) {
            this.logger.debug("Failed to authenticate since user account is expired");
            throw new AccountExpiredException(
                    this.messages.getMessage("AccountStatusUserDetailsChecker.expired", "User account has expired"));
        }
        if (!user.isCredentialsNonExpired()) {
            this.logger.debug("Failed to authenticate since user account credentials have expired");
            throw new CredentialsExpiredException(this.messages
                    .getMessage("AccountStatusUserDetailsChecker.credentialsExpired", "User credentials have expired"));
        }
    }

MyAuthenticationFailureHandler

MyAuthenticationFailureHandlerでは、これらの例外毎に、遷移先を変更することも可能です。

package com.example.demo.security.exception.handler.authentication;

...

public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {


    if (exception instanceof AccountExpiredException) {
        // 有効期限を延長するページへリダイレクト 
        response.sendRedirect("https://~");
        return;
    }

    /**
     * Redirect API Key Reset Link
     */
    response.sendRedirect("https://www.google.com");
  }
}

AuthenticationFailureHandler は、Filter内での認証失敗時にcallされます。
仕組みは単純で、認証処理中に起きた認証に関する例外 AuthenticationException をそのまま受け取ります。

認証失敗時にcallする部分

catch (AuthenticationException ex) {
  unsuccessfulAuthentication(request, response, ex);
  if (!this.continueFilterChainOnUnsuccessfulAuthentication) {
    throw ex;
  }
}

まとめ

ここまで、APIキーによる認証方式を最低限で実装するための解説をしてきました。

実際の現場では、認証のみを提供することは少ないかと思いますが、以降の理解に役に立つと思うので、敢えて独立したパートとして記載してみました。

4.APIキーによる認証・認可の実装例

ここからは、前半で解説した認証方式に、認可を実装したいと思います。

ディレクトリ構成

demo
├── DemoApplication.java
├── controller
│   └── HelloWorldController.java
└── security
    ├── config
    │   └── MyWebSecurityConfigurer.java ・・・ update
    ├── exception
    │   └── handler
    │       ├── authentication
    │       │   ├── MyAuthenticationEntryPoint.java ・・・ new
    │       │   └── MyAuthenticationFailureHandler.java 
    │       └── authorization
    │           └── MyAccessDeniedHandler.java ・・・ new
    ├── filter
    │   └── MyPreAuthenticatedProcessingFilter.java 
    ├── model
    │   └── MyUserDetails.java ・・・ update
    └── service
        └── MyAuthenticationUserDetailsService.java ・・・ update

MyWebSecurityConfigurer.java

package com.example.demo.security.config;

...

@Configuration
@EnableWebSecurity
class MyWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
  
  ...

  @Override
  protected void configure(HttpSecurity http) throws Exception {

    http.addFilter(getMyPreAuthenticatedProcessingFilter());

+   http.authorizeRequests()
+     .antMatchers("/world").hasAuthority("WORLD")
+     .anyRequest().authenticated();

+   http.exceptionHandling().authenticationEntryPoint(new MyAuthenticationEntryPoint());

+   http.exceptionHandling().accessDeniedHandler(new MyAccessDeniedHandler());
  }

  public AbstractPreAuthenticatedProcessingFilter getMyPreAuthenticatedProcessingFilter() throws Exception {
    var myPreAuthenticatedProcessingFilter = new MyPreAuthenticatedProcessingFilter();
    myPreAuthenticatedProcessingFilter.setAuthenticationManager(authenticationManager());
-   myPreAuthenticatedProcessingFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler());
    myPreAuthenticatedProcessingFilter.setCheckForPrincipalChanges(true);
    myPreAuthenticatedProcessingFilter.setInvalidateSessionOnPrincipalChange(true);
    return myPreAuthenticatedProcessingFilter;
    }


  ...

}

MyUserDetails.java

package com.example.demo.security.model;

...

@Data
public class MyUserDetails implements UserDetails {

 ...

+ private List<GrantedAuthority> authorities;

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
+   return authorities;
  }

  ...

}

MyAuthenticationUserDetailsService

package com.example.demo.security.service;

...

public class MyAuthenticationUserDetailsService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {

  @Override
  public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) {

    ...
+   /**
+    * Add Authority
+    */
+   if (key.equals("world")) {
+     List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
+     authorities.add(new SimpleGrantedAuthority("WORLD"));
+     myUserDetails.setAuthorities(authorities);
+   }

    return myUserDetails;
  }
}

MyAccessDeniedHandler.java

package com.example.demo.security.exception.handler.authorization;

...

public class MyAccessDeniedHandler implements AccessDeniedHandler {
  @Override
  public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
    response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
  }
}

MyAuthenticationEntryPoint.java

package com.example.demo.security.exception.handler.authentication;

...

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        /**
         * API Key Reset Link
         */
        response.sendRedirect("https://yahoo.co.jp");
    }
}

5.認可の実装例の解説

ここでは、実際に認可処理がどのように行われているかは解説せずに、

APIキーによる認証・認可のエラーハンドリングについて解説します。

※ 実際の認可処理は高度にカプセル化されており、カスタムするケースが稀なことや、認可処理に関する記事は既に多く公開されていることから、認可処理がどのように構築されているかをここで解説しません。

MyWebSecurityConfigurer.java

package com.example.demo.security.config;

...
@Configuration
@EnableWebSecurity
class MyWebSecurityConfigurer extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.addFilter(getMyPreAuthenticatedProcessingFilter());

+   http.authorizeRequests() ・・・①
+     .antMatchers("/world").hasAuthority("WORLD") 
+     .anyRequest().authenticated(); 

+   http.exceptionHandling().authenticationEntryPoint(new MyAuthenticationEntryPoint()); ・・・②

+   http.exceptionHandling().accessDeniedHandler(new MyAccessDeniedHandler()); ・・・②
  }

  public AbstractPreAuthenticatedProcessingFilter getMyPreAuthenticatedProcessingFilter() throws Exception {
    var myPreAuthenticatedProcessingFilter = new MyPreAuthenticatedProcessingFilter();
    myPreAuthenticatedProcessingFilter.setAuthenticationManager(authenticationManager());
-   myPreAuthenticatedProcessingFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler()); ・・・③
    myPreAuthenticatedProcessingFilter.setCheckForPrincipalChanges(true);
    myPreAuthenticatedProcessingFilter.setInvalidateSessionOnPrincipalChange(true);
    return myPreAuthenticatedProcessingFilter;
    }

  ...

}

authorizeRequests について

authorizeRequests を利用し、URLに基づく認可ルールを構成することができます。

今回は2つのルールを追加しています。

.antMatchers("/world").hasAuthority("WORLD") 

を通じて、/world エンドポイントについては、WORLD Authorityを持つユーザのみを認可します。

また、

.anyRequest().authenticated(); 

を通じて、いづれのリクエストについても認証を実行します。

MyAccessDeniedHandlerMyAuthenticationEntryPoint について

ここで、前半と後半のSecurity FIlterを比較してみましょう。

前半: 認証処理のみ

Will secure any request with [
  org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@642505c7, 
  org.springframework.security.web.context.SecurityContextPersistenceFilter@40e60ece, 
  org.springframework.security.web.header.HeaderWriterFilter@5fac521d, 
  org.springframework.security.web.csrf.CsrfFilter@782bf610, 
  org.springframework.security.web.authentication.logout.LogoutFilter@476e8796, 
  com.example.demo.security.filter.MyPreAuthenticatedProcessingFilter@XXXXXXXX,
  org.springframework.security.web.savedrequest.RequestCacheAwareFilter@3a230001, 
  org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@4d654825, 
  org.springframework.security.web.authentication.AnonymousAuthenticationFilter@4339e0de, 
  org.springframework.security.web.session.SessionManagementFilter@706eab5d, 
  org.springframework.security.web.access.ExceptionTranslationFilter@1846579f
]

後半: 認証・認可処理

 Will secure any request with [
   org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@149c3204, 
   org.springframework.security.web.context.SecurityContextPersistenceFilter@6ab7ce48, 
   org.springframework.security.web.header.HeaderWriterFilter@148c7c4b, 
   org.springframework.security.web.csrf.CsrfFilter@6ffa56fa, 
   org.springframework.security.web.authentication.logout.LogoutFilter@7a1f45ed, 
   com.example.demo.security.filter.MyPreAuthenticatedProcessingFilter@64f16277, 
   org.springframework.security.web.savedrequest.RequestCacheAwareFilter@e322ec9, 
   org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@4d27d9d, 
   org.springframework.security.web.authentication.AnonymousAuthenticationFilter@497aec8c, 
   org.springframework.security.web.session.SessionManagementFilter@459cfcca, 
   org.springframework.security.web.access.ExceptionTranslationFilter@51a8313b, 
+  org.springframework.security.web.access.intercept.FilterSecurityInterceptor@2de50ee4
]

末尾に FilterSecurityInterceptor というFilterが追加されています。
このFilterが、認可処理を行うFilterになっています。

tips: このFilterだけ、末尾がFilterではなく、Interceptor となっていますね。JavaDoc によると、複雑な前処理を行う場合は Interceptor を利用するべきだと記載してあります。確かに、この FilterSecurityInterceptor では認可処理という複雑な処理を行っています。しかし、FilterSecurityInterceptor実装しているインターフェースはFilterであり、JavaDoc内に記載の HandlerInterceptor インターフェースとは異なります。ここでは、複雑なことをしているFilterだから、Interceptor と呼んでいるのだなという程度の認識に留めておいて結構です。

As a basic guideline, fine-grained handler-related preprocessing tasks are candidates for HandlerInterceptor implementations

このFilterは、authorizeRequests 句等の認可にまつわる処理を記載した場合、Security Filterの末尾に追加されます。

createFilterSecurityInterceptor メソッド

これから解説する MyAccessDeniedHandler は、認可処理で起きたエラーを ハンドリングするためのクラスですが、 FilterSecurityInterceptor では利用されず、ExceptionTranslationFilter にて利用されます。

後半では、この ExceptionTranslationFilter を中心に巻き起こる、認可処理のエラーハンドリングについて解説します。


エラーハンドリングについて

では早速エラーハンドリングについて解説を始めていきます。

エラーハンドリングは大きく2種類に別れます。

1種類目は認証に失敗したケースです。

1. APIキーが指定されていないケース
2. APIキーが一致しないケース (今回だと、helloとworld以外のAPIキーが指定されているケース)
3. APIキーは一致しているが、その他の理由で認証が失敗するケース(上述のcheckメソッドを通じて有効期限切れを検知するケース)

2種類目は認証には成功したが、認可で失敗するケースです。

今回のケースで言えば、/world エンドポイントに接続するには、WORLD 権限が付与されるAPIキー world を利用する必要があります。

しかし、その /world エンドポイントに対して、WORLD 権限が付与されないAPIキー hello を用いて接続しようとすると、APIキー自体は有効であるものの、WORLD 権限を持っていないため、認可に失敗し接続できません。

それでは、それぞれのケースについて、どのようにSpring Securityでエラーハンドリングが行われているかを解説します。

ケース1: 認証に失敗したケース

前半解説した、認証のみを実装しているケースでは、認証に失敗すると、まず始めに MyPreAuthenticatedProcessingFilter にsetされている MyAuthenticationFailureHandler 内の onAuthenticationFailure メソッドが発火していました。

package com.example.demo.security.exception.handler.authentication;

...

public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
    /**
     * Redirect API Key Reset Link
     */
    response.sendRedirect("https://www.google.com"); ・・・ココが発火していた
  }
}

onAuthenticationFailure メソッド: このメソッドは、Applicationを利用したユーザに対して、認証情報を適切に発行するよう誘導するような使い方をします。例えば、APIキーが違うようなら、APIキーを発行時のメールアドレスを入力させるようなページに遷移させたり、APIキーの有効期限が切れているようなら、APIキーを更新するページへ遷移させたりします。

しかし認証・認可のケースでは、MyAuthenticationFailureHandler はsetされていません。
この理由については後述します。

  public AbstractPreAuthenticatedProcessingFilter getMyPreAuthenticatedProcessingFilter() throws Exception {
    var myPreAuthenticatedProcessingFilter = new MyPreAuthenticatedProcessingFilter();
    myPreAuthenticatedProcessingFilter.setAuthenticationManager(authenticationManager());
-   myPreAuthenticatedProcessingFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler()); ・・・③
    myPreAuthenticatedProcessingFilter.setCheckForPrincipalChanges(true);
    myPreAuthenticatedProcessingFilter.setInvalidateSessionOnPrincipalChange(true);
    return myPreAuthenticatedProcessingFilter;
    }

認証に失敗した際、MyAuthenticationFailureHandler がsetされていないため、デフォルトで AuthenticationFailureHandler がnullになっている AbstractPreAuthenticatedProcessingFilter では、認証失敗を検知しないまま、後続のFilterの処理を実行します。


現在の流れ
1. 認証に失敗後、失敗を検知せずに後続のFilterを実行する

通常 AuthenticationFilter は、認証に成功した場合、先述の SecurityContext 内に、認証情報 AuhenticationToken をsetします。

公式ドキュメントより引用: Authenticationは、現在認証されているユーザーを表します。現在の Authentication は SecurityContext から取得できます。

しかし今回のような認証に失敗したケースでは、AuthenticationFilter から SecurityContext 内に AuhenticationToken がsetされません。

SecurityContext 内に AuhenticationToken がsetされていないまま、後続のFilter処理が進んでいくと(※6)、
いづれ AnonymousAuthenticationFilter の処理に移ります。

   ...
   com.example.demo.security.filter.MyPreAuthenticatedProcessingFilter@64f16277, 

   ...

   org.springframework.security.web.authentication.AnonymousAuthenticationFilter@497aec8c, 

   ...

AnonymousAuthenticationFilter にて、SecurityContext 内に なんらかの AuhenticationToken がsetされていないことを確認したとき、SecurityContext 内に AnonymousAuhenticationToken を設置します。

公式ドキュメントより引用: AnonymousAuthenticationFilter - 通常の認証メカニズムの後にチェーンされ、既存の Authentication が保持されていない場合、AnonymousAuthenticationToken を SecurityContextHolder に自動的に追加します。


現在の流れ
1. 認証に失敗後、失敗を検知せずに後続のFilterを実行する
2. 空の SecurityContext に AnonymousAuhenticationToken を配置

AnonymousAuthenticationFilter にて SecurityContextAnonymousAuhenticationToken がsetされたあとは、以下の順でFilterが実行されます。

   org.springframework.security.web.authentication.AnonymousAuthenticationFilter@497aec8c, 
   org.springframework.security.web.session.c@459cfcca, 
   org.springframework.security.web.access.ExceptionTranslationFilter@51a8313b, 
+  org.springframework.security.web.access.intercept.FilterSecurityInterceptor@2de50ee4

tips: SessionManagementFilter では、sessionを改ざんした不正なリクエストをブロックしたり、複数ログインに対する制御を行います。今回は最低限の認証・認可を実装するというコンセプトなので、このFilterについての解説は省略させていただきます。

一見、AnonymousAuthenticationFilter のあとに、ExceptionTranslationFilter が来ているので、先に ExceptionTranslationFilter の処理が発火するかのように見えます。

しかし、ここでは、ExceptionTranslationFilter は後続のFilterSecurityInterceptor を実行するのみで、前処理は何もしません。

  ...
    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        try {
      // ExceptionTranslationFilterでは前処理が記載されておらず
      // FilterSecurityInterceptorを実行するのみ
            chain.doFilter(request, response);
        }
        catch (IOException ex) {
            throw ex;
        }
    ... 

ExceptionTranslationFilter では、try句内で、FilterSecurityInterceptor を実行し、その例外処理のみを担っています。


現在の流れ
1. 認証に失敗後、失敗を検知せずに後続のFilterを実行する
2. 空の SecurityContext に AnonymousAuhenticationToken を配置
3. ExceptionTranslationFilter 内のtry句で FilterSecurityInterceptorを実行

FilterSecurityInterceptor

ということで、try句内で実行される FilterSecurityInterceptor 内の処理を確認します。

現段階で SecurityContext には AnonymousAuhenticationToken が設定されています。

無論、AnonymousAuhenticationToken には何の権限も保持していないため、FilterSecurityInterceptor は認可に失敗したことを示す AccessDeniedException をthrowします。

tips: 認可処理の流れは割愛しますが、気になる人は認可処理を呼んでみてください: デフォルトのvoterを設定している箇所と、そのVoterを実行しているクラス。これらが FilterSecurityInterceptor 内の beforeInvocation メソッドを通じてcallされている。


現在の流れ
1. 認証に失敗後、失敗を検知せずに後続のFilterを実行する
2. 空の SecurityContext に AnonymousAuhenticationToken を配置
3. ExceptionTranslationFilter 内のtry句で FilterSecurityInterceptorを実行
4. FilterSecurityInterceptor が AccessDeniedException をthrowする

ステップ3で、ExceptionTranslationFilter のtry句内で、FilterSecurityInterceptor が実行されていることを解説しました。

そのため、さきほどthrowされた AccessDeniedException は、 ExceptionTranslationFilter にてcatchされます。

AccessDeniedException をcatchした際の処理は下記です。

    ...
        try {
            chain.doFilter(request, response);
        }
        catch (IOException ex) {
            throw ex;
        }
        catch (Exception ex) {
            // Try to extract a SpringSecurityException from the stacktrace
            Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
      // StackTraceに書き込まれた例外から、AuthenticationExceptionを探す
            RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
                    .getFirstThrowableOfType(AuthenticationException.class, causeChain);

            if (securityException == null) {
        // AuthenticationExceptionがStackTraceから見つからない場合、
        // AccessDeniedExceptionを探す
                securityException = (AccessDeniedException) this.throwableAnalyzer
                        .getFirstThrowableOfType(AccessDeniedException.class, causeChain);
            }
            if (securityException == null) {
                rethrow(ex);
            }
            if (response.isCommitted()) {
        // もしresponseがコミットされている場合は、
        // ServletExceptionをthrowし、エラーハンドリングを終了する
                throw new ServletException("Unable to handle the Spring Security Exception "
                        + "because the response is already committed.", ex);
            }
            handleSpringSecurityException(request, response, chain, securityException);
        }
    ...

ExceptionTranslationFilter では、AccessDeniedException をStackTraceから取得します。
最終的に、ExceptionTranslationFilter では handleSpringSecurityException を実行します。

    private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
            FilterChain chain, RuntimeException exception) throws IOException, ServletException {
        if (exception instanceof AuthenticationException) {
            handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
        }
        else if (exception instanceof AccessDeniedException) {
            handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
        }
    }

現在の流れ
1. 認証に失敗後、失敗を検知せずに後続のFilterを実行する
2. 空の SecurityContext に AnonymousAuhenticationToken を配置
3. ExceptionTranslationFilter 内のtry句で FilterSecurityInterceptorを実行
4. FilterSecurityInterceptor が AccessDeniedException をthrowする
5. ExceptionTranslationFilterにてhandleSpringSecurityExceptionが実行される

handleSpringSecurityException では、AccessDeniedException 型を検知し、handleAccessDeniedException が実行されます。

    private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
            FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
        if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
            if (logger.isTraceEnabled()) {
                logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
                        authentication), exception);
            }
            sendStartAuthentication(request, response, chain,
                    new InsufficientAuthenticationException(
                            this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
                                    "Full authentication is required to access this resource")));
        } else { ...


現在の流れ
1. 認証に失敗後、失敗を検知せずに後続のFilterを実行する
2. 空の SecurityContext に AnonymousAuhenticationToken を配置
3. ExceptionTranslationFilter 内のtry句で FilterSecurityInterceptorを実行
4. FilterSecurityInterceptor が AccessDeniedException をthrowする
5. ExceptionTranslationFilterにてhandleSpringSecurityExceptionが実行される
6. handleSpringSecurityExceptionにてhandleAccessDeniedExceptionが実行される

ここでは、SecurityContext に配置されている AuthenticationToken を確認しています。

そして、もし AnonymousAuthenticationToken が配置されていた場合は、sendStartAuthentication メソッドを通じ、InsufficientAuthenticationException によって、MyAuthenticationEntryPoint を発火させます。

    protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            AuthenticationException reason) throws ServletException, IOException {
        // SEC-112: Clear the SecurityContextHolder's Authentication, as the
        // existing Authentication is no longer considered valid
        SecurityContextHolder.getContext().setAuthentication(null);
        this.requestCache.saveRequest(request, response);
        this.authenticationEntryPoint.commence(request, response, reason);
    }

現在の流れ
1. 認証に失敗後、失敗を検知せずに後続のFilterを実行する
2. 空の SecurityContext に AnonymousAuhenticationToken を配置
3. ExceptionTranslationFilter 内のtry句で FilterSecurityInterceptorを実行
4. FilterSecurityInterceptor が AccessDeniedException をthrowする
5. ExceptionTranslationFilterにてhandleSpringSecurityExceptionが実行される
6. handleSpringSecurityExceptionにてhandleAccessDeniedExceptionが実行される
7. SecurityContextのAnonymousAuthenticationTokenを検知し、sendStartAuthenticationが実行される

この後、MyAuthenticationEntryPoint 内の

response.sendRedirect("https://yahoo.co.jp");

を通じて、クライアントを任意のサイトにリダイレクトします。

これまでの処理をまとめると、認証に失敗した場合、処理は以下のように進行します。

現在の流れ
1. 認証に失敗後、失敗を検知せずに後続のFilterを実行する
2. 空の SecurityContext に AnonymousAuhenticationToken を配置
3. ExceptionTranslationFilter 内のtry句で FilterSecurityInterceptorを実行
4. FilterSecurityInterceptor が AccessDeniedException をthrowする
5. ExceptionTranslationFilter にて handleSpringSecurityException が実行される
6. handleSpringSecurityException にて handleAccessDeniedException が実行される
7. SecurityContext の AnonymousAuthenticationToken を検知し、sendStartAuthentication が実行される
8. sendStartAuthentication にて MyAuthenticationEntryPointが実行される

このエラーハンドリングでは、

AnonymousAuthenticationTokenSecurityContext に配置されている場合、認証に失敗していると見做す

という前提の元、処理が進んでいることを理解しておくと良いでしょう。

ケース2: 認証には成功したが、認可で失敗するケース

ケース1の流れを押さえていれば、ケース2は単純です。

認証に成功しているということは、

AnonymousAuthenticationTokenSecurityContext に配置されていない

ということです。

今回のケースでは、認証に成功しているため、PreAuthenticatedAuthenticationTokenSecurityContext に配置されています。

認証に成功したが、認可に失敗するケースでは、以下のように処理を実行します。

現在の流れ
1. 認証に成功後、SecurityContext に PreAuthenticatedAuthenticationToken を配置し、後続のFilterを実行する
2. SecurityContext に Token が配置済み(認証済み) のため、AnonymousAuhenticationToken を配置せず後続のFilterを実行する
3. ExceptionTranslationFilter 内のtry句で FilterSecurityInterceptorを実行
4. FilterSecurityInterceptor が AccessDeniedException をthrowする
5. ExceptionTranslationFilter にて handleSpringSecurityException が実行される
6. handleSpringSecurityException にて handleAccessDeniedException が実行される
7. handleAccessDeniedException にて MyAccessDeniedHandler が実行される

違いは、

  1. 認証に成功しているため、AnonymousAuthenticationFilter にて SecurityContextAnonymousAuhenticationToken がsetされない

  2. ExceptionTranslationFilter 内の handleAccessDeniedException にて、AnonymousAuhenticationToken の制御を受けずに MyAccessDeniedHandler を実行する

という2点です。

③なぜ事前認証ケースにおける認証・認可では、FailureHandlerをFilterにsetしないのか?

結論から言うと、仕様上 AuthenticationEntryPoint にて認証失敗エラーをハンドリングするようになっているからです。

仮にsetされていた状態で、認証・認可の処理が進んでいたとします。

誤った認証情報を用いて、アプリケーションに接続を試みたとき、FailureHandleronAuthenticationFailure メソッドが発火します。

前述の通り、認証に失敗したユーザを、再度認証するためのページに誘導する役割を担うべきとされる FailureHandler では、以下の sendRedirect 句が用いられます。この時点で、responseはcommitされます(※6)。

response.sendRedirect("https://www.google.com");

(※6) ServletResponseのcommitとは?:

sentRedirectが実行された時点で、ユーザは特定のURLに遷移するわけではありません。
この sendRedirect メソッドが実行されると、response こと ServletResponsecommit済み というステータスになるだけで、後続の処理が中断されるわけではありません。
無論Filterの処理は継続し、最終的に全てのFilterの前処理・後処理を終えたあと、commit済み ステータスに切り替わったresponseがクライアントに返送されます。
sendRedirect メソッド以外にも、ServletResponse をcommit済みとするメソッドはいくつかあります。また、commit済みになって以降、その ServletResponse を更新することはできません。(※ 個人的なハマりポイントだったので、少し突っ込んで解説しています。)

このまま、前述で解説したように、処理を進めていきます。

そして、5つ目の処理を確認してみます。

現在の流れ
1. 認証に失敗したため response.sendRedirect が発火され、response がcommit済みとなる。
2. 空の SecurityContext に AnonymousAuhenticationToken を配置
3. ExceptionTranslationFilter 内のtry句で FilterSecurityInterceptorを実行
4. FilterSecurityInterceptor が AccessDeniedException をthrowする
5. ExceptionTranslationFilter にて ・・・

1つ目の処理にて、既に response.sendRedirect が実行され、responseがコミット済みとなっています。

    ...
        try {
            chain.doFilter(request, response);
        }
        catch (IOException ex) {
            throw ex;
        }
        catch (Exception ex) {
            // Try to extract a SpringSecurityException from the stacktrace
            Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
      // StackTraceに書き込まれた例外から、AuthenticationExceptionを探す
            RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
                    .getFirstThrowableOfType(AuthenticationException.class, causeChain);

            if (securityException == null) {
        // AuthenticationExceptionがStackTraceから見つからない場合、
        // AccessDeniedExceptionを探す
                securityException = (AccessDeniedException) this.throwableAnalyzer
                        .getFirstThrowableOfType(AccessDeniedException.class, causeChain);
            }
            if (securityException == null) {
                rethrow(ex);
            }
            if (response.isCommitted()) {
        // もしresponseがコミットされている場合は、
        // ServletExceptionをthrowし、エラーハンドリングを終了する
                throw new ServletException("Unable to handle the Spring Security Exception "
                        + "because the response is already committed.", ex);
            }
            handleSpringSecurityException(request, response, chain, securityException);
        }
    ...

そのため、ExceptionTranslationFilter のcatch句で、例外をハンドリングしようと思ったとき、

response.isCommitted() の条件がtrueとなってしまうため、
handleSpringSecurityException を実行する前に、ServletException がthrowされてしまいます。

実際のエラー: Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Unable to handle the Spring Security Exception because the response is already committed.] with root cause

仮にこのif文がなかったとしても、response.sendRedirect にてcommit済みのresponseを、 MyAccessDeniedHandlerMyAuthenticationEntryPoint にて更にcommitしてしまうので、以下のようなエラーが発生するでしょう。

java.lang.IllegalStateException: ~ Response already committed.

このように、認証・認可処理にて、AuthenticationFailureHandler 本来の使い方をしようと思うと、ExceptionTranslationFilter の2重commitを防ぐためのLogicによって、内部エラーが発生してしまいます。

この仕様を見るに、認証・認可処理において、認証エラーについては、AuthenticationEntryPoint を用いるのが正しいと言えそうです。

InsufficientAuthenticationException しか検出できない件について:
AuthenticationEntryPointを用いて認証エラーをハンドリングする際、全ての認証エラーは InsufficientAuthenticationException でthrowされます。つまり、AuthenticationFailureHandlerのように、発生した例外別に、遷移先を用意することができません。これは、認証エラーを AnonymousAuhenticationToken の有無で判別するというSpring Securityの仕様上の問題です。現在この問題についてissueは上がっているものの、優先順位は低いものとなっています。

認証例外が InsufficientAuthenticationException によって上塗りされてしまう件について :https://github.com/spring-projects/spring-security/issues/4630

最後に

さて、ここまでに、事前認証ケースにおける、APIキーによる認証方式の実装例を解説してきました。

複数の実装方法がある中で、それぞれの方法を比較検討し、コードに落とし込むことは困難です。

特にAPIキーの認証方式(特にJWTを用いた認証方式等)について検索すると、GenericFilterBean や OncePerRequestFilter を拡張し、addFilterBefore句でFilterを追加したり、SecurityContextの管理を直書きするケースが散見されますが、よほど特別なケースで無い限りは、Spring Securityから提供されている pre-auth パッケージを正しく利用するほうが、考えることは少なくて済むと思っています。

この実装例から、それぞれに最適な認証方式を確立できれば嬉しいなと思っています。

8
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
13