使用SpringSecurity搭建授權認證服務(1) -- 基本demo

登入認證是做後臺開發的最基本的能力,初學就知道一個interceptor或者filter攔截所有請求,然後判斷引數是否合理,如此即可。當涉及到某些介面許可權的時候,則if-else判斷以下,也是沒問題的。
但如果判斷多了,業務邏輯也摻雜在一起,降低可讀性的同時也不利於擴充套件和維護。於是就出現了apache shiro, spring security這樣的框架,抽離出認證授權判斷。
由於我現在的專案都是給予springboot的,那選擇spring security就方便很多。接下來基於此構建我的認證授權服務: 基於Token的認證授權服務。

專案初始化

第一個版本,專案初始化https://github.com/Ryan-Miao/spring-security-token-login-server/releases/tag/v1.0

首先學習兩個單詞:

authentication 身份驗證

authorized 經授權的

我們登入鑑權就是兩個步驟,先認證登入,然後許可權校驗。對應到Spring Security裡就是 AuthenticationManagerAccessDecisionManager,前者負責對使用者憑證進行認證,後者對認證後的許可權進行校驗。

首先,建立一個基本的springboot專案。

  • 引入Springboot, Mybatis, Redis, Swagger, Spring Security
  • 配置全域性異常攔截ExceptionInterceptor
  • 配置Redis快取,這裡使用redisson,也可以直接使用starter
  • 配置Spring Security Config

Spring Security參照官方文件配置即可。接下來是自定義和可以修改的地方。

資料表許可權模型

本專案簡單使用 user - role -permission的模型。

  • 一個user可以有多個role
  • 一個role可以指定給多個user
  • 一個role可以擁有多個permission
  • 一個permission也可以從屬於多個role

許可權判定通過判斷user是否擁有permission來決定。通過role實現了user和permission之間的解耦,建立多個role模型,繫結對應的許可權,當新增新使用者的時候,直接指定role就可以授權。

Spring Security自帶了org.springframework.security.provisioning.JdbcUserDetailsManager,它裡面的模型為user-group-authority. 即使用者歸屬使用者組,使用者組有許可權。差不多可以和當前模型一一對應。

認證流程

大體認證流程和涉及的核心類如下:

ApplicationFilterChain的filter順序:

FilterChainProxy(springSecurityFilterChain)執行認證的順序, 忽略的url將不命中任何filter, 而需要認證的url將通過VirtualFilterChain來認證。

使用Token認證

starter預設啟用的基於使用者名稱密碼的basic認證。

通過UsernamePasswordAuthenticationFilter組裝UsernamePasswordAuthenticationToken去認證。

通過org.springframework.security.web.authentication.www.BasicAuthenticationFilter解析header Authorization, 然後組裝成UsernamePasswordAuthenticationToken去給AuthenticationManager認證。

我們要做的就是模仿UsernamePasswordAuthenticationFilter或者BasicAuthenticationFilter解析header將我們的認證憑證傳遞給AuthenticationManager.

兩種方式我實現了一遍,最終選擇了基於UsernamePasswordAuthenticationFilter來實現。


/**
 * @author Ryan Miao
 * @date 2019/5/30 10:11
 * @see org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
 */
public class TokenAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public TokenAuthenticationFilter(String defaultFilterProcessesUrl) {
        super(defaultFilterProcessesUrl);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
        HttpServletResponse response)
        throws AuthenticationException, IOException, ServletException {
        boolean debug = this.logger.isDebugEnabled();
        String token = TokenUtils.readTokenFromRequest(request);
        if (StringUtils.isBlank(token)) {
            throw new UsernameNotFoundException("token not found");
        }

        if (debug) {
            this.logger.debug("Token Authentication Authorization header found ");
        }

        //token包裝類, 使用principal來裝載token
        UsernamePasswordAuthenticationToken tokenAuthenticationToken = new UsernamePasswordAuthenticationToken(
            token, null);

        //AuthenticationManager 負責解析
        Authentication authResult = getAuthenticationManager()
            .authenticate(tokenAuthenticationToken);
        if (debug) {
            this.logger.debug("Authentication success: " + authResult);
        }

        return authResult;
    }

    /**
     * 重寫認證成功後的方法,不跳轉.
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
        HttpServletResponse response, FilterChain chain, Authentication authResult)
        throws IOException, ServletException {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug(
                "Authentication success. Updating SecurityContextHolder to contain: " + authResult);
        }

        SecurityContextHolder.getContext().setAuthentication(authResult);
        getRememberMeServices().loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(
                new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }

        chain.doFilter(request, response);
    }
}
  1. TokenUtils來從request裡拿到我們的憑證,我這裡是從cookie裡取出token的值。
  2. 封裝給UsernamePasswordAuthenticationToken的username欄位
  3. 交給getAuthenticationManager()去認證

認證Provider

上一步拿到使用者憑證,接下來就是對憑證進行認證。由AuthenticationManager提供。簡單理解下AuthenticationManager是什麼。

public interface AuthenticationManager {
    // ~ Methods

    /**
     * Attempts to authenticate the passed {@link Authentication} object, returning a
     * fully populated <code>Authentication</code> object (including granted authorities)
     * if successful.
     * <p>
     * An <code>AuthenticationManager</code> must honour the following contract concerning
     * exceptions:
     * <ul>
     * <li>A {@link DisabledException} must be thrown if an account is disabled and the
     * <code>AuthenticationManager</code> can test for this state.</li>
     * <li>A {@link LockedException} must be thrown if an account is locked and the
     * <code>AuthenticationManager</code> can test for account locking.</li>
     * <li>A {@link BadCredentialsException} must be thrown if incorrect credentials are
     * presented. Whilst the above exceptions are optional, an
     * <code>AuthenticationManager</code> must <B>always</B> test credentials.</li>
     * </ul>
     * Exceptions should be tested for and if applicable thrown in the order expressed
     * above (i.e. if an account is disabled or locked, the authentication request is
     * immediately rejected and the credentials testing process is not performed). This
     * prevents credentials being tested against disabled or locked accounts.
     *
     * @param authentication the authentication request object
     *
     * @return a fully authenticated object including credentials
     *
     * @throws AuthenticationException if authentication fails
     */
    Authentication authenticate(Authentication authentication)
            throws AuthenticationException;
}

