1. 程式人生 > >Spring Security 案例實現和執行流程剖析

Spring Security 案例實現和執行流程剖析

線上演示

演示地址:http://139.196.87.48:9002/kitty

使用者名稱:admin 密碼:admin

Spring Security

Spring Security 是 Spring 社群的一個頂級專案,也是 Spring Boot 官方推薦使用的安全框架。除了常規的認證(Authentication)和授權(Authorization)之外,Spring Security還提供了諸如ACLs,LDAP,JAAS,CAS等高階特性以滿足複雜場景下的安全需求。

Spring Security 應用級別的安全主要包含兩個主要部分,即登入認證(Authentication)和訪問授權(Authorization),首先使用者登入的時候傳入登入資訊,登入驗證器完成登入認證並將登入認證好的資訊儲存到請求上下文,然後在進行其他操作,如介面訪問、方法呼叫時,許可權認證器從上下文中獲取登入認證資訊,然後根據認證資訊獲取許可權資訊,通過許可權資訊和特定的授權策略決定是否授權。

接下來,本教程將分別對登入認證和訪問授權的執行流程進行剖析,並在最後給出完整的案例實現,如果覺得先讀前面原理比較難懂,可以先學習後面的實現案例,再結合案例理解登入認證和訪問授權的執行原理。

登入認證

登入認證過濾器

如果在繼承 WebSecurityConfigurerAdapter 的配置類中的 configure(HttpSecurity http) 方法中有配置 HttpSecurity 的 formLogin,則會返回一個 FormLoginConfigurer 物件。如下是一個 Spring Security 的配置樣例, formLogin().x

.x 就是配置使用內建的登入驗證過濾器,預設實現為 UsernamePasswordAuthenticationFilter

WebSecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;
@Override public void configure(AuthenticationManagerBuilder auth) throws Exception { // 使用自定義身份驗證元件 auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService)); } @Override protected void configure(HttpSecurity http) throws Exception { http.cors().and().csrf().disable() .authorizeRequests() // 首頁和登入頁面 .antMatchers("/").permitAll() // 其他所有請求需要身份認證 .anyRequest().authenticated() // 配置登入認證 .and().formLogin().loginProcessingUrl("/login"); } }

檢視 HttpSecurity , formLogion 方法返回一個 FormLoginConfigurer 物件。

HttpSecurity.java

    public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception {
        return getOrApply(new FormLoginConfigurer<>());
    }

而 FormLoginConfigurer 的建構函式內綁定了一個 UsernamePasswordAuthenticationFilter 過濾器。

FormLoginConfigurer.java

    public FormLoginConfigurer() {
        super(new UsernamePasswordAuthenticationFilter(), null);
        usernameParameter("username");
        passwordParameter("password");
    }

再看 UsernamePasswordAuthenticationFilter 過濾器的建構函式內綁定了 POST 型別的 /login 請求,也就是說,如果配置了 formLogin 的相關資訊,那麼在使用 POST 型別的 /login URL進行登入的時候就會被這個過濾器攔截,並進行登入驗證,登入驗證過程我們下面繼續分析。

UsernamePasswordAuthenticationFilter.java

    public UsernamePasswordAuthenticationFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }

檢視 UsernamePasswordAuthenticationFilter,發現它繼承了 AbstractAuthenticationProcessingFilter,AbstractAuthenticationProcessingFilter 中的 doFilter 包含了觸發登入認證執行流程的相關邏輯。

