Spring Security 是 Spring 社區的一個頂級項目,也是 Spring Boot 官方推薦使用的安全框架。除了常規的認證(Authentication)和受權(Authorization)以外,Spring Security還提供了諸如ACLs,LDAP,JAAS,CAS等高級特性以知足複雜場景下的安全需求。另外,就目前而言,Spring Security和Shiro也是當前廣大應用使用比較普遍的兩個安全框架。html
Spring Security 應用級別的安全主要包含兩個主要部分,即登陸認證(Authentication)和訪問受權(Authorization),首先用戶登陸的時候傳入登陸信息,登陸驗證器完成登陸認證並將登陸認證好的信息存儲到請求上下文,而後再進行其餘操做,如在進行接口訪問、方法調用時,權限認證器從上下文中獲取登陸認證信息,而後根據認證信息獲取權限信息,經過權限信息和特定的受權策略決定是否受權。java
本教程將首先給出一個完整的案例實現,而後再分別對登陸認證和訪問受權的執行流程進行剖析,但願你們能夠經過實現案例和流程分析,充分理解Spring Security的登陸認證和訪問受權的執行原理,而且可以在理解原理的基礎上熟練自主的使用Spring Security實現相關的需求。web
接下來,咱們就經過一個具體的案例,來說解如何進行Spring Security的整合,而後藉助Spring Security實現登陸認證和訪問控制。spring
爲方便咱們初始化項目,Spring Boot給咱們提供一個項目模板生成網站。sql
1. 打開瀏覽器,訪問:https://start.spring.io/express
2. 根據頁面提示,選擇構建工具,開發語言,項目信息等。apache
3. 點擊 Generate the project,生成項目模板,生成以後會將壓縮包下載到本地。json
4. 使用IDE導入項目,我這裏使用Eclipse,經過導入Maven項目的方式導入。後端
清理掉不須要的測試類及測試依賴,添加 Maven 相關依賴,這裏須要添加上web、swagger、spring security、jwt和fastjson的依賴,Swagge和fastjson的添加是爲了方便接口測試。api
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> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.5.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.louis.springboot</groupId> <artifactId>demo</artifactId> <version>0.0.1-SNAPSHOT</version> <name>demo</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <!-- web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- swagger --> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger2</artifactId> <version>2.9.2</version> </dependency> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger-ui</artifactId> <version>2.9.2</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>0.9.1</version> </dependency> <!-- fastjson --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.58</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> <!-- 打包時拷貝MyBatis的映射文件 --> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/sqlmap/*.xml</include> </includes> <filtering>false</filtering> </resource> <resource> <directory>src/main/resources</directory> <includes> <include>**/*.*</include> </includes> <filtering>true</filtering> </resource> </resources> </build> </project>
1.添加swagger 配置
添加一個swagger 配置類,在工程下新建 config 包並添加一個 SwaggerConfig 配置類,除了常規配置外,加了一個令牌屬性,能夠在接口調用的時候傳遞令牌。
SwaggerConfig.java
package com.louis.springboot.demo.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; @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() .title("SpringBoot API Doc") .description("This is a restful api document of Spring Boot.") .version("1.0") .build(); } }
加了令牌屬性後的 Swagger 接口調用界面,會多出一個令牌參數,在發起請求的時候一塊兒發送令牌。
2.添加跨域 配置
添加一個CORS跨域配置類,在工程下新建 config 包並添加一個 CorsConfig配置類。
CorsConfig.java
package com.louis.springboot.demo.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @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 } }
下面這個配置類是Spring Security的關鍵配置。
在這個配置類中,咱們主要作了如下幾個配置:
1. 訪問路徑URL的受權策略,如登陸、Swagger訪問免登陸認證等
2. 指定了登陸認證流程過濾器 JwtLoginFilter,由它來觸發登陸認證
3. 指定了自定義身份認證組件 JwtAuthenticationProvider,並注入 UserDetailsService
4. 指定了訪問控制過濾器 JwtAuthenticationFilter,在受權時解析令牌和設置登陸狀態
5. 指定了退出登陸處理器,由於是先後端分離,防止內置的登陸處理器在後臺進行跳轉
WebSecurityConfig.java
package com.louis.springboot.demo.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.demo.security.JwtAuthenticationFilter; import com.louis.springboot.demo.security.JwtAuthenticationProvider; import com.louis.springboot.demo.security.JwtLoginFilter; @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**/**").permitAll() .antMatchers("/webjars/**").permitAll() .antMatchers("/v2/**").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.demo.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.demo.utils.HttpUtils; import com.louis.springboot.demo.utils.JwtTokenUtils; /** * 啓動登陸認證流程過濾器 * @author Louis * @date Jun 29, 2019 */ 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.demo.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.demo.security.JwtAuthenticatioToken; import com.louis.springboot.demo.utils.SecurityUtils; import com.louis.springboot.demo.vo.HttpResult; import com.louis.springboot.demo.vo.LoginBean; /** * 登陸控制器 * @author Louis * @date Jun 29, 2019 */ @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的login方法。
SecurityUtils.java
package com.louis.springboot.demo.utils; import javax.servlet.http.HttpServletRequest; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import com.louis.springboot.demo.security.JwtAuthenticatioToken; /** * Security相關操做 * @author Louis * @date Jun 29, 2019 */ 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; } /** * 獲取令牌進行認證 * @param request */ public static void checkAuthentication(HttpServletRequest request) { // 獲取令牌並根據令牌獲取登陸認證信息 Authentication authentication = JwtTokenUtils.getAuthenticationeFromToken(request); // 設置登陸認證信息到上下文 SecurityContextHolder.getContext().setAuthentication(authentication); } /** * 獲取當前用戶名 * @return */ public static String getUsername() { String username = null; Authentication authentication = getAuthentication(); if(authentication != null) { Object principal = authentication.getPrincipal(); if(principal != null && principal instanceof UserDetails) { username = ((UserDetails) principal).getUsername(); } } return username; } /** * 獲取用戶名 * @return */ public static String getUsername(Authentication authentication) { String username = null; if(authentication != null) { Object principal = authentication.getPrincipal(); if(principal != null && principal instanceof UserDetails) { username = ((UserDetails) principal).getUsername(); } } return username; } /** * 獲取當前登陸信息 * @return */ public static Authentication getAuthentication() { if(SecurityContextHolder.getContext() == null) { return null; } Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return authentication; } }
咱們令牌是使用JWT生成的,令牌生成的邏輯,參見源碼JwtTokenUtils的generateToken相關方法。
JwtTokenUtils.java
package com.louis.springboot.demo.utils; import java.io.Serializable; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletRequest; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import com.louis.springboot.demo.security.GrantedAuthorityImpl; import com.louis.springboot.demo.security.JwtAuthenticatioToken; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; /** * JWT工具類 * @author Louis * @date Jun 29, 2019 */ public class JwtTokenUtils implements Serializable { private static final long serialVersionUID = 1L; /** * 用戶名稱 */ private static final String USERNAME = Claims.SUBJECT; /** * 建立時間 */ private static final String CREATED = "created"; /** * 權限列表 */ private static final String AUTHORITIES = "authorities"; /** * 密鑰 */ private static final String SECRET = "abcdefgh"; /** * 有效期12小時 */ private static final long EXPIRE_TIME = 12 * 60 * 60 * 1000; /** * 生成令牌 * * @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(); } /** * 從令牌中獲取用戶名 * * @param token 令牌 * @return 用戶名 */ public static String getUsernameFromToken(String token) { String username; try { Claims claims = getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; } return username; } /** * 根據請求令牌獲取登陸認證信息 * @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; } /** * 從令牌中獲取數據聲明 * * @param token 令牌 * @return 數據聲明 */ private static Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } catch (Exception e) { claims = null; } return claims; } /** * 驗證令牌 * @param token * @param username * @return */ public static Boolean validateToken(String token, String username) { String userName = getUsernameFromToken(token); return (userName.equals(username) && !isTokenExpired(token)); } /** * 刷新令牌 * @param token * @return */ public static String refreshToken(String token) { String refreshedToken; try { Claims claims = getClaimsFromToken(token); claims.put(CREATED, new Date()); refreshedToken = generateToken(claims); } catch (Exception e) { refreshedToken = null; } return refreshedToken; } /** * 判斷令牌是否過時 * * @param token 令牌 * @return 是否過時 */ public static Boolean isTokenExpired(String token) { try { Claims claims = getClaimsFromToken(token); Date expiration = claims.getExpiration(); return expiration.before(new Date()); } catch (Exception e) { return false; } } /** * 獲取請求token * @param request * @return */ public static String getToken(HttpServletRequest request) { String token = request.getHeader("Authorization"); String tokenHead = "Bearer "; if(token == null) { token = request.getHeader("token"); } else if(token.contains(tokenHead)){ token = token.substring(tokenHead.length()); } if("".equals(token)) { token = null; } return token; } }
上面說到登陸認證是經過調用 AuthenticationManager 的 authenticate(token) 方法實現的,而 AuthenticationManager 又是經過調用 AuthenticationProvider 的 authenticate(Authentication authentication) 來完成認證的,因此經過定製 AuthenticationProvider 也能夠完成各類自定義的需求,咱們這裏只是簡單的繼承 DaoAuthenticationProvider 展現如何自定義,具體的你們能夠根據各自的需求按需定製。
JwtAuthenticationProvider.java
package com.louis.springboot.demo.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 Jun 29, 2019 */ 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.demo.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.demo.model.User; import com.louis.springboot.demo.service.UserService; /** * 用戶登陸認證信息查詢 * @author Louis * @date Jun 29, 2019 */ @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 中這樣配置。
@Override public void configure(AuthenticationManagerBuilder auth) throws Exception { // 指定自定義的獲取信息獲取服務 auth.userDetailsService(userDetailsService) }
上面 UserDetailsService 加載好用戶認證信息後會封裝認證信息到一個 UserDetails 的實現類。
默認實現是 User 類,咱們這裏沒有特殊須要,簡單繼承便可,複雜需求能夠在此基礎上進行拓展。
JwtUserDetails.java
package com.louis.springboot.demo.security; import java.util.Collection; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.User; /** * 安全用戶模型 * @author Louis * @date Jun 29, 2019 */ 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.demo.model; /** * 用戶模型 * @author Louis * @date Jun 29, 2019 */ public class User { private Long id; private String username; private String password; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } }
用戶服務接口,只提供簡單的用戶查詢和權限查詢接口用於模擬。
UserService.java
package com.louis.springboot.demo.service; import java.util.Set; import com.louis.springboot.demo.model.User; /** * 用戶管理 * @author Louis * @date Jun 29, 2019 */ public interface UserService { /** * 根據用戶名查找用戶 * @param username * @return */ User findByUsername(String username); /** * 查找用戶的菜單權限標識集合 * @param userName * @return */ Set<String> findPermissions(String username); }
用戶服務實現,只簡單獲取返回模擬數據,實際場景根據狀況從DAO獲取便可。
SysUserServiceImpl.java
package com.louis.springboot.demo.service.impl; import java.util.HashSet; import java.util.Set; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import com.louis.springboot.demo.model.User; import com.louis.springboot.demo.service.UserService; @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"); permissions.add("sys:user:delete"); return permissions; } }
用戶控制器,提供三個測試接口,其中權限列表中未包含刪除接口定義的權限('sys:user:delete'),登陸以後也將無權限調用。
UserController.java
package com.louis.springboot.demo.controller; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.louis.springboot.demo.vo.HttpResult; /** * 用戶控制器 * @author Louis * @date Jun 29, 2019 */ @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
package com.louis.springboot.demo.security; import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import com.louis.springboot.demo.utils.SecurityUtils; /** * 登陸認證檢查過濾器 * @author Louis * @date Jun 29, 2019 */ 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); } }
具體詳細獲取token和檢查登陸狀態代碼請查看SecurityUtils的checkAuthentication方法。
1. 右鍵項目 -> Run as -> Maven install,開始執行Maven構建,第一次會下載Maven依賴,可能須要點時間,若是出現以下信息,就說明項目編譯打包成功了。
2. 右鍵文件 DemoApplication.java -> Run as -> Java Application,開始啓動應用,當出現以下信息的時候,就說明應用啓動成功了,默認啓動端口是8080。
3. 打開瀏覽器,訪問:http://localhost:8080/swagger-ui.html,進入swagger接口文檔界面。
4.咱們先再未登陸沒有令牌的時候直接訪問接口,發現都返回無權限,禁止訪問的結果。
發現接口調用失敗,返回狀態碼爲403的錯誤,表示由於權限的問題拒絕訪問。
打開 LoginController,輸入咱們用戶名和密碼(username:amdin, password:123,密碼是咱們在SysUserServiceImpl中設置的)
登陸成功以後,會成功返回令牌,以下圖所示。
拷貝返回的令牌,粘貼到令牌參數輸入框,再次訪問 /user/edit 接口。
這個時候,成功的返回告終果: the edit service is called success.
一樣的,拷貝返回的令牌,粘貼到令牌參數輸入框,訪問 /user/delete 接口。
發現仍是返回拒絕訪問的結果,那是由於訪問這個接口須要 'sys:user:delete' 權限,而咱們以前返回的權限列表中並無包含,因此受權訪問失敗。
咱們能夠修改一下 SysUserServiceImpl,添加上‘sys:user:delete’ 權限,從新登陸,再次訪問一遍。
發現刪除接口也能夠訪問了,記住務必要從新調用登陸接口,獲取令牌後拷貝到刪除接口,再次訪問刪除接口。
到此,一個簡單但相對完整的Spring Security案例就實現了,咱們經過Spring Security實現了簡單的登陸認證和訪問控制,讀者能夠在此基礎上拓展出更爲豐富的功能。
Spring Security的安全主要包含兩部份內容,即登陸認證和訪問受權,接下來,咱們別對這兩個部分的流程進行追蹤和分析,分析過程當中,讀者最好同時對比查看相應源碼,以更好的學習和了解相關的內容。
若是在繼承 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);
...
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); ... 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 { // 登陸成功以後,把認證後的 Authentication 對象存儲到請求線程上下文,這樣在受權階段就能夠獲取到此認證信息進行訪問控制判斷 SecurityContextHolder.getContext().setAuthentication(authResult); rememberMeServices.loginSuccess(request, response, authResult); 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 {
// 委託給AuthenticationProvider result = provider.authenticate(authentication); } } }
根據驗證方式的多樣化,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
public class LogoutFilter extends GenericFilterBean { public LogoutFilter(LogoutSuccessHandler logoutSuccessHandler, LogoutHandler... handlers) { this.handler = new CompositeLogoutHandler(handlers);this.logoutSuccessHandler = logoutSuccessHandler; setFilterProcessesUrl("/logout"); // 綁定 /logout } 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(); 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); ... } }
而 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; } }
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 Securiy的登陸認證和訪問受權兩部分的執行流程大體進行了追蹤和分析,但願讀者能夠親自跟隨源碼調試這個過程,通過反覆對比論證,進一步的加深對Spring Securiy總體流程的理解,從而提升自身在實際項目運用中的分析能力和解決能力。
官方網站:https://spring.io/projects/spring-security
W3C資料:https://www.w3cschool.cn/springsecurity/
參考手冊:https://springcloud.cc/spring-security-zhcn.html