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


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
     */
    public String getBody(HttpServletRequest request) {
        StringBuilder sb = new StringBuilder();
        InputStream inputStream = null;
        BufferedReader reader = null;
        try {
            inputStream = request.getInputStream();
            reader = new BufferedReader(new InputStreamReader(inputStream, Charset.forName("UTF-8")));
            String line = "";
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return sb.toString();
    }
}
復制代碼

登錄控制器

除了使用上面的登錄認證過濾器攔截 /login Post請求之外,我們也可以不使用上面的過濾器,通過自定義登錄接口實現,只要在登錄接口手動觸發登錄流程並生產令牌即可。

其實 Spring Security 的登錄認證過程只需 調用 AuthenticationManager 的 authenticate(Authentication authentication) 方法,最終返回認證成功的 Authentication 實現類並存儲到SpringContexHolder 上下文即可,這樣后面授權的時候就可以從 SpringContexHolder 中獲取登錄認證信息,並根據其中的用戶信息和權限信息決定是否進行授權。

LoginController.java

復制代碼
package com.louis.springboot.spring.security.controller;

import java.io.IOException;

import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.louis.springboot.spring.security.security.JwtAuthenticatioToken;
import com.louis.springboot.spring.security.utils.SecurityUtils;
import com.louis.springboot.spring.security.vo.HttpResult;
import com.louis.springboot.spring.security.vo.LoginBean;

/**
 * 登錄控制器
 * @author Louis
 * @date Nov 28, 2018
 */
@RestController
public class LoginController {

    @Autowired
    private AuthenticationManager authenticationManager;

    /**
     * 登錄接口
     */
    @PostMapping(value = "/login")
    public HttpResult login(@RequestBody LoginBean loginBean, HttpServletRequest request) throws IOException {
        String username = loginBean.getUsername();
        String password = loginBean.getPassword();
        
        // 系統登錄認證
        JwtAuthenticatioToken token = SecurityUtils.login(request, username, password, authenticationManager);
                
        return HttpResult.ok(token);
    }

}
復制代碼

注意:如果使用此登錄控制器觸發登錄認證,需要禁用登錄認證過濾器,即將 WebSecurityConfig 中的以下配置項注釋即可,否則訪問登錄接口會被過濾攔截,執行不會再進入此登錄接口,大家根據使用習慣二選一即可。

// 開啟登錄認證流程過濾器,如果使用LoginController的login接口, 需要注釋掉此過濾器,根據使用習慣二選一即可
http.addFilterBefore(new JwtLoginFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);

如下是登錄認證的邏輯, 可以看到部分邏輯跟上面的登錄認證過濾器差不多。

1. 執行登錄認證過程,通過調用 AuthenticationManager 的 authenticate(token) 方法實現

2. 將認證成功的認證信息存儲到上下文,供后續訪問授權的時候獲取使用

3. 通過JWT生成令牌並返回給客戶端,后續訪問和操作都需要攜帶此令牌

SecurityUtils.java

復制代碼
/**
 * Security相關操作
 * @author Louis
 * @date Nov 28, 2018
 */
public class SecurityUtils {

    /**
     * 系統登錄認證
     * @param request
     * @param username
     * @param password
     * @param authenticationManager
     * @return
     */
    public static JwtAuthenticatioToken login(HttpServletRequest request, String username, String password, AuthenticationManager authenticationManager) {
        JwtAuthenticatioToken token = new JwtAuthenticatioToken(username, password);
        token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        // 執行登錄認證過程
        Authentication authentication = authenticationManager.authenticate(token);
        // 認證成功存儲認證信息到上下文
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 生成令牌並返回給客戶端
        token.setToken(JwtTokenUtils.generateToken(authentication));
        return token;
    }
  
  ...
}
復制代碼

令牌生成器

我們令牌是使用JWT生成的,下面是令牌生成的簡要邏輯,詳細參見源碼。

JwtTokenUtils.java

復制代碼
/**
 * JWT工具類
 * @author Louis
 * @date Nov 28, 2018
 */
public class JwtTokenUtils implements Serializable {

    .../**
     * 生成令牌
     *
     * @param userDetails 用戶
     * @return 令牌
     */
    public static String generateToken(Authentication authentication) {
        Map<String, Object> claims = new HashMap<>(3);
        claims.put(USERNAME, SecurityUtils.getUsername(authentication));
        claims.put(CREATED, new Date());
        claims.put(AUTHORITIES, authentication.getAuthorities());
        return generateToken(claims);
    }

    /**
     * 從數據聲明生成令牌
     *
     * @param claims 數據聲明
     * @return 令牌
     */
    private static String generateToken(Map<String, Object> claims) {
        Date expirationDate = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, SECRET).compact();
    }

  ...

}
復制代碼

登錄身份認證組件

上面說到登錄認證是通過調用 AuthenticationManager 的 authenticate(token) 方法實現的,而 AuthenticationManager 又是通過調用 AuthenticationProvider 的 authenticate(Authentication authentication) 來完成認證的,所以通過定制 AuthenticationProvider 也可以完成各種自定義的需求,我們這里只是簡單的繼承 DaoAuthenticationProvider 展示如何自定義,具體的大家可以根據各自的需求按需定制。