AbstractAuthenticationProcessingFilter.java

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        ...
Authentication authResult; try { authResult = attemptAuthentication(request, response); if (authResult == null) { // return immediately as subclass has indicated that it hasn't completed // authentication return; } sessionStrategy.onAuthentication(authResult, request, response); }
     ...
successfulAuthentication(request, response, chain, authResult); }

上面的登入邏輯主要步驟有兩個:

1. attemptAuthentication(request, response)

這是 AbstractAuthenticationProcessingFilter  中的一個抽象方法,包含登入主邏輯,由其子類實現具體的登入驗證,如 UsernamePasswordAuthenticationFilter 是使用表單方式登入的具體實現。如果是非表單登入的方式,如JNDI等其他方式登入的可以通過繼承 AbstractAuthenticationProcessingFilter 自定義登入實現。UsernamePasswordAuthenticationFilter 的登入實現邏輯如下。

UsernamePasswordAuthenticationFilter.java

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
     // 獲取使用者名稱和密碼
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        if (username == null) {
            username = "";
        }

        if (password == null) {
            password = "";
        }

        username = username.trim();

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    }

2. successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult)

登入成功之後,將認證後的 Authentication 物件儲存到請求執行緒上下文,這樣在授權階段就可以獲取到 Authentication 認證資訊,並利用 Authentication 內的許可權資訊進行訪問控制判斷。

AbstractAuthenticationProcessingFilter.java

    protected void successfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain, Authentication authResult)
            throws IOException, ServletException {

        if (logger.isDebugEnabled()) {
            logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
        }
     // 登入成功之後,把認證後的 Authentication 物件儲存到請求執行緒上下文,這樣在授權階段就可以獲取到此認證資訊進行訪問控制判斷
        SecurityContextHolder.getContext().setAuthentication(authResult);

        rememberMeServices.loginSuccess(request, response, authResult);

        // Fire event
        if (this.eventPublisher != null) {
            eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                    authResult, this.getClass()));
        }

        successHandler.onAuthenticationSuccess(request, response, authResult);
    }

從上面的登入邏輯我們可以看到,Spring Security的登入認證過程是委託給 AuthenticationManager 完成的,它先是解析出使用者名稱和密碼,然後把使用者名稱和密碼封裝到一個UsernamePasswordAuthenticationToken 中,傳遞給 AuthenticationManager,交由 AuthenticationManager 完成實際的登入認證過程。 

AuthenticationManager.java

package org.springframework.security.authentication;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;

/**
* Processes an {@link Authentication} request.
* @author Ben Alex
*/
public interface AuthenticationManager {

  Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

AuthenticationManager 提供了一個預設的 實現 ProviderManager,而 ProviderManager 又將驗證委託給了 AuthenticationProvider。

ProviderManager.java

    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
     ...
   for (AuthenticationProvider provider : getProviders()) { if (!provider.supports(toTest)) { continue; }try { result = provider.authenticate(authentication); if (result != null) { copyDetails(authentication, result); break; } }
     ...
}

根據驗證方式的多樣化,AuthenticationProvider 衍生出多種型別的實現,AbstractUserDetailsAuthenticationProvider 是 AuthenticationProvider 的抽象實現,定義了較為統一的驗證邏輯,各種驗證方式可以選擇直接繼承 AbstractUserDetailsAuthenticationProvider 完成登入認證,如 DaoAuthenticationProvider 就是繼承了此抽象類,完成了從DAO方式獲取驗證需要的使用者資訊的。

AbstractUserDetailsAuthenticationProvider.java

public Authentication authenticate(Authentication authentication) throws AuthenticationException {// Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);
        if (user == null) {
            cacheWasUsed = false;
            try {
          // 子類根據自身情況從指定的地方載入認證需要的使用者資訊 user
= retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } ...try {
       // 前置檢查,一般是檢查賬號狀態,如是否鎖定之類 preAuthenticationChecks.check(user);

       // 進行一般邏輯認證,如 DaoAuthenticationProvider 實現中的密碼驗證就是在這裡完成的 additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } ...

     // 後置檢查,如可以檢查密碼是否過期之類 postAuthenticationChecks.check(user);
     ...
     // 驗證成功之後返回包含完整認證資訊的 Authentication 物件
return createSuccessAuthentication(principalToReturn, authentication, user); }

如上面所述, AuthenticationProvider 通過 retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) 獲取驗證資訊,對於我們一般所用的 DaoAuthenticationProvider 是由 UserDetailsService 專門負責獲取驗證資訊的。

DaoAuthenticationProvider.java

    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        try {
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
            }
            return loadedUser;
        }
    }

