#はじめに
##なぜ記事を書いたか?
直近で携わったプロダクトでは、Spring Securityを用いて、APIキーによる認証・認可 を実装しました。
詳しくは後述しますが、Spring Security を通じた APIキーによる認証・認可 は、公式から特に実装例等は公開されていないため、
公式ドキュメント: 事前認証シナリオより引用
実装する際、公式ドキュメント
と 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では、事前認証用に提供されているクラス群が存在するため、そのクラス群を活用する必要があります。なお、以下に記載の通り、事前認証シナリオを実装する際、概要のみが公開されており、実装に関しては、自身でソースを読みながら実装を進める必要があります。
公式ドキュメント: 事前認証シナリオより再掲
記事の構成
1.前提知識はこちら
ここでは、Spring Securityを初めて触る方に向けて、抽象的にSpring Securityの仕組みをおさらいしています。
既にSpring Securityがどのように動作するかイメージできている場合は、Skipしてください。
Spring Security での大まかな認証の流れ
実装例を解説する前に、認証がどのように行われるのかを、かんたんにおさらいしてみます。
Spring Bootで提供されている各認証方式では、大きく以下の2つのフローを実行しています。
- リクエストから認証情報を取得
- その認証情報が正しいものか確認
これら2つの認証フローは、各認証方式毎に用意された AuthenticaitonFilter
と名の付くクラス(※2)で実行されています。
それでは、AuthenticaitonFilter
クラス内で、
- リクエストから認証情報を取得
- その認証情報が正しいものか確認
という2つのフローをどのように実行しているか、具体的に確認していきます。
AuthenticaitonFilter
と名の付くクラス内では、おおまかに下記のような処理を行っています。
-
[認証フロー] リクエストから認証情報を取得
- [AuthenticaitonFilter] リクエストから認証情報を抽出する
- [AuthenticaitonFilter] 抽出した認証情報を元に
AuthenticationToken
を作成する
-
[認証フロー] その認証情報が正しいものか確認
- [AuthenticaitonFilter] 生成した
AuthenticationToken
を用いてAuthenticationManager
が認証処理を実行する - [AuthenticaitonFilter] 認証の確認が正常に終了する
- [AuthenticaitonFilter] 生成した
ここで初めて登場した
AuthenticationToken
AuthenticationManager
について簡単に説明します。
AuthenticationToken
リクエストには、様々な形式で認証情報が含まれています。
- Authorization Header として認証情報が含まれているケース
- Postパラメータとして認証情報が含まれているケース
- etc...
抽出したこれらの認証情報が格納し、以降の認証処理で参照するためのオブジェクトが AuthenticationToken
です。
AuthenticationManager
AuthenticationManager
には、認証情報が正しいかどうかを確認するための認証処理が格納 されています。
抽象的ではありましたが、Spring Security内での認証のフローは以上のようになります。
(※2)
AuthenticaitonFilter
と名の付くクラスを実装するための基底クラス・インターフェースは複数あります。そして、これらを元に実装されたAuthenticaitonFilterクラス内の認証フローに冠する処理は共通している部分が多いです。
AuthenticaitonFilter
を実装するための基底クラス・インターフェース:
AbstractAuthenticationProcessingFilter,OncePerRequestFilter,AbstractPreAuthenticatedProcessingFilter
##前提知識のまとめ
Spring Securityでは、AuthenticaitonFilter
と名の付くクラスで、以下の手順で認証を行っています。
- リクエストから認証情報を抽出する
- 抽出した認証情報を元に
AuthenticationToken
を作成する - 生成した
AuthenticationToken
を用いてAuthenticationManager
が認証処理を実行する - 認証の確認が正常に終了する
抽象的に認証の流れをおさらいしたところで、
実際にAPIキーによる認証の実装例を確認してみましょう。
#2. APIキーによる認証の実装例
以下はSpring SecurityでAPIキーによる認証を実装するミニマムな構成です。
認証・認可のうち、このbranchでは、認証のみを実装しています。
認証と認可の違いについて: https://dev.classmethod.jp/articles/authentication-and-authorization-again/
Repository
- プロジェクトのベースとなっている Spring Initializr の設定
- 上記は、Spring Initializr のデフォルト設定に、Dependencyとして Spring Web と Spring Security と Lombok を追加しただけのもの
##ディレクトリ構成
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);
...
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);
MyPreAuthenticatedProcessingFilter
は AbstractPreAuthenticatedProcessingFilter
インターフェースで実装されているため、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内で認証処理に利用されます。
AuthenticationManager
とAuthenticationProvider
の関係について
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しています。
WebSecurityConfigurerAdapter
の authenticationManager()
メソッドは、共有の 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
を指定する必要があります。
AbstractPreAuthenticatedProcessingFilter
のauthenticationFailureHandler
はデフォルトで null:private AuthenticationFailureHandler authenticationFailureHandler = null;
これにより、APIキーが一致しないケースや、それ以外の認証例外を、自作のハンドラで処理することができます。
ハンドラが発火した際の処理については後述します。
###⑦setCheckForPrincipalChangesにtrueを設定する。
myPreAuthenticatedProcessingFilter.setCheckForPrincipalChanges(true); ・・・⑦
myPreAuthenticatedProcessingFilter.setInvalidateSessionOnPrincipalChange(true); ・・・⑦
この設定について解説する前に、SecurityContext
についておさらいする必要があります。
AuthenticationManager
を通じて認証処理が成功した際、SecurityContext
には、AuthenticationToken
が格納されます。
SecurityContext
は HttpSession
に格納されていますが、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'
同じ環境にて認証キーを使い分けたいケースでは、認証キーをリクスストの都度確認したいです。
同じ環境にて認証キーを使い分けたいケース: 管理者用アカウントとユーザアカウントで区別する等の、認証キー毎に認可情報を切り替えるケースがあります。
既にSecurityContext
に AuthenticationToken
が格納されていても、認証キーをリクエストの都度確認 するための設定が 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キーが正しいかどうかを認証します。
AuthenticationManager
と AuthenticationProvider
が、1:n の関係だったのに対して、AuthenticationProvider
と UserDetailsService
は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に返却したUserDetails
Objectを元に、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
をそのまま返却せず、UserDetails
Objectを返却後、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());
この再生成時に生成される PreAuthenticatedAuthenticationToken
の getName
メソッドは、基本的に UserDetails
オブジェクトの getUsername
メソッドを元に値を返します。
getUsernameがgetName内でcallされている部分:
public String getName() { ... return ((AuthenticatedPrincipal) this.getPrincipal()).getName(); ...
この PreAuthenticatedAuthenticationToken
の getName
メソッドは、前述の、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キーを返すよう指定しない場合、次回の認証で用いられる認証情報が空になったまま、SecurityContext
に PreAuthenticatedAuthenticationToken
が登録されてしまいます。
そうなると、
前回の認証情報 : 空
今回の認証情報 : 正しい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します。
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
をそのまま受け取ります。
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();
を通じて、いづれのリクエストについても認証を実行します。
###② MyAccessDeniedHandler
・ MyAuthenticationEntryPoint
について
ここで、前半と後半の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の末尾に追加されます。
これから解説する 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
にて SecurityContext
に AnonymousAuhenticationToken
が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が実行される
このエラーハンドリングでは、
AnonymousAuthenticationToken
が SecurityContext
に配置されている場合、認証に失敗していると見做す
という前提の元、処理が進んでいることを理解しておくと良いでしょう。
####ケース2: 認証には成功したが、認可で失敗するケース
ケース1の流れを押さえていれば、ケース2は単純です。
認証に成功しているということは、
AnonymousAuthenticationToken
が SecurityContext
に配置されていない
ということです。
今回のケースでは、認証に成功しているため、PreAuthenticatedAuthenticationToken
が SecurityContext
に配置されています。
認証に成功したが、認可に失敗するケースでは、以下のように処理を実行します。
現在の流れ
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 が実行される
違いは、
-
認証に成功しているため、
AnonymousAuthenticationFilter
にてSecurityContext
にAnonymousAuhenticationToken
がsetされない -
ExceptionTranslationFilter
内のhandleAccessDeniedException
にて、AnonymousAuhenticationToken
の制御を受けずにMyAccessDeniedHandler
を実行する
という2点です。
###③なぜ事前認証ケースにおける認証・認可では、FailureHandlerをFilterにsetしないのか?
結論から言うと、仕様上 AuthenticationEntryPoint
にて認証失敗エラーをハンドリングするようになっているからです。
仮にsetされていた状態で、認証・認可の処理が進んでいたとします。
誤った認証情報を用いて、アプリケーションに接続を試みたとき、FailureHandler
の onAuthenticationFailure
メソッドが発火します。
前述の通り、認証に失敗したユーザを、再度認証するためのページに誘導する役割を担うべきとされる FailureHandler
では、以下の sendRedirect
句が用いられます。この時点で、responseはcommitされます(※6)。
response.sendRedirect("https://www.google.com");
(※6) ServletResponseのcommitとは?:
sentRedirectが実行された時点で、ユーザは特定のURLに遷移するわけではありません。
このsendRedirect
メソッドが実行されると、response ことServletResponse
はcommit済み
というステータスになるだけで、後続の処理が中断されるわけではありません。
無論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を、 MyAccessDeniedHandler
や MyAuthenticationEntryPoint
にて更に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 パッケージを正しく利用するほうが、考えることは少なくて済むと思っています。
この実装例から、それぞれに最適な認証方式を確立できれば嬉しいなと思っています。