一、背景
最近在進行項目從jdk8
和spring boot 2.7.x
版本技術架構向jdk17
和spring boot 3.3.x
版本的代碼遷移,在遷移過程中,發現spring boot 3.3.x
版本依賴的spring security
版本已經升級6.x
版本了,語法上和spring security 5.x
版本有很多地方不兼容,因此記錄試一下spring boot 3.3.x
版本下,spring security 6.x
的集成方案。
二、技術實現
1. 創建spring boot 3.3.x版本項目
spring boot 3.3.x
版本對jdk
版本要求較高,我這里使用的是jdk17
,不久前,jdk21
也已經發布了,可以支持虛擬線程,大家也可以使用jdk21
。
設置好jdk
版本以后,新建項目,導入項目需要的相關依賴:
<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>3.3.1</version></parent><groupId>com.j.ss</groupId><artifactId>spring-secrity6-spring-boot3-demo</artifactId><version>1.0-SNAPSHOT</version><packaging>jar</packaging><name>spring-secrity6-spring-boot3-demo</name><url>http://maven.apache.org</url><properties><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency></dependencies>
</project>
2. 創建兩個測試接口
-
創建兩個接口用于測試,源碼參考如下
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController;@RestController public class SecurityController {@GetMapping("/hello")public String hello() {return "hello, spring security.";}@PostMapping("/work")public String work() {return "I am working.";}}
-
啟動項目,測試一下接口是否正常
-
hello接口
-
work接口
-
3. 引入spring-boot-starter-security依賴
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId>
</dependency>
引入spring-boot-starter-security
依賴以后,此時訪問接口,會有未授權問題。
4. 定義UserDetailsManager實現類
spring security
框架會自動使用UserDetailsManager
的loadUserByUsername
方法進行用戶加載,在加載用戶以后,會在UsernamePasswordAuthenticationFilter
過濾器中的attemptAuthentication
方法中,進行前端輸入的用戶信息和加載的用戶信息進行信息對比。
import lombok.extern.java.Log;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.stereotype.Component;import java.util.ArrayList;
import java.util.List;@Component
@Log
public class MyUserDetailsManager implements UserDetailsManager {@Overridepublic void createUser(UserDetails user) {}@Overridepublic void updateUser(UserDetails user) {}@Overridepublic void deleteUser(String username) {}@Overridepublic void changePassword(String oldPassword, String newPassword) {}@Overridepublic boolean userExists(String username) {return false;}@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {/*** 這里為了演示方便,模擬從數據庫查詢,直接設置一下權限*/log.info("query user from db!");return queryFromDB(username);}private static UserDetails queryFromDB(String username) {GrantedAuthority authority = new SimpleGrantedAuthority("testRole");List<GrantedAuthority> list = new ArrayList<>();list.add(authority);return new User("jack", // 用戶名稱new BCryptPasswordEncoder().encode("123456"), //密碼list //權限列表);}
}
5. 定義權限不足處理邏輯
用戶在訪問沒有權限的接口時,會拋出異常,spring security
允許我們自己這里這種異常,我這里就是模擬一下權限不足的提示信息,不做過多處理。
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;import java.io.IOException;
import java.io.PrintWriter;@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {@Overridepublic void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {//登陸狀態下,權限不足執行該方法response.setStatus(200);response.setCharacterEncoding("UTF-8");response.setContentType("application/json; charset=utf-8");PrintWriter printWriter = response.getWriter();String body = "403,權限不足!";printWriter.write(body);printWriter.flush();}
}
6. 定義未登錄情況處理邏輯
當用戶沒有登錄情況下,訪問需要權限的接口時,會拋出異常,spring security
允許我們自定義處理邏輯,這里未登錄就直接拋出401
,提示用戶登錄。
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;import java.io.IOException;
import java.io.PrintWriter;
import java.io.Serializable;@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {//驗證為未登陸狀態會進入此方法,認證錯誤response.setStatus(401);response.setCharacterEncoding("UTF-8");response.setContentType("application/json; charset=utf-8");PrintWriter printWriter = response.getWriter();String body = "401, 請先進行登錄!";printWriter.write(body);printWriter.flush();}
}
7. 定義自定義動態權限檢驗處理邏輯
在請求接口進行安全訪問的時候,我們可以指定訪問接口需要的角色,但是實際應用中,為了滿足系統的靈活性,我們往往需要自定義動態權限的校驗邏輯。
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.stereotype.Component;import java.util.Collection;
import java.util.function.Supplier;@Component
public class MyAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {/*** @param authentication the {@link Supplier} of the {@link Authentication} to check* @param object the {@link T} object to check* @return*/@Overridepublic AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {// 獲取訪問urlString requestURI = object.getRequest().getRequestURI();// 模擬從數據庫或者緩存里面查詢擁有當前URI的權限的角色String[] allRole = query(requestURI);// 獲取當前用戶權限Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities();// 判斷是否擁有權限for (String role : allRole) {for (GrantedAuthority r : authorities) {if (role.equals(r.getAuthority())) {return new AuthorizationDecision(true); // 返回有權限}}}return new AuthorizationDecision(false); //返回沒有權限}/*** 查詢當前擁有對應url的權限的角色** @param requestURI* @return*/private String[] query(String requestURI) {return new String[]{"testRole"};}
}
8. 定義安全訪問統一入口
在統一入口,我們可以做一些統一的邏輯,比如前后端分離的情況下,進行token
內容的解析,這里我只是用代碼模擬演示一下,方便大家理解。
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.java.Log;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;@Component
@Log
public class MyAuthenticationFilter extends OncePerRequestFilter {@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {String token = request.getHeader("token"); // 前后端分離的時候獲取tokenif (StringUtils.hasText(token)) { // 如果token不為空,則需要解析出用戶信息,填充到當前上下文中UsernamePasswordAuthenticationToken authentication = getUserFromToken(token);SecurityContextHolder.getContext().setAuthentication(authentication);if (log.isLoggable(Level.INFO)) {log.info("set authentication");}} else {if (log.isLoggable(Level.INFO)) {log.info("user info is null.");}}filterChain.doFilter(request, response);}private UsernamePasswordAuthenticationToken getUserFromToken(String token) {GrantedAuthority authority = new SimpleGrantedAuthority(token);List<GrantedAuthority> list = new ArrayList<>();list.add(authority);User user = new User("jack", // 用戶名稱new BCryptPasswordEncoder().encode("123456"), //密碼list //權限列表);UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());usernamePasswordAuthenticationToken.setDetails(user);return usernamePasswordAuthenticationToken;}
}
9. 編寫spring security配置類
當所有準備工作,做好以后,下面就是編寫spring security
的配置類了,使我們的相關配置生效。
import com.j.ss.MyAccessDeniedHandler;
import com.j.ss.MyAuthenticationEntryPoint;
import com.j.ss.MyAuthenticationFilter;
import com.j.ss.MyAuthorizationManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;/*** @Configuration 注解表示將該類以配置類的方式注冊到spring容器中*/
@Configuration
/*** @EnableWebSecurity 注解表示啟動spring security*/
@EnableWebSecurity
/*** @EnableMethodSecurity 注解表示啟動全局函數權限*/
@EnableMethodSecurity
public class WebSecurityConfig {/*** 權限不足處理邏輯*/@Autowiredprivate MyAccessDeniedHandler accessDeniedHandler;/*** 未授權處理邏輯*/@Autowiredprivate MyAuthenticationEntryPoint authenticationEntryPoint;/*** 訪問統一處理器*/@Autowiredprivate MyAuthenticationFilter authenticationTokenFilter;/*** 自定義權限校驗邏輯*/@Autowiredprivate MyAuthorizationManager myAuthorizationManager;/*** spring security的核心過濾器鏈** @param httpSecurity* @return*/@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {// 定義安全請求攔截規則httpSecurity.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> {authorizationManagerRequestMatcherRegistry.requestMatchers("/hello").permitAll() // hello 接口放行,不進行權限校驗.anyRequest()// .hasRole() 其他接口不進行role具體校驗,進行動態權限校驗.access(myAuthorizationManager); // 動態權限校驗邏輯})// 前后端分離,關閉csrf.csrf(AbstractHttpConfigurer::disable)// 前后端分離架構禁用session.sessionManagement(httpSecuritySessionManagementConfigurer -> {httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS);})// 訪問異常處理.exceptionHandling(httpSecurityExceptionHandlingConfigurer -> {httpSecurityExceptionHandlingConfigurer.accessDeniedHandler(accessDeniedHandler);})// 未授權異常處理.exceptionHandling(httpSecurityExceptionHandlingConfigurer -> {httpSecurityExceptionHandlingConfigurer.authenticationEntryPoint(authenticationEntryPoint);}).headers(httpSecurityHeadersConfigurer -> {// 禁用緩存httpSecurityHeadersConfigurer.cacheControl(HeadersConfigurer.CacheControlConfig::disable);httpSecurityHeadersConfigurer.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable);});// 添加入口filter, 前后端分離的時候,可以進行token解析操作httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);return httpSecurity.build();}/*** 明文密碼加密** @return*/@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}/*** 忽略權限校驗** @return*/@Beanpublic WebSecurityCustomizer webSecurityCustomizer() {return (web -> web.ignoring().requestMatchers("/hello"));}}
三、 功能測試
上述代碼編寫完成以后,啟動項目,下面進行功能測試。
1. 忽略權限校驗測試
訪問/hello
接口
可以看到,此時接口在無登錄信息的情況下,也可以正常訪問的。
2. 無權限測試
同樣的,我們直接訪問/work
接口
可以看到,此時提醒我們需要登錄了。
3. 有權限測試
再次訪問/work
接口,模擬已經登錄,并擁有對應的權限。
可以看到,我們模擬有testRole
權限,此時訪問是正常的。
4. 權限不足測試
再次訪問/work
接口,模擬已經登錄,但擁有錯誤的權限。
可以看到,此時報出了權限不足的異常。
四、寫在最后
上面的案例只是演示,spring security
的實際應用,應該根據具體項目權限要求來進行合理實現。