UserDetailsService 介面只有一個方法,loadUserByUsername(String username),一般需要我們實現此介面方法,根據使用者名稱載入登入認證和訪問授權所需要的資訊,並返回一個 UserDetails的實現類,後面登入認證和訪問授權都需要用到此中的資訊。

public interface UserDetailsService {
    /**
     * Locates the user based on the username. In the actual implementation, the search
     * may possibly be case sensitive, or case insensitive depending on how the
     * implementation instance is configured. In this case, the <code>UserDetails</code>
     * object that comes back may have a username that is of a different case than what
     * was actually requested..
     *
     * @param username the username identifying the user whose data is required.
     *
     * @return a fully populated user record (never <code>null</code>)
     *
     * @throws UsernameNotFoundException if the user could not be found or the user has no
     * GrantedAuthority
     */
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetails 提供了一個預設實現 User,主要包含使用者名稱(username)、密碼(password)、許可權(authorities)和一些賬號或密碼狀態的標識。

如果預設實現滿足不了你的需求,可以根據需求定製自己的 UserDetails,然後在 UserDetailsService 的 loadUserByUsername 中返回即可。

public class User implements UserDetails, CredentialsContainer {// ~ Instance fields
    // ================================================================================================
    private String password;
    private final String username;
    private final Set<GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    private final boolean accountNonLocked;
    private final boolean credentialsNonExpired;
    private final boolean enabled;

    // ~ Constructors
    // ===================================================================================================
    public User(String username, String password,
            Collection<? extends GrantedAuthority> authorities) {
        this(username, password, true, true, true, true, authorities);
    }

   ... }

退出登入

Spring Security 提供了一個預設的登出過濾器 LogoutFilter,預設攔截路徑是 /logout,當訪問 /logout 路徑的時候,LogoutFilter 會進行退出處理。

LogoutFilter.java

package org.springframework.security.web.authentication.logout;

public class LogoutFilter extends GenericFilterBean {

    // ~ Instance fields
    // ================================================================================================
    private RequestMatcher logoutRequestMatcher;
    private final LogoutHandler handler;
    private final LogoutSuccessHandler logoutSuccessHandler;

    // ~ Constructors
    // ===================================================================================================
    public LogoutFilter(LogoutSuccessHandler logoutSuccessHandler,
            LogoutHandler... handlers) {
        this.handler = new CompositeLogoutHandler(handlers);
        Assert.notNull(logoutSuccessHandler, "logoutSuccessHandler cannot be null");
        this.logoutSuccessHandler = logoutSuccessHandler;
        setFilterProcessesUrl("/logout");  // 繫結 /logout
    }// ~ Methods
    // ========================================================================================================
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        if (requiresLogout(request, response)) {
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();this.handler.logout(request, response, auth);  // 登出處理,可能包含session、cookie、認證資訊的清理工作

            logoutSuccessHandler.onLogoutSuccess(request, response, auth);  // 退出後的操作,可能是跳轉、返回成功狀態等

            return;
        }

        chain.doFilter(request, response);
    }

   ...
}

如下是 SecurityContextLogoutHandler 中的登出處理實現。

SecurityContextLogoutHandler.java

    public void logout(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) {
        // 讓 session 失效 if (invalidateHttpSession) {
            HttpSession session = request.getSession(false);
            if (session != null) {
                logger.debug("Invalidating session: " + session.getId());
                session.invalidate();
            }
        }
     // 清理 Security 上下文,其中包含登入認證資訊
        if (clearAuthentication) {
            SecurityContext context = SecurityContextHolder.getContext();
            context.setAuthentication(null);
        }
        SecurityContextHolder.clearContext();
    }

 

訪問授權

訪問授權主要分為兩種:通過URL方式的介面訪問控制和方法呼叫的許可權控制。

介面訪問許可權

在通過比如瀏覽器使用URL訪問後臺介面時,是否允許訪問此URL,就是介面訪問許可權。

