【Spring Security】認証アーキテクチャ
はじめに
過去の記事でも簡単に触れているのですが、改めてSpring Securityの仕組みについて深掘りをしていきたいと思います。 少し内容が飛躍するかもしれませんので、過去の記事など参考にしていただけますと幸いです。
Security Filter
認証・認可のフィルター
こちらの記事でも記載しているのですが、Spring Securityはサーブレットフィルターの仕組みを利用し、Security Filterとしていくつかの処理を行なっています。 その中の1つに認証や認可が含まれます。
どのフィルターを採用するかは、これまでConfigにて設定していた内容になります。
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain secutiryFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/fuga").hasRole("USER")
.requestMatchers("/piyo").hasRole("ADMIN")
.anyRequest().authenticated());
return http.build();
}
}
HttpSecurity
の各メソッドが該当のフィルターを追加する処理をしており、これにより対応する処理が実行されるようになります。
一部ですが以下のような関連となっています。
メソッド | 追加フィルター | 説明 |
---|---|---|
httpBasic() | BasicAuthenticationFilter | ベーシック認証 |
formLogin() | UsernamePasswordAuthenticationFilter | フォームによる認証 |
rememberMe() | RememberMeAythenticationFilter | リメンバーミーによる認証 |
authorizeHttpRequests() | AuthorizationFilter | 認可 |
実行の確認
どのようなSecurity Filterが実行されているかを確認したい場合は、以下の設定を行います。
- アプリ起動クラスに
@EnableWebSecurity(debug = true)
を追加HogeApplication.java@SpringBootApplication @EnableWebSecurity(debug = true) public class HogeApplication { public static void main(String[] args) { SpringApplication.run(HogeApplication.class, args); } }
application,yml
にログ出力設定を追加application.ymllogging: level: org: springframework: security: DEBUG
この設定後にリクエストを送信すると、以下のような内容がコンソールに出力されるかと思います。
Security filter chain: [
DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter
HeaderWriterFilter
CorsFilter
CsrfFilter
LogoutFilter
BasicAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
ExceptionTranslationFilter
AuthorizationFilter
]
出力結果から認証、認可に関するフィルターが実行されていることがわかります。 この結果はフィルターを追加するときに重要になります。
カスタムフィルターの追加
例えば、認証の前後で何か処理を行いたいといった場合は、カスタムフィルターを作成し、addFilterBefore()
またはaddFilterAfter()
メソッドによって追加をします。
追加の際に、フィルタークラスを指定することで追加位置を指定します。
Security Filterを作成する場合は、GenericFilterBean
またはこれを継承しているクラスを継承します。
public class CustomFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 実行したい処理を記述
chain.doFilter(request, response);
}
}
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain secutiryFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults())
.addFilterBefore(new CustomFilter(), CorsFilter.class) //追加
.addFilterAfter(new CustomFilter(), BasicAuthenticationFilter.class) //追加
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/fuga").hasRole("USER")
.requestMatchers("/piyo").hasRole("ADMIN")
.anyRequest().authenticated());
return http.build();
}
}
Security filter chain: [
DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter
HeaderWriterFilter
CustomFilter
CorsFilter
CsrfFilter
LogoutFilter
BasicAuthenticationFilter
CustomFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
ExceptionTranslationFilter
AuthorizationFilter
]
認証アーキテクチャ
上述したように、認証の処理はどのフィルターが実行されるかで決まりますが、基本的な考え方はどれも同じです。
ここではUsernamePasswordAuthenticationFilter
を例に取り上げます。
Filter
UsernamePasswordAuthenticationFilter
の実装をみてみます。
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
//...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
//...
}
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
//...
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult);
}
catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed);
}
catch (AuthenticationException ex) {
// Authentication failed
unsuccessfulAuthentication(request, response, ex);
}
}
//...
}
内部的にはAbstractAuthenticationProcessingFilter
を継承しており、doFilter()
がフィルターの処理となります。
フィルターの処理で実行しているattemptAuthentication()
が抽象メソッドとなっており、UsernamepasswordAuthenticationFilter
で実装しています。
このメソッドが認証処理へとつながります。
具体的には16行目のthis.getAuthenticationManager().authenticate(authRequest);
が該当します。
このメソッドに対し、authRequest
としてUsernamePasswordAuthenticationToken
のインスタンスを渡しています。
Authentication
を実装しており、認証に関する情報を保持する役割があります。
ここではusername
とpassword
が設定されています。
AuthenticationManager / ProviderManager
フィルターからAuthenticationManager
のauthenticate()
が実行されていることがわかります。
AuthenticationManager
はインターフェースであるため、実際には実装クラスであるProviderManager
のauthenticate()
が実行されます。
ProviderManager
の役割は、authenticated()
の引数となっているAuthentication(UsernamePasswordAuthenticationToken)
から適切な認証方法を選ぶことにあります。
実際にProviderManager
の実装をみてみます。
少し処理が長いため一部省略しています。
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
Class<? extends Authentication> toTest = authentication.getClass();
//...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//...
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException ex) {
prepareException(ex, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw ex;
}
catch (AuthenticationException ex) {
lastException = ex;
}
}
//...
}
//...
}
7行目でAuthenticationProvider
をループして処理をしています。
AuthenticationProvider
は認証方法が定義されたものになります。
いくつかある認証方法を1つずつみていき、8〜10行目の条件式(provider.supports()
)で実際に使用する認証方法の判定をしています。
判定方法などAuthenticationProvider
については後述します。
認証方法が決まった後は、16行目にprovider.authenticate(authentication);
とあるようにAuthenticationProvider
のauthenticate()
メソッドを実行して、認証結果を受け取っています。
authenticate()
には、フィルターと同様にユーザー情報が設定されているAuthentication
を引数として渡します。
AuthenticationProvider / DaoAuthenticationProvider
AuthenticationProvider
は、前述の通り認証方法を定義するためのインターフェースになります。
実際には認証方法に合わせて実装されたクラスがいくつか存在します。
今回の場合はDaoAuthenticationProvider
が該当します。
ではDaoAuthenticationProvider
の実装を確認してみます。
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
//...
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
//...
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
//...
}
public abstract class AbstractUserDetailsAuthenticationProvider
implements AuthenticationProvider, InitializingBean, MessageSourceAware {
//...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//...
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
//...
return createSuccessAuthentication(principalToReturn, authentication, user);
}
//...
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
}
DaoAuthenticationProvider
はAbstractUserDetailsAuthenticationProvider
を継承しています。
ProviderManager
で利用されているauthenticate()
とsupports()
は抽象クラス側で実装されています。
まずsupports()
についてみていきます。
実装の通りなのですが、ユーザー情報として渡されてきたAuthentication
のインスタンスがUsernamePasswordAuthenticationToken
クラスであればtrue
が返されます。
つまり、UsernamePasswordAuthenticationToken
の場合に使用する認証方法であることを示しています。
次にauthenticate()
についてみていきます。authenticate()
では抽象メソッドであるretrieveUser()
でユーザー情報を取得しています。
これがDaoAuthenticationProvider
で実装されており、その中でUserDetailsService
のloadUserByUsername()
が使用されます。
もう1つadditionalAuthenticationChecks()
という抽象メソッドがあります。
このメソッドもDaoAuthenticationProvider
で実装されており、取得したユーザー情報のパスワードとリクエスト時に送られたパスワードが一致しているかを検証しています。
一致しなかった場合はAuthenticationException
が投げられ、一致した場合は認証成功として結果がフィルターまで戻されます。
まとめ
以下簡単なまとめです。
Filter
: リクエストからユーザー情報としてAuthentication
のインスタンスを生成し、AuthenticationManager
によって認証を行う。AuthenticationManager
: どの認証を行うか、Authentication
のインスタンスにより判断する。AuthenticationProvider
: 決まった認証処理を実行し、結果を返す。