一、背景
最近在进行项目从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 {
@Override
public void createUser(UserDetails user) {
}
@Override
public void updateUser(UserDetails user) {
}
@Override
public void deleteUser(String username) {
}
@Override
public void changePassword(String oldPassword, String newPassword) {
}
@Override
public boolean userExists(String username) {
return false;
}
@Override
public 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 {
@Override
public 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 {
@Override
public 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
*/
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
// 获取访问url
String 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 {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("token"); // 前后端分离的时候获取token
if (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 {
/**
* 权限不足处理逻辑
*/
@Autowired
private MyAccessDeniedHandler accessDeniedHandler;
/**
* 未授权处理逻辑
*/
@Autowired
private MyAuthenticationEntryPoint authenticationEntryPoint;
/**
* 访问统一处理器
*/
@Autowired
private MyAuthenticationFilter authenticationTokenFilter;
/**
* 自定义权限校验逻辑
*/
@Autowired
private MyAuthorizationManager myAuthorizationManager;
/**
* spring security的核心过滤器链
*
* @param httpSecurity
* @return
*/
@Bean
public 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
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 忽略权限校验
*
* @return
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web -> web.ignoring().requestMatchers("/hello"));
}
}
三、 功能测试
上述代码编写完成以后,启动项目,下面进行功能测试。
1. 忽略权限校验测试
访问/hello
接口
可以看到,此时接口在无登录信息的情况下,也可以正常访问的。
2. 无权限测试
同样的,我们直接访问/work
接口
可以看到,此时提醒我们需要登录了。
3. 有权限测试
再次访问/work
接口,模拟已经登录,并拥有对应的权限。
可以看到,我们模拟有testRole
权限,此时访问是正常的。
4. 权限不足测试
再次访问/work
接口,模拟已经登录,但拥有错误的权限。
可以看到,此时报出了权限不足的异常。
四、写在最后
上面的案例只是演示,spring security
的实际应用,应该根据具体项目权限要求来进行合理实现。