在進行介面訪問時,會由 FilterSecurityInterceptor 進行攔截並進行授權。

FilterSecurityInterceptor 繼承了 AbstractSecurityInterceptor 並實現了 javax.servlet.Filter 介面, 所以在URL訪問的時候都會被過濾器攔截,doFilter 實現如下。

FilterSecurityInterceptor.java

    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        FilterInvocation fi = new FilterInvocation(request, response, chain);
        invoke(fi);
    }

doFilter 方法又呼叫了自身的 invoke 方法, invoke 方法又呼叫了父類 AbstractSecurityInterceptor 的 beforeInvocation 方法。

FilterSecurityInterceptor.java

    public void invoke(FilterInvocation fi) throws IOException, ServletException {
        if ((fi.getRequest() != null)
                && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
                && observeOncePerRequest) {
            // filter already applied to this request and user wants us to observe
            // once-per-request handling, so don't re-do security checking
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        }
        else {
            // first time this request being called, so perform security checking
            if (fi.getRequest() != null && observeOncePerRequest) {
                fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
            }

            InterceptorStatusToken token = super.beforeInvocation(fi);

            try {
                fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
            }
            finally {
                super.finallyInvocation(token);
            }

            super.afterInvocation(token, null);
        }
    }

方法呼叫許可權

在進行後臺方法呼叫時,是否允許該方法呼叫,就是方法呼叫許可權。比如在方法上添加了此類註解 @PreAuthorize("hasRole('ROLE_ADMIN')") ,Security 方法註解的支援需要在任何配置類中(如 WebSecurityConfigurerAdapter )新增 @EnableGlobalMethodSecurity(prePostEnabled = true) 開啟,才能夠使用。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

}

在進行方法呼叫時,會由 MethodSecurityInterceptor 進行攔截並進行授權。

MethodSecurityInterceptor 繼承了 AbstractSecurityInterceptor 並實現了AOP 的 org.aopalliance.intercept.MethodInterceptor 介面, 所以可以在方法呼叫時進行攔截。

MethodSecurityInterceptor .java

    public Object invoke(MethodInvocation mi) throws Throwable {
        InterceptorStatusToken token = super.beforeInvocation(mi);

        Object result;
        try {
            result = mi.proceed();
        }
        finally {
            super.finallyInvocation(token);
        }
        return super.afterInvocation(token, result);
    }

我們看到,MethodSecurityInterceptor 跟 FilterSecurityInterceptor 一樣, 都是通過呼叫父類 AbstractSecurityInterceptor 的相關方法完成授權,其中 beforeInvocation 是完成許可權認證的關鍵。

AbstractSecurityInterceptor.java

protected InterceptorStatusToken beforeInvocation(Object object) {
        ...
     // 通過 SecurityMetadataSource 獲取許可權配置資訊,可以定製實現自己的許可權資訊獲取邏輯
        Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);

     ...

     // 確認是否經過登入認證      Authentication authenticated
= authenticateIfRequired(); // Attempt authorization try {
       // 通過 AccessDecisionManager 完成授權認證,預設實現是 AffirmativeBased
this.accessDecisionManager.decide(authenticated, object, attributes); } ... }

上面程式碼顯示 AbstractSecurityInterceptor 又是委託授權認證器 AccessDecisionManager 完成授權認證,預設實現是 AffirmativeBased, decide 方法實現如下。

AffirmativeBased.java

public void decide(Authentication authentication, Object object,
            Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
        int deny = 0;
for (AccessDecisionVoter voter : getDecisionVoters()) {
  
        // 通過各種投票策略,最終決定是否授權 
int result = voter.vote(authentication, object, configAttributes); switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED: return;
case AccessDecisionVoter.ACCESS_DENIED: deny++; break;
default: break; }     ... }