JwtAuthenticationProvider.java

復制代碼
package com.louis.springboot.spring.security.security;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

/**
 * 身份驗證提供者
 * @author Louis
 * @date Nov 20, 2018
 */
public class JwtAuthenticationProvider extends DaoAuthenticationProvider {

    public JwtAuthenticationProvider(UserDetailsService userDetailsService) {
        setUserDetailsService(userDetailsService);
        setPasswordEncoder(new BCryptPasswordEncoder());
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 可以在此處覆寫整個登錄認證邏輯
        return super.authenticate(authentication);
    }
    
    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        // 可以在此處覆寫密碼驗證邏輯
        super.additionalAuthenticationChecks(userDetails, authentication);
    }

}
復制代碼

認證信息獲取服務

通過跟蹤代碼運行,我們發現像默認使用的 DaoAuthenticationProvider,在認證的使用都是通過一個叫 UserDetailsService 的來獲取用戶認證所需信息的。

AbstractUserDetailsAuthenticationProvider 定義了在 authenticate 方法中通過 retrieveUser 方法獲取用戶信息,子類 DaoAuthenticationProvider 通過 UserDetailsService 來進行獲取, 一般情況,這個 UserDetailsService 需要我們自定義,實現從用戶服務獲取用戶和權限信息封裝到 UserDetails 的實現類。

AbstractUserDetailsAuthenticationProvider.java

復制代碼
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        
     ...
if (user == null) { cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } ...return createSuccessAuthentication(principalToReturn, authentication, user); }
復制代碼

DaoAuthenticationProvider.java

復制代碼
    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); return loadedUser; } ... }
復制代碼

我們自定義的 UserDetailsService,從我們的用戶服務 UserService 中獲取用戶和權限信息。

UserDetailsServiceImpl.java

復制代碼
package com.louis.springboot.spring.security.security;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.louis.springboot.spring.security.model.User;
import com.louis.springboot.spring.security.service.UserService;

/**
 * 用戶登錄認證信息查詢
 * @author Louis
 * @date Nov 20, 2018
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("該用戶不存在");
        }
        // 用戶權限列表,根據用戶擁有的權限標識與如 @PreAuthorize("hasAuthority('sys:menu:view')") 標注的接口對比,決定是否可以調用接口
        Set<String> permissions = userService.findPermissions(username);
        List<GrantedAuthority> grantedAuthorities = permissions.stream().map(GrantedAuthorityImpl::new).collect(Collectors.toList());
        return new JwtUserDetails(username, user.getPassword(), grantedAuthorities);
    }
}
復制代碼

一般而言,定制 UserDetailsService 就可以滿足大部分需求了,在 UserDetailsService 滿足不了我們的需求的時候考慮定制 AuthenticationProvider。

如果直接定制UserDetailsService ,而不自定義 AuthenticationProvider,可以直接在配置文件 WebSecurityConfig 中這樣配置。

WebSecurityConfig.java

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 指定自定義的獲取信息獲取服務
        auth.userDetailsService(userDetailsService)
    }

用戶認證信息

上面 UserDetailsService 加載好用戶認證信息后會封裝認證信息到一個 UserDetails 的實現類。

默認實現是 User 類,我們這里沒有特殊需要,簡單繼承即可,復雜需求可以在此基礎上進行拓展。

JwtUserDetails.java

復制代碼
package com.louis.springboot.spring.security.security;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

/**
 * 安全用戶模型
 * @author Louis
 * @date Nov 28, 2018
 */
public class JwtUserDetails extends User {

    private static final long serialVersionUID = 1L;

    public JwtUserDetails(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        this(username, password, true, true, true, true, authorities);
    }
    
    public JwtUserDetails(String username, String password, boolean enabled, boolean accountNonExpired,
            boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
    }

}
復制代碼

用戶操作代碼

簡單的用戶模型,包含用戶名密碼。

User.java

復制代碼
package com.louis.springboot.spring.security.model;

/**
 * 用戶模型
 * @author Louis
 * @date Nov 28, 2018
 */
public class User {

    private Long id;
    
    private String username;

    private String password;

    ...

}
復制代碼

用戶服務接口,只提供簡單的用戶查詢和權限查詢接口用於模擬。

UserService.java

復制代碼
/**
 * 用戶管理
 * @author Louis
 * @date Nov 28, 2018
 */
public interface UserService {

    /**
     * 根據用戶名查找用戶
     * @param username
     * @return
     */
    User findByUsername(String username);

    /**
     * 查找用戶的菜單權限標識集合
     * @param userName
     * @return
     */
    Set<String> findPermissions(String username);

}
復制代碼

用戶服務實現,只簡單獲取返回模擬數據,實際場景根據情況從DAO獲取即可。

SysUserServiceImpl.java

復制代碼
@Service
public class SysUserServiceImpl implements UserService {