嘗試認證傳遞過來的Authentication物件(即我們的UsernamePasswordAuthenticationToken), 如果認證通過,返回全部資訊以及authority許可權,否則丟擲AuthenticationException異常表示認證失敗。

AuthenticationManager的初始化比較複雜,繞了好多路。在我們的SecurityConfig裡可以找到宣告的地方。

//com.example.serverapi.config.SecurityConfig#authenticationManagerBean
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

//com.example.serverapi.config.SecurityConfig#configure(org.springframework.security.config.annotation.web.builders.HttpSecurity)
TokenAuthenticationFilter filter = new TokenAuthenticationFilter("/**");
        filter.setAuthenticationManager(authenticationManagerBean());

而AuthenticationManager是AuthenticationManagerDelegator來代替的,其代理的則是org.springframework.security.authentication.ProviderManager。

所以,我們定義provider來認證上一步的token是否合法。

//com.example.serverapi.config.SecurityConfig#configure(org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder)
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    //DaoAuthenticationConfigurer-DaoAuthenticationProvider用來提供登入時使用者名稱和密碼認證
    //auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    //自定義TokenAuthenticationProvider, 用來提供token認證
    auth.authenticationProvider(new UserTokenAuthenticationProvider());

}

以下是provider全部資訊

//com.example.serverapi.domain.security.config.UserTokenAuthenticationProvider
/**
 * 這裡只使用了username欄位。
 *
 * @author Ryan Miao
 * @date 2019/5/29 22:05
 */
public class UserTokenAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {


    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
        UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

    }


    @Override
    protected UserDetails retrieveUser(String token,
        UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        //驗證token
        TokenManagement tokenManagement = ServerApiApplication.context
            .getBean(TokenManagement.class);
        com.example.serverapi.domain.security.entity.User userInfo = tokenManagement.get(token);

        if (userInfo == null) {
            throw new BadCredentialsException("token認證失敗");
        }

        authentication.setDetails(userInfo);

        Set<SimpleGrantedAuthority> authorities = userInfo.getRoleList().stream()
            .map(Role::getPermissionList)
            .flatMap(Collection::stream)
            .map(p -> new SimpleGrantedAuthority(p.getName())).collect(
                Collectors.toSet());
        return new User(userInfo.getUsername(), userInfo.getPassword(), authorities);
    }

    /**
     * 對應我們的Token令牌類UsernamePasswordAuthenticationToken,可以採用本provide驗證.
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return (UsernamePasswordAuthenticationToken.class
            .isAssignableFrom(authentication));
    }
}
  • supports方法來表示本provider提供的認證範圍,即傳遞UsernamePasswordAuthenticationToken的憑證將接受認證
  • 自定義了我們自己的Token管理方法TokenManagement,來對token進行認證。根據token拿到userinfo則成功
  • 從userInfo裡提取authority,建立一個UserDetails,交給下一步的許可權校驗

許可權校驗

前面截圖裡的filter chain,最前面是我們的自定義filter來認證的,最後面的FilterSecurityInterceptor則是許可權校驗。

//spring-security-core-5.1.4.RELEASE-sources.jar!/org/springframework/security/access/intercept/AbstractSecurityInterceptor.java:229
Authentication authenticated = authenticateIfRequired();

// Attempt authorization
try {
    this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
    publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
            accessDeniedException));

    throw accessDeniedException;
}

可以看到,呼叫accessDecisionManager來判斷是否繼續,許可權不足則丟擲AccessDeniedException,對應處理就是403了。

AccessDecisionManager目前看到有兩種,一個是全域性配置,在我們配置Security Config裡指定哪些url需要哪些許可權。一個是method級別的配置,通過前者校驗後判斷method是否有許可權。

AbstractAccessDecisionManager提供了3種方式。

  • AffirmativeBased 任意一種許可權校驗voter方式通過即通過
  • UnanimousBased 必須所有voter通過才可以通過,即任意失敗則不通過
  • ConsensusBased 通過的voter大於拒絕的voter則通過
  • 其他,可以自己實現AbstractAccessDecisionManager

Voter是什麼呢?AccessDecisionVoter是真正判斷許可權的地方。通過對比當前登入使用者的authority許可權和要訪問的資源的許可權比較,返回如下code。

  • int ACCESS_GRANTED = 1;
  • int ACCESS_ABSTAIN = 0;
  • int ACCESS_DENIED = -1;

許可權移除字首ROLE_

Spring Security預設使用ROLE_作為authority的字首,然後表示式裡的hasRole, hasAuthority幾乎等價,這讓我一直很困惑。尤其是當我使用user-role-permission模型的時候,差點以為hasRole是角色判斷。所以,為了避免混淆,決定把ROLE_的字首去掉。

方法就是宣告一個類, 具體理由可以追尋原始碼hasRole來確定。

@Bean
GrantedAuthorityDefaults grantedAuthorityDefaults() {
    return new GrantedAuthorityDefaults(""); // Remove the ROLE_ prefix
}

統一超級許可權admin

到現在差不多已經可以實現使用者許可權校驗了。我們給需要許可權敏感的api添加註解,比如@PreAuthorize("hasRole('can_list_user')"), 然後permission表裡新增can_list_user, 然後角色表role繫結permission,最後把role指派給user。

然而,當系統需要許可權的地方特別多的時候,繫結role的代價也很高。比如,我們需要一個超級管理員admin角色,那麼這個admin就必須把所有的permission繫結一遍。想想就恐怖。

既然理解了Spring Security的許可權校驗方式,那麼就可以自定義了。我們指定帶有admin的authority直接通過,無需校驗其他許可權。


/**
 * 允許設計admin許可權的使用者直接通過所有認證
 * @author Ryan Miao
 * @date 2019/6/12 20:49
 */
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class GlobalMethodSecurityConfig extends GlobalMethodSecurityConfiguration {

    @Override
    protected AccessDecisionManager accessDecisionManager() {
        List<AccessDecisionVoter<? extends Object>> decisionVoters = new ArrayList<AccessDecisionVoter<? extends Object>>();
        ExpressionBasedPreInvocationAdvice expressionAdvice = new ExpressionBasedPreInvocationAdvice();
        expressionAdvice.setExpressionHandler(getExpressionHandler());
        decisionVoters
            .add(new PreInvocationAuthorizationAdviceVoter(expressionAdvice));
        decisionVoters.add(new RoleVoter());
        decisionVoters.add(new AuthenticatedVoter());
        decisionVoters.add(new AdminVoter());
        return new AffirmativeBased(decisionVoters);
    }
}


/**
 * 擁有admin許可權的角色,直接包含所有許可權
 *
 * @author Ryan Miao
 * @date 2019/6/12 20:00
 */
public class AdminVoter implements AccessDecisionVoter<Object> {

    private static final String ADMIN = "admin";

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }

    @Override
    public int vote(Authentication authentication, Object object,
        Collection<ConfigAttribute> attributes) {
        if (authentication == null) {
            return ACCESS_DENIED;
        }
        int result = ACCESS_ABSTAIN;
        Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);

        for (ConfigAttribute attribute : attributes) {
            if (this.supports(attribute)) {
                result = ACCESS_DENIED;

                // Attempt to find a matching granted authority
                for (GrantedAuthority authority : authorities) {
                    if (ADMIN.equals(authority.getAuthority())) {
                        return ACCESS_GRANTED;
                    }
                }
            }
        }

        return result;
    }

    Collection<? extends GrantedAuthority> extractAuthorities(
        Authentication authentication) {
        return authentication.getAuthorities();
    }

    @Override
    public boolean supports(Class clazz) {
        return true;
    }
}

總結

初步梳理了Spring Security的認證邏輯和流程,細節的地方還很多,比如SpEL的實現邏輯。但差不多可以理解認證授權是如何實現的了,基於此也足夠開展我們的業務開發了。如果說還有想要改造的地方,就是動態許可權修改了,為了簡化邏輯模型,不做動態許可權設定,所有許可權初始化指定即可。簡單最重要