而 AccessDecisionManager 決定授權又是通過一個授權策略集合(AccessDecisionVoter )決定的,授權決定的原則是:

  1. 遍歷所有授權策略, 如果有其中一個返回 ACCESS_GRANTED,則同意授權。

  2. 否則,等待遍歷結束,統計 ACCESS_DENIED 個數,只要拒絕數大於1,則不同意授權。

對於介面訪問授權,也就是 FilterSecurityInterceptor 管理的URL授權,預設對應的授權策略只有一個,就是 WebExpressionVoter,它的授權策略主要是根據 WebSecurityConfigurerAdapter 內配置的路徑訪問策略進行匹配,然後決定是否授權。

WebExpressionVoter.java

/**
 * Voter which handles web authorisation decisions.
 * @author Luke Taylor
 * @since 3.0
 */
public class WebExpressionVoter implements AccessDecisionVoter<FilterInvocation> {
    private SecurityExpressionHandler<FilterInvocation> expressionHandler = new DefaultWebSecurityExpressionHandler();

    public int vote(Authentication authentication, FilterInvocation fi,
            Collection<ConfigAttribute> attributes) {
        assert authentication != null;
        assert fi != null;
        assert attributes != null;

        WebExpressionConfigAttribute weca = findConfigAttribute(attributes);

        if (weca == null) {
            return ACCESS_ABSTAIN;
        }

        EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, fi);
ctx
= weca.postProcess(ctx, fi); return ExpressionUtils.evaluateAsBoolean(weca.getAuthorizeExpression(), ctx) ? ACCESS_GRANTED : ACCESS_DENIED; } ... }

對於方法呼叫授權,在全域性方法安全配置類裡,可以看到給 MethodSecurityInterceptor 預設配置的有 RoleVoter、AuthenticatedVoter、Jsr250Voter、和 PreInvocationAuthorizationAdviceVoter,其中 Jsr250Voter、PreInvocationAuthorizationAdviceVoter 都需要開啟指定的開關,才會新增支援。

GlobalMethodSecurityConfiguration.java

@Configuration
public class GlobalMethodSecurityConfiguration implements ImportAware, SmartInitializingSingleton {
...
private MethodSecurityInterceptor methodSecurityInterceptor;
  @Bean
public MethodInterceptor methodSecurityInterceptor() throws Exception { this.methodSecurityInterceptor = isAspectJ() ? new AspectJMethodSecurityInterceptor() : new MethodSecurityInterceptor(); methodSecurityInterceptor.setAccessDecisionManager(accessDecisionManager()); methodSecurityInterceptor.setAfterInvocationManager(afterInvocationManager()); methodSecurityInterceptor .setSecurityMetadataSource(methodSecurityMetadataSource()); RunAsManager runAsManager = runAsManager(); if (runAsManager != null) { methodSecurityInterceptor.setRunAsManager(runAsManager); } return this.methodSecurityInterceptor; } protected AccessDecisionManager accessDecisionManager() { List<AccessDecisionVoter<? extends Object>> decisionVoters = new ArrayList<AccessDecisionVoter<? extends Object>>(); ExpressionBasedPreInvocationAdvice expressionAdvice = new ExpressionBasedPreInvocationAdvice(); expressionAdvice.setExpressionHandler(getExpressionHandler()); if (prePostEnabled()) { decisionVoters .add(new PreInvocationAuthorizationAdviceVoter(expressionAdvice)); } if (jsr250Enabled()) { decisionVoters.add(new Jsr250Voter()); } decisionVoters.add(new RoleVoter()); decisionVoters.add(new AuthenticatedVoter()); return new AffirmativeBased(decisionVoters); }

  ...
}

RoleVoter 是根據角色進行匹配授權的策略。

RoleVoter.java

public class RoleVoter implements AccessDecisionVoter<Object> {