    @Override
    public User findByUsername(String username) {
        User user = new User();
        user.setId(1L);
        user.setUsername(username);
        String password = new BCryptPasswordEncoder().encode("123");
        user.setPassword(password);
        return user;
    }

    @Override
    public Set<String> findPermissions(String username) {
        Set<String> permissions = new HashSet<>();
        permissions.add("sys:user:view");
        permissions.add("sys:user:add");
        permissions.add("sys:user:edit");
        return permissions;
    }

}
復制代碼

用戶控制器,提供三個測試接口,其中權限列表中未包含刪除接口定義的權限('sys:user:delete'),登錄之后也將無權限調用。

UserController.java

復制代碼
/**
 * 用戶控制器
 * @author Louis 
 * @date Oct 31, 2018
 */
@RestController
@RequestMapping("user")
public class UserController {

    
    @PreAuthorize("hasAuthority('sys:user:view')")
    @GetMapping(value="/findAll")
    public HttpResult findAll() {
        return HttpResult.ok("the findAll service is called success.");
    }
    
    @PreAuthorize("hasAuthority('sys:user:edit')")
    @GetMapping(value="/edit")
    public HttpResult edit() {
        return HttpResult.ok("the edit service is called success.");
    }
    
    @PreAuthorize("hasAuthority('sys:user:delete')")
    @GetMapping(value="/delete")
    public HttpResult delete() {
        return HttpResult.ok("the delete service is called success.");
    }

}
復制代碼

登錄認證檢查過濾器

訪問接口的時候,登錄認證檢查過濾器 JwtAuthenticationFilter 會攔截請求校驗令牌和登錄狀態,並根據情況設置登錄狀態。

JwtAuthenticationFilter.java

復制代碼
/**
 * 登錄認證檢查過濾器
 * @author Louis
 * @date Nov 20, 2018
 */
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
    
    @Autowired
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 獲取token, 並檢查登錄狀態
        SecurityUtils.checkAuthentication(request);
        chain.doFilter(request, response);
    }
    
}
復制代碼

SecurityUtils.java

復制代碼
    /**
     * 獲取令牌進行認證
     * @param request
     */
    public static void checkAuthentication(HttpServletRequest request) {
        // 獲取令牌並根據令牌獲取登錄認證信息
        Authentication authentication = JwtTokenUtils.getAuthenticationeFromToken(request);
        // 設置登錄認證信息到上下文
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
復制代碼

JwtTokenUtils.java

復制代碼
    /**
     * 根據請求令牌獲取登錄認證信息
     * @param token 令牌
     * @return 用戶名
     */
    public static Authentication getAuthenticationeFromToken(HttpServletRequest request) {
        Authentication authentication = null;
        // 獲取請求攜帶的令牌
        String token = JwtTokenUtils.getToken(request);
        if(token != null) {
            // 請求令牌不能為空
            if(SecurityUtils.getAuthentication() == null) {
                // 上下文中Authentication為空
                Claims claims = getClaimsFromToken(token);
                if(claims == null) {
                    return null;
                }
                String username = claims.getSubject();
                if(username == null) {
                    return null;
                }
                if(isTokenExpired(token)) {
                    return null;
                }
                Object authors = claims.get(AUTHORITIES);
                List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
                if (authors != null && authors instanceof List) {
                    for (Object object : (List) authors) {
                        authorities.add(new GrantedAuthorityImpl((String) ((Map) object).get("authority")));
                    }
                }
                authentication = new JwtAuthenticatioToken(username, null, authorities, token);
            } else {
                if(validateToken(token, SecurityUtils.getUsername())) {
                    // 如果上下文中Authentication非空,且請求令牌合法,直接返回當前登錄認證信息
                    authentication = SecurityUtils.getAuthentication();
                }
            }
        }
        return authentication;
    }
復制代碼

接口測試

找到 SpringSecurityApplication, 啟動程序, 訪問 http://localhost:8080/swagger-ui.html,進入Swagger。

 

我們先再未登錄沒有令牌的時候直接訪問接口,發現都返回無權限,禁止訪問的結果。

返回拒絕訪問結果。

 打開 LoginController,輸入我們用戶名和密碼(username:amdin, password:123)

 登錄成功之后,成功返回令牌,如下圖所示。

 

拷貝返回的令牌,粘貼到令牌參數輸入框,再次訪問 /user/edit 接口。

這個時候,成功的返回了結果: the edit service is called success.

同樣的,拷貝返回的令牌,粘貼到令牌參數輸入框,訪問 /user/delete 接口。

發現還是返回拒絕訪問的結果,那是因為訪問這個接口需要 'sys:user:delete' 權限,而我們之前返回的權限列表中並沒有包含,所以授權訪問失敗。

我們修改一下 SysUserServiceImpl,添加上‘sys:user:delete’ 權限,重新登錄,再次訪問一遍。

發現刪除接口也可以訪問了,記住務必要重新調用登錄接口,獲取令牌后拷貝到刪除接口,再次訪問刪除接口。

到此,Spring Security 的講解就結束了,本人知識有限,有不正確的地方,煩請指正,不盡感激。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM