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
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