   // RoleVoter  預設角色名以 "ROLE_" 為字首。 private String rolePrefix = "ROLE_";public boolean supports(ConfigAttribute attribute) { if ((attribute.getAttribute() != null) && attribute.getAttribute().startsWith(getRolePrefix())) { return true; } else { return false; } }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 (attribute.getAttribute().equals(authority.getAuthority())) { return ACCESS_GRANTED; } } } } return result; } Collection<? extends GrantedAuthority> extractAuthorities(Authentication authentication) { return authentication.getAuthorities(); } }

AuthenticatedVoter 主要是針對有配置以下幾個屬性來決定授權的策略。

IS_AUTHENTICATED_REMEMBERED:記住我登入狀態

IS_AUTHENTICATED_ANONYMOUSLY:匿名認證狀態

IS_AUTHENTICATED_FULLY: 完全登入狀態,即非上面兩種型別

AuthenticatedVoter.java

public int vote(Authentication authentication, Object object,
            Collection<ConfigAttribute> attributes) {
        int result = ACCESS_ABSTAIN;

        for (ConfigAttribute attribute : attributes) {
            if (this.supports(attribute)) {
                result = ACCESS_DENIED;
          // 完全登入狀態
                if (IS_AUTHENTICATED_FULLY.equals(attribute.getAttribute())) {
                    if (isFullyAuthenticated(authentication)) {
                        return ACCESS_GRANTED;
                    }
                }
          // 記住我登入狀態
                if (IS_AUTHENTICATED_REMEMBERED.equals(attribute.getAttribute())) {
                    if (authenticationTrustResolver.isRememberMe(authentication)
                            || isFullyAuthenticated(authentication)) {
                        return ACCESS_GRANTED;
                    }
                }
          // 匿名登入狀態
                if (IS_AUTHENTICATED_ANONYMOUSLY.equals(attribute.getAttribute())) {
                    if (authenticationTrustResolver.isAnonymous(authentication)
                            || isFullyAuthenticated(authentication)
                            || authenticationTrustResolver.isRememberMe(authentication)) {
                        return ACCESS_GRANTED;
                    }
                }
            }
        }

        return result;
    }

PreInvocationAuthorizationAdviceVoter 是針對類似  @PreAuthorize("hasRole('ROLE_ADMIN')")  註解解析並進行授權的策略。

PreInvocationAuthorizationAdviceVoter.java

public class PreInvocationAuthorizationAdviceVoter implements AccessDecisionVoter<MethodInvocation> {private final PreInvocationAuthorizationAdvice preAdvice;
public int vote(Authentication authentication, MethodInvocation method,
            Collection<ConfigAttribute> attributes) {

        PreInvocationAttribute preAttr = findPreInvocationAttribute(attributes);

        if (preAttr == null) {
            // No expression based metadata, so abstain
            return ACCESS_ABSTAIN;
        }

        boolean allowed = preAdvice.before(authentication, method, preAttr);

        return allowed ? ACCESS_GRANTED : ACCESS_DENIED;
    }

    private PreInvocationAttribute findPreInvocationAttribute(
            Collection<ConfigAttribute> config) {
        for (ConfigAttribute attribute : config) {
            if (attribute instanceof PreInvocationAttribute) {
                return (PreInvocationAttribute) attribute;
            }
        }
        return null;
    }
}

PreInvocationAuthorizationAdviceVoter 解析出註解屬性配置, 然後通過呼叫 PreInvocationAuthorizationAdvice 的前置通知方法進行授權認證,預設實現類似 ExpressionBasedPreInvocationAdvice,通知內主要進行了內容的過濾和許可權表示式的匹配。

ExpressionBasedPreInvocationAdvice.java

public class ExpressionBasedPreInvocationAdvice implements PreInvocationAuthorizationAdvice {
    private MethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();

    public boolean before(Authentication authentication, MethodInvocation mi, PreInvocationAttribute attr) {
        PreInvocationExpressionAttribute preAttr = (PreInvocationExpressionAttribute) attr;
        EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, mi);
        Expression preFilter = preAttr.getFilterExpression();
        Expression preAuthorize = preAttr.getAuthorizeExpression();

        if (preFilter != null) {
            Object filterTarget = findFilterTarget(preAttr.getFilterTarget(), ctx, mi);
            expressionHandler.filter(filterTarget, preFilter, ctx);
        }

