spring.svg

【Spring Security】認証アーキテクチャ

SpringBoot

はじめに

過去の記事でも簡単に触れているのですが、改めてSpring Securityの仕組みについて深掘りをしていきたいと思います。 少し内容が飛躍するかもしれませんので、過去の記事など参考にしていただけますと幸いです。

Security Filter

認証・認可のフィルター

こちらの記事でも記載しているのですが、Spring Securityはサーブレットフィルターの仕組みを利用し、Security Filterとしていくつかの処理を行なっています。 その中の1つに認証や認可が含まれます。

どのフィルターを採用するかは、これまでConfigにて設定していた内容になります。

WebSercurityConfig.java
@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が実行されているかを確認したい場合は、以下の設定を行います。

  1. アプリ起動クラスに@EnableWebSecurity(debug = true)を追加
    HogeApplication.java
    @SpringBootApplication
    @EnableWebSecurity(debug = true)
    public class HogeApplication {
        public static void main(String[] args) {
            SpringApplication.run(HogeApplication.class, args);
        }
    }
  2. application,ymlにログ出力設定を追加
    application.yml
    logging:
      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またはこれを継承しているクラスを継承します。

CustomFilter.java
public class CustomFilter extends GenericFilterBean {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
            throws IOException, ServletException {
        // 実行したい処理を記述
        chain.doFilter(request, response);
    }
}
WebSercurityConfig.java
@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を例に取り上げます。

UsernamePassword AuthenticationFilter authenticate() Authentication UsernamePassword AuthenticationToken Authentication Manager Provider Manager authenticate() Authentication Provider DaoAuthentication Provider loadUserByusername() UserDetails UserDetails Service implements implements implements

Filter

UsernamePasswordAuthenticationFilterの実装をみてみます。

UsernamePasswordAuthenticationFilter.class
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);
    }
    //...
}
AbstractAuthenticationProcessingFilter.class
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を実装しており、認証に関する情報を保持する役割があります。 ここではusernamepasswordが設定されています。

AuthenticationManager / ProviderManager

フィルターからAuthenticationManagerauthenticate()が実行されていることがわかります。 AuthenticationManagerはインターフェースであるため、実際には実装クラスであるProviderManagerauthenticate()が実行されます。

ProviderManagerの役割は、authenticated()の引数となっているAuthentication(UsernamePasswordAuthenticationToken)から適切な認証方法を選ぶことにあります。 実際にProviderManagerの実装をみてみます。 少し処理が長いため一部省略しています。

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);とあるようにAuthenticationProviderauthenticate()メソッドを実行して、認証結果を受け取っています。 authenticate()には、フィルターと同様にユーザー情報が設定されているAuthenticationを引数として渡します。

AuthenticationProvider / DaoAuthenticationProvider

AuthenticationProviderは、前述の通り認証方法を定義するためのインターフェースになります。 実際には認証方法に合わせて実装されたクラスがいくつか存在します。 今回の場合はDaoAuthenticationProviderが該当します。

ではDaoAuthenticationProviderの実装を確認してみます。

DaoAuthenticationProvider.class
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);
        }
    }
    //...
}
AbstractUserDetailsAuthenticationProvider.class
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));
    }
}

DaoAuthenticationProviderAbstractUserDetailsAuthenticationProviderを継承しています。 ProviderManagerで利用されているauthenticate()supports()は抽象クラス側で実装されています。

まずsupports()についてみていきます。 実装の通りなのですが、ユーザー情報として渡されてきたAuthenticationのインスタンスがUsernamePasswordAuthenticationTokenクラスであればtrueが返されます。 つまり、UsernamePasswordAuthenticationTokenの場合に使用する認証方法であることを示しています。

次にauthenticate()についてみていきます。authenticate()では抽象メソッドであるretrieveUser()でユーザー情報を取得しています。 これがDaoAuthenticationProviderで実装されており、その中でUserDetailsServiceloadUserByUsername()が使用されます。 もう1つadditionalAuthenticationChecks()という抽象メソッドがあります。 このメソッドもDaoAuthenticationProviderで実装されており、取得したユーザー情報のパスワードとリクエスト時に送られたパスワードが一致しているかを検証しています。 一致しなかった場合はAuthenticationExceptionが投げられ、一致した場合は認証成功として結果がフィルターまで戻されます。

まとめ

以下簡単なまとめです。

  • Filter: リクエストからユーザー情報としてAuthenticationのインスタンスを生成し、AuthenticationManagerによって認証を行う。
  • AuthenticationManager: どの認証を行うか、Authenticationのインスタンスにより判断する。
  • AuthenticationProvider: 決まった認証処理を実行し、結果を返す。