        if (preAuthorize == null) {
            return true;
        }

        return ExpressionUtils.evaluateAsBoolean(preAuthorize, ctx);
    }

  ...
}

案例實現

接下來,我們以一個實現案例來進行說明講解。

新建工程

新建一個 Spring Boot 專案 springboot-spring-security。

新增依賴

新增專案依賴,主要是 Spring Security 和 JWT,另外新增 Swagger 和 fastjson 作為輔助工具。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.ivan.demo</groupId>
    <artifactId>springboot-spring-security</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>springboot-spring-security</name>
    <description>Demo project for Spring Boot</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.4.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <mybatis.spring.version>1.3.2</mybatis.spring.version>
        <swagger.version>2.8.0</swagger.version>
        <jwt.version>0.9.1</jwt.version>
        <fastjson.version>1.2.48</fastjson.version>
    </properties>

    <dependencies>
           <!-- spring boot -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- swagger -->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>${swagger.version}</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>${swagger.version}</version>
        </dependency>
        <!-- spring security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- jwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>${jwt.version}</version>
        </dependency>
        <!-- fastjson -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>${fastjson.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

啟動類

啟動類沒什麼,主要開啟以下包掃描。

SpringSecurityApplication.java

package com.louis.springboot.spring.security;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

/**
 * 啟動器
 * @author Louis
 * @date Nov 28, 2018
 */
@SpringBootApplication
@ComponentScan(basePackages = "com.louis.springboot")
public class SpringSecurityApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringSecurityApplication.class, args);
    }
}

跨域配置類

跨域配置類,不多說,都懂得。

CorsConfig.java

package com.louis.springboot.spring.security.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 跨域配置
 * @author Louis
 * @date Nov 28, 2018
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")    // 允許跨域訪問的路徑
        .allowedOrigins("*")    // 允許跨域訪問的源
        .allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")    // 允許請求方法
        .maxAge(168000)    // 預檢間隔時間
        .allowedHeaders("*")  // 允許頭部設定
        .allowCredentials(true);    // 是否傳送cookie
    }
}

Swagger配置類

Swagger配置類,除了常規配置外,加了一個令牌屬性,可以在介面呼叫的時候傳遞令牌。

SwaggerConfig.java

package com.louis.springboot.spring.security.config;
import java.util.ArrayList;
import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

/**
 * Swagger配置
 * @author Louis
 * @date Nov 28, 2018
 */
@Configuration
@EnableSwagger2
public class SwaggerConfig {

    @Bean
    public Docket createRestApi(){
        // 新增請求引數,我們這裡把token作為請求頭部引數傳入後端
        ParameterBuilder parameterBuilder = new ParameterBuilder();
        List<Parameter> parameters = new ArrayList<Parameter>();
        parameterBuilder.name("Authorization").description("令牌").modelRef(new ModelRef("string")).parameterType("header")
                .required(false).build();
        parameters.add(parameterBuilder.build());
        return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select().apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any()).build().globalOperationParameters(parameters);
    }

    private ApiInfo apiInfo(){
        return new ApiInfoBuilder().build();
    }

}

加了令牌屬性後的 Swagger 介面呼叫介面。

安全配置類

下面這個配置類是Spring Security的關鍵配置。

在這個配置類中,我們主要做了以下幾個配置:

1. 訪問路徑URL的授權策略,如登入、Swagger訪問免登入認證等

2. 指定了登入認證流程過濾器 JwtLoginFilter,由它來觸發登入認證

3. 指定了自定義身份認證元件 JwtAuthenticationProvider,並注入 UserDetailsService

4. 指定了訪問控制過濾器 JwtAuthenticationFilter,在授權時解析令牌和設定登入狀態

5. 指定了退出登入處理器,因為是前後端分離,防止內建的登入處理器在後臺進行跳轉

WebSecurityConfig.java

package com.louis.springboot.spring.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;

import com.louis.springboot.spring.security.security.JwtAuthenticationFilter;
import com.louis.springboot.spring.security.security.JwtAuthenticationProvider;
import com.louis.springboot.spring.security.security.JwtLoginFilter;

/**
 * Security Config
 * @author Louis
 * @date Nov 28, 2018
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;
    
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用自定義登入身份認證元件
        auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 禁用 csrf, 由於使用的是JWT,我們這裡不需要csrf
        http.cors().and().csrf().disable()
            .authorizeRequests()
            // 跨域預檢請求
            .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
            // 登入URL
            .antMatchers("/login").permitAll()
            // swagger
            .antMatchers("/swagger-ui.html").permitAll()
            .antMatchers("/swagger-resources").permitAll()
            .antMatchers("/v2/api-docs").permitAll()
            .antMatchers("/webjars/springfox-swagger-ui/**").permitAll()
            // 其他所有請求需要身份認證
            .anyRequest().authenticated();
        // 退出登入處理器
        http.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());
        // 開啟登入認證流程過濾器
        http.addFilterBefore(new JwtLoginFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
        // 訪問控制時登入狀態檢查過濾器
        http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
    
}

登入認證觸發過濾器

JwtLoginFilter 是在通過訪問 /login 的POST請求是被首先被觸發的過濾器,預設實現是 UsernamePasswordAuthenticationFilter,它繼承了 AbstractAuthenticationProcessingFilter,抽象父類的 doFilter 定義了登入認證的大致操作流程,這裡我們的 JwtLoginFilter 繼承了 UsernamePasswordAuthenticationFilter,並進行了兩個主要內容的定製。

1. 覆寫認證方法,修改使用者名稱、密碼的獲取方式,具體原因看程式碼註釋

2. 覆寫認證成功後的操作,移除後臺跳轉,新增生成令牌並返回給客戶端

JwtLoginFilter.java

package com.louis.springboot.spring.security.security;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.louis.springboot.spring.security.utils.HttpUtils;
import com.louis.springboot.spring.security.utils.JwtTokenUtils;

/**
 * 啟動登入認證流程過濾器
 * @author Louis
 * @date Nov 28, 2018
 */
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
    
    public JwtLoginFilter(AuthenticationManager authManager) {
        setAuthenticationManager(authManager);
    }
    
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        // POST 請求 /login 登入時攔截, 由此方法觸發執行登入認證流程,可以在此覆寫整個登入認證邏輯
        super.doFilter(req, res, chain); 
    }
    
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 可以在此覆寫嘗試進行登入認證的邏輯,登入成功之後等操作不再此方法內
        // 如果使用此過濾器來觸發登入認證流程,注意登入請求資料格式的問題
        // 此過濾器的使用者名稱密碼預設從request.getParameter()獲取,但是這種
        // 讀取方式不能讀取到如 application/json 等 post 請求資料,需要把
        // 使用者名稱密碼的讀取邏輯修改為到流中讀取request.getInputStream()

        String body = getBody(request);
        JSONObject jsonObject = JSON.parseObject(body);
        String username = jsonObject.getString("username");
        String password = jsonObject.getString("password");

        if (username == null) {
            username = "";
        }

        if (password == null) {
            password = "";
        }

        username = username.trim();

        JwtAuthenticatioToken authRequest = new JwtAuthenticatioToken(username, password);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    
    }
    
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            Authentication authResult) throws IOException, ServletException {
        // 儲存登入認證資訊到上下文
        SecurityContextHolder.getContext().setAuthentication(authResult);
        // 記住我服務
        getRememberMeServices().loginSuccess(request, response, authResult);
        // 觸發事件監聽器
        if (this.eventPublisher != null) {
            eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }
        // 生成並返回token給客戶端,後續訪問攜帶此token
        JwtAuthenticatioToken token = new JwtAuthenticatioToken(null, null, JwtTokenUtils.generateToken(authResult));
        HttpUtils.write(response, token);
    }
    
    /** 
     * 獲取請求Body
     * @param request
     * @return