Spring Security6 最新版配置该怎么写,该如何实现动态权限管理
Spring Security 在最近几个版本中配置的写法都有一些变化,很多常见的方法都废弃了,并且将在未来的 Spring Security7 中移除,因此又补充了一些新的内容,重新发一下,供各位使用 Spring Security 的小伙伴们参考。
接下来,我把从 Spring Security5.7 开始(对应 Spring Boot2.7 开始),各种已知的变化都来和小伙伴们梳理一下。
1. WebSecurityConfigurerAdapter
首先第一点,就是各位小伙伴最容易发现的 WebSecurityConfigurerAdapter
过期了,在目前最新的 Spring Security6.1 中,这个类已经完全被移除了,想凑合着用都不行了。
准确来说,Spring Security 是在 5.7.0-M2 这个版本中将 WebSecurityConfigurerAdapter
过期的,过期的原因是因为官方想要鼓励各位开发者使用基于组件的安全配置。
那么什么是基于组件的安全配置呢?我们来举几个例子:
以前我们配置 SecurityFilterChain 的方式是下面这样:
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeHttpRequests((authz) -> authz.anyRequest().authenticated()).httpBasic(withDefaults());}}
那么以后就要改为下面这样了:
@Configuration
public class SecurityConfiguration {@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests((authz) -> authz.anyRequest().authenticated()).httpBasic(withDefaults());return http.build();}}
如果懂之前的写法的话,下面这个代码其实是很好理解的,我就不做过多解释了,不过还不懂 Spring Security 基本用法的小伙伴
以前我们配置 WebSecurity 是这样:
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {@Overridepublic void configure(WebSecurity web) {web.ignoring().antMatchers("/ignore1", "/ignore2");}}
以后就得改成下面这样了:
@Configuration
public class SecurityConfiguration {@Beanpublic WebSecurityCustomizer webSecurityCustomizer() {return (web) -> web.ignoring().antMatchers("/ignore1", "/ignore2");}}
另外还有一个就是关于 AuthenticationManager 的获取,以前可以通过重写父类的方法来获取这个 Bean,类似下面这样:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Override@Beanpublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}
}
以后就只能自己创建这个 Bean 了,类似下面这样:
@Configuration
public class SecurityConfig {@AutowiredUserService userService;@BeanAuthenticationManager authenticationManager() {DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();daoAuthenticationProvider.setUserDetailsService(userService);ProviderManager pm = new ProviderManager(daoAuthenticationProvider);return pm;}
}
当然,也可以从 HttpSecurity 中提取出来 AuthenticationManager,如下:
@Configuration
public class SpringSecurityConfiguration {AuthenticationManager authenticationManager;@AutowiredUserDetailsService userDetailsService;@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);authenticationManagerBuilder.userDetailsService(userDetailsService);authenticationManager = authenticationManagerBuilder.build();http.csrf().disable().cors().disable().authorizeHttpRequests().antMatchers("/api/v1/account/register", "/api/v1/account/auth").permitAll().anyRequest().authenticated().and().authenticationManager(authenticationManager).sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);return http.build();}}
这也是一种办法。
我们来看一个具体的例子。
首先我们新建一个 Spring Boot 工程,引入 Web 和 Spring Security 依赖,注意 Spring Boot 选择最新版。
接下来我们提供一个简单的测试接口,如下:
@RestController
public class HelloController {@GetMapping("/hello")public String hello() {return "hello 江南一点雨!";}
}
小伙伴们知道,在 Spring Security 中,默认情况下,只要添加了依赖,我们项目的所有接口就已经被统统保护起来了,现在启动项目,访问 /hello
接口,就需要登录之后才可以访问,登录的用户名是 user,密码则是随机生成的,在项目的启动日志中。
现在我们的第一个需求是使用自定义的用户,而不是系统默认提供的,这个简单,我们只需要向 Spring 容器中注册一个 UserDetailsService 的实例即可,像下面这样:
@Configuration
public class SecurityConfig {@BeanUserDetailsService userDetailsService() {InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());users.createUser(User.withUsername("江南一点雨").password("{noop}123").roles("admin").build());return users;}}
这就可以了。
当然我现在的用户是存在内存中的,如果你的用户是存在数据库中,那么只需要提供 UserDetailsService 接口的实现类并注入 Spring 容器即可,这个之前在 vhr 视频中讲过多次了(公号后台回复 666 有视频介绍),这里就不再赘述了。
但是假如说我希望 /hello
这个接口能够匿名访问,并且我希望这个匿名访问还不经过 Spring Security 过滤器链,要是在以前,我们可以重写 configure(WebSecurity)
方法进行配置,但是现在,得换一种玩法:
@Configuration
public class SecurityConfig {@BeanUserDetailsService userDetailsService() {InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());users.createUser(User.withUsername("江南一点雨").password("{noop}123").roles("admin").build());return users;}@BeanWebSecurityCustomizer webSecurityCustomizer() {return new WebSecurityCustomizer() {@Overridepublic void customize(WebSecurity web) {web.ignoring().antMatchers("/hello");}};}}
以前位于 configure(WebSecurity)
方法中的内容,现在位于 WebSecurityCustomizer Bean 中,该配置的东西写在这里就可以了。
那如果我还希望对登录页面,参数等,进行定制呢?继续往下看:
@Configuration
public class SecurityConfig {@BeanUserDetailsService userDetailsService() {InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());users.createUser(User.withUsername("江南一点雨").password("{noop}123").roles("admin").build());return users;}@BeanSecurityFilterChain securityFilterChain() {List<Filter> filters = new ArrayList<>();return new DefaultSecurityFilterChain(new AntPathRequestMatcher("/**"), filters);}}
Spring Security 的底层实际上就是一堆过滤器,所以我们之前在 configure(HttpSecurity) 方法中的配置,实际上就是配置过滤器链。现在过滤器链的配置,我们通过提供一个 SecurityFilterChain Bean 来配置过滤器链,SecurityFilterChain 是一个接口,这个接口只有一个实现类 DefaultSecurityFilterChain,构建 DefaultSecurityFilterChain 的第一个参数是拦截规则,也就是哪些路径需要拦截,第二个参数则是过滤器链,这里我给了一个空集合,也就是我们的 Spring Security 会拦截下所有的请求,然后在一个空集合中走一圈就结束了,相当于不拦截任何请求。
此时重启项目,你会发现 /hello
也是可以直接访问的,就是因为这个路径不经过任何过滤器。
其实我觉得目前这中新写法比以前老的写法更直观,更容易让大家理解到 Spring Security 底层的过滤器链工作机制。
有小伙伴会说,这写法跟我以前写的也不一样呀!这么配置,我也不知道 Spring Security 中有哪些过滤器,其实,换一个写法,我们就可以将这个配置成以前那种样子:
@Configuration
public class SecurityConfig {@BeanUserDetailsService userDetailsService() {InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();users.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());users.createUser(User.withUsername("江南一点雨").password("{noop}123").roles("admin").build());return users;}@BeanSecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.authorizeRequests().anyRequest().authenticated().and().formLogin().permitAll().and().csrf().disable();return http.build();}}
这么写,就跟以前的写法其实没啥大的差别了。
2. 使用 Lambda
在最新版中,小伙伴们发现,很多常见的方法废弃了,如下图:
包括大家熟悉的用来连接各个配置项的 and() 方法现在也废弃了,并且按照官方的说法,将在 Spring Security7 中彻底移除该方法。
也就是说,你以后见不到类似下面这样的配置了:
@Override
protected void configure(HttpSecurity http) throws Exception {InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();users.createUser(User.withUsername("javagirl").password("{noop}123").roles("admin").build());http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().csrf().disable().userDetailsService(users);http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
and() 方法将被移除!
从上面 and 方法的注释中小伙伴们可以看到,官方现在是在推动基于 Lambda 的配置来代替传统的链式配置,所以以后我们的写法就得改成下面这样啦:
@Configuration
public class SecurityConfig {@BeanSecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests(auth -> auth.requestMatchers("/hello").hasAuthority("user").anyRequest().authenticated()).formLogin(form -> form.loginProcessingUrl("/login").usernameParameter("name").passwordParameter("passwd")).csrf(csrf -> csrf.disable()).sessionManagement(session -> session.maximumSessions(1).maxSessionsPreventsLogin(true));return http.build();}
}
其实,这里的几个方法倒不是啥新方法,只不过有的小伙伴可能之前不太习惯用上面这几个方法进行配置,习惯于链式配置。可是往后,就得慢慢习惯上面这种按照 Lambda 的方式来配置了,配置的内容倒很好理解,我觉得没啥好解释的。
3. 自定义 JSON 登录
自定义 JSON 登录也和之前旧版不太一样了。
3.1 自定义 JSON 登录
小伙伴们知道,Spring Security 中默认的登录接口数据格式是 key-value 的形式,如果我们想使用 JSON 格式来登录,那么就必须自定义过滤器或者自定义登录接口,下面先来和小伙伴们展示一下这两种不同的登录形式。
3.1.1 自定义登录过滤器
Spring Security 默认处理登录数据的过滤器是 UsernamePasswordAuthenticationFilter,在这个过滤器中,系统会通过 request.getParameter(this.passwordParameter)
的方式将用户名和密码读取出来,很明显这就要求前端传递参数的形式是 key-value。
如果想要使用 JSON 格式的参数登录,那么就需要从这个地方做文章了,我们自定义的过滤器如下:
public class JsonLoginFilter extends UsernamePasswordAuthenticationFilter {@Overridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {//获取请求头,据此判断请求参数类型String contentType = request.getContentType();if (MediaType.APPLICATION_JSON_VALUE.equalsIgnoreCase(contentType) || MediaType.APPLICATION_JSON_UTF8_VALUE.equalsIgnoreCase(contentType)) {//说明请求参数是 JSONif (!request.getMethod().equals("POST")) {throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());}String username = null;String password = null;try {//解析请求体中的 JSON 参数User user = new ObjectMapper().readValue(request.getInputStream(), User.class);username = user.getUsername();username = (username != null) ? username.trim() : "";password = user.getPassword();password = (password != null) ? password : "";} catch (IOException e) {throw new RuntimeException(e);}//构建登录令牌UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,password);// Allow subclasses to set the "details" propertysetDetails(request, authRequest);//执行真正的登录操作Authentication auth = this.getAuthenticationManager().authenticate(authRequest);return auth;} else {return super.attemptAuthentication(request, response);}}
}
看过之前的 Spring Security 系列文章的小伙伴,这段代码应该都是非常熟悉了。
- 首先我们获取请求头,根据请求头的类型来判断请求参数的格式。
- 如果是 JSON 格式的参数,就在 if 中进行处理,否则说明是 key-value 形式的参数,那么我们就调用父类的方法进行处理即可。
- JSON 格式的参数的处理逻辑和 key-value 的处理逻辑是一致的,唯一不同的是参数的提取方式不同而已。
最后,我们还需要对这个过滤器进行配置:
@Configuration
public class SecurityConfig {@AutowiredUserService userService;@BeanJsonLoginFilter jsonLoginFilter() {JsonLoginFilter filter = new JsonLoginFilter();filter.setAuthenticationSuccessHandler((req,resp,auth)->{resp.setContentType("application/json;charset=utf-8");PrintWriter out = resp.getWriter();//获取当前登录成功的用户对象User user = (User) auth.getPrincipal();user.setPassword(null);RespBean respBean = RespBean.ok("登录成功", user);out.write(new ObjectMapper().writeValueAsString(respBean));});filter.setAuthenticationFailureHandler((req,resp,e)->{resp.setContentType("application/json;charset=utf-8");PrintWriter out = resp.getWriter();RespBean respBean = RespBean.error("登录失败");if (e instanceof BadCredentialsException) {respBean.setMessage("用户名或者密码输入错误,登录失败");} else if (e instanceof DisabledException) {respBean.setMessage("账户被禁用,登录失败");} else if (e instanceof CredentialsExpiredException) {respBean.setMessage("密码过期,登录失败");} else if (e instanceof AccountExpiredException) {respBean.setMessage("账户过期,登录失败");} else if (e instanceof LockedException) {respBean.setMessage("账户被锁定,登录失败");}out.write(new ObjectMapper().writeValueAsString(respBean));});filter.setAuthenticationManager(authenticationManager());filter.setFilterProcessesUrl("/login");return filter;}@BeanAuthenticationManager authenticationManager() {DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();daoAuthenticationProvider.setUserDetailsService(userService);ProviderManager pm = new ProviderManager(daoAuthenticationProvider);return pm;}@BeanSecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {//开启过滤器的配置http.authorizeHttpRequests()//任意请求,都要认证之后才能访问.anyRequest().authenticated().and()//开启表单登录,开启之后,就会自动配置登录页面、登录接口等信息.formLogin()//和登录相关的 URL 地址都放行.permitAll().and()//关闭 csrf 保护机制,本质上就是从 Spring Security 过滤器链中移除了 CsrfFilter.csrf().disable();http.addFilterBefore(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class);return http.build();}}
这里就是配置一个 JsonLoginFilter 的 Bean,并将之添加到 Spring Security 过滤器链中即可。
在 Spring Boot3 之前(Spring Security6 之前),上面这段代码就可以实现 JSON 登录了。
但是从 Spring Boot3 开始,这段代码有点瑕疵了,直接用已经无法实现 JSON 登录了,具体原因下文分析。
3.1.2 自定义登录接口
另外一种自定义 JSON 登录的方式是直接自定义登录接口,如下:
@RestController
public class LoginController {@AutowiredAuthenticationManager authenticationManager;@PostMapping("/doLogin")public String doLogin(@RequestBody User user) {UsernamePasswordAuthenticationToken unauthenticated = UsernamePasswordAuthenticationToken.unauthenticated(user.getUsername(), user.getPassword());try {Authentication authenticate = authenticationManager.authenticate(unauthenticated);SecurityContextHolder.getContext().setAuthentication(authenticate);return "success";} catch (AuthenticationException e) {return "error:" + e.getMessage();}}
}
这里直接自定义登录接口,请求参数通过 JSON 的形式来传递。拿到用户名密码之后,调用 AuthenticationManager#authenticate 方法进行认证即可。认证成功之后,将认证后的用户信息存入到 SecurityContextHolder 中。
最后再配一下登录接口就行了:
@Configuration
public class SecurityConfig {@AutowiredUserService userService;@BeanAuthenticationManager authenticationManager() {DaoAuthenticationProvider provider = new DaoAuthenticationProvider();provider.setUserDetailsService(userService);ProviderManager pm = new ProviderManager(provider);return pm;}@BeanSecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests()//表示 /doLogin 这个地址可以不用登录直接访问.requestMatchers("/doLogin").permitAll().anyRequest().authenticated().and().formLogin().permitAll().and().csrf().disable();return http.build();}
}
这也算是一种使用 JSON 格式参数的方案。在 Spring Boot3 之前(Spring Security6 之前),上面这个方案也是没有任何问题的。
从 Spring Boot3(Spring Security6) 开始,上面这两种方案都出现了一些瑕疵。
具体表现就是:当你调用登录接口登录成功之后,再去访问系统中的其他页面,又会跳转回登录页面,说明访问登录之外的其他接口时,系统不知道你已经登录过了。
3.2 原因分析
产生上面问题的原因,主要在于 Spring Security 过滤器链中有一个过滤器发生变化了:
在 Spring Boot3 之前,Spring Security 过滤器链中有一个名为 SecurityContextPersistenceFilter 的过滤器,这个过滤器在 Spring Boot2.7.x 中废弃了,但是还在使用,在 Spring Boot3 中则被从 Spring Security 过滤器链中移除了,取而代之的是一个名为 SecurityContextHolderFilter 的过滤器。
在第一小节和小伙伴们介绍的两种 JSON 登录方案在 Spring Boot2.x 中可以运行在 Spring Boot3.x 中无法运行,就是因为这个过滤器的变化导致的。
所以接下来我们就来分析一下这两个过滤器到底有哪些区别。
先来看 SecurityContextPersistenceFilter 的核心逻辑:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws IOException, ServletException {HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);try {SecurityContextHolder.setContext(contextBeforeChainExecution);chain.doFilter(holder.getRequest(), holder.getResponse());}finally {SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();SecurityContextHolder.clearContext();this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());}
}
我这里只贴出来了一些关键的核心代码:
- 首先,这个过滤器位于整个 Spring Security 过滤器链的第三个,是非常靠前的。
- 当登录请求经过这个过滤器的时候,首先会尝试从 SecurityContextRepository(上文中的 this.repo)中读取到 SecurityContext 对象,这个对象中保存了当前用户的信息,第一次登录的时候,这里实际上读取不到任何用户信息。
- 将读取到的 SecurityContext 存入到 SecurityContextHolder 中,默认情况下,SecurityContextHolder 中通过 ThreadLocal 来保存 SecurityContext 对象,也就是当前请求在后续的处理流程中,只要在同一个线程里,都可以直接从 SecurityContextHolder 中提取到当前登录用户信息。
- 请求继续向后执行。
- 在 finally 代码块中,当前请求已经结束了,此时再次获取到 SecurityContext,并清空 SecurityContextHolder 防止内存泄漏,然后调用
this.repo.saveContext
方法保存当前登录用户对象(实际上是保存到 HttpSession 中)。 - 以后其他请求到达的时候,执行前面第 2 步的时候,就读取到当前用户的信息了,在请求后续的处理过程中,Spring Security 需要知道当前用户的时候,会自动去 SecurityContextHolder 中读取当前用户信息。
这就是 Spring Security 认证的一个大致流程。
然而,到了 Spring Boot3 之后,这个过滤器被 SecurityContextHolderFilter 取代了,我们来看下 SecurityContextHolderFilter 过滤器的一个关键逻辑:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException {Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);try {this.securityContextHolderStrategy.setDeferredContext(deferredContext);chain.doFilter(request, response);}finally {this.securityContextHolderStrategy.clearContext();request.removeAttribute(FILTER_APPLIED);}
}
小伙伴们看到,前面的逻辑基本上还是一样的,不一样的是 finally 中的代码,finally 中少了一步向 HttpSession 保存 SecurityContext 的操作。
这下就明白了,用户登录成功之后,用户信息没有保存到 HttpSession,导致下一次请求到达的时候,无法从 HttpSession 中读取到 SecurityContext 存到 SecurityContextHolder 中,在后续的执行过程中,Spring Security 就会认为当前用户没有登录。
这就是问题的原因!
找到原因,那么问题就好解决了。
3.3 问题解决
首先问题出在了过滤器上,直接改过滤器倒也不是不可以,但是,既然 Spring Security 在升级的过程中抛弃了之前旧的方案,我们又费劲的把之前旧的方案写回来,好像也不合理。
其实,Spring Security 提供了另外一个修改的入口,在 org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#successfulAuthentication 方法中,源码如下:
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,Authentication authResult) throws IOException, ServletException {SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();context.setAuthentication(authResult);this.securityContextHolderStrategy.setContext(context);this.securityContextRepository.saveContext(context, request, response);this.rememberMeServices.loginSuccess(request, response, authResult);if (this.eventPublisher != null) {this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));}this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
这个方法是当前用户登录成功之后的回调方法,小伙伴们看到,在这个回调方法中,有一句 this.securityContextRepository.saveContext(context, request, response);
,这就表示将当前登录成功的用户信息存入到 HttpSession 中。
在当前过滤器中,securityContextRepository 的类型是 RequestAttributeSecurityContextRepository,这个表示将 SecurityContext 存入到当前请求的属性中,那很明显,在当前请求结束之后,这个数据就没了。在 Spring Security 的自动化配置类中,将 securityContextRepository 属性指向了 DelegatingSecurityContextRepository,这是一个代理的存储器,代理的对象是 RequestAttributeSecurityContextRepository 和 HttpSessionSecurityContextRepository,所以在默认的情况下,用户登录成功之后,在这里就把登录用户数据存入到 HttpSessionSecurityContextRepository 中了。
当我们自定义了登录过滤器之后,就破坏了自动化配置里的方案了,这里使用的 securityContextRepository 对象就真的是 RequestAttributeSecurityContextRepository 了,所以就导致用户后续访问时系统以为用户未登录。
那么解决方案很简单,我们只需要为自定义的过滤器指定 securityContextRepository 属性的值就可以了,如下:
@Bean
JsonLoginFilter jsonLoginFilter() {JsonLoginFilter filter = new JsonLoginFilter();filter.setAuthenticationSuccessHandler((req,resp,auth)->{resp.setContentType("application/json;charset=utf-8");PrintWriter out = resp.getWriter();//获取当前登录成功的用户对象User user = (User) auth.getPrincipal();user.setPassword(null);RespBean respBean = RespBean.ok("登录成功", user);out.write(new ObjectMapper().writeValueAsString(respBean));});filter.setAuthenticationFailureHandler((req,resp,e)->{resp.setContentType("application/json;charset=utf-8");PrintWriter out = resp.getWriter();RespBean respBean = RespBean.error("登录失败");if (e instanceof BadCredentialsException) {respBean.setMessage("用户名或者密码输入错误,登录失败");} else if (e instanceof DisabledException) {respBean.setMessage("账户被禁用,登录失败");} else if (e instanceof CredentialsExpiredException) {respBean.setMessage("密码过期,登录失败");} else if (e instanceof AccountExpiredException) {respBean.setMessage("账户过期,登录失败");} else if (e instanceof LockedException) {respBean.setMessage("账户被锁定,登录失败");}out.write(new ObjectMapper().writeValueAsString(respBean));});filter.setAuthenticationManager(authenticationManager());filter.setFilterProcessesUrl("/login");filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());return filter;
}
小伙伴们看到,最后调用 setSecurityContextRepository 方法设置一下就行。
Spring Boot3.x 之前之所以不用设置这个属性,是因为这里虽然没保存最后还是在 SecurityContextPersistenceFilter 过滤器中保存了。
那么对于自定义登录接口的问题,解决思路也是类似的:
@RestController
public class LoginController {@AutowiredAuthenticationManager authenticationManager;@PostMapping("/doLogin")public String doLogin(@RequestBody User user, HttpSession session) {UsernamePasswordAuthenticationToken unauthenticated = UsernamePasswordAuthenticationToken.unauthenticated(user.getUsername(), user.getPassword());try {Authentication authenticate = authenticationManager.authenticate(unauthenticated);SecurityContextHolder.getContext().setAuthentication(authenticate);session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext());return "success";} catch (AuthenticationException e) {return "error:" + e.getMessage();}}
}
小伙伴们看到,在登录成功之后,开发者自己手动将数据存入到 HttpSession 中,这样就能确保下个请求到达的时候,能够从 HttpSession 中读取到有效的数据存入到 SecurityContextHolder 中了。
4. 该如何实现动态权限管理
4.1. 权限开发思路
先来说权限开发的思路,当我们设计好 RBAC 权限之后,具体到代码层面,我们有两种实现思路:
- 直接在接口/Service 层方法上添加权限注解,这样做的好处是实现简单,但是有一个问题就是权限硬编码,每一个方法需要什么权限都是代码中配置好的,后期如果想通过管理页面修改是不可能的,要修改某一个方法所需要的权限只能改代码。
- 将请求和权限的关系通过数据库来描述,每一个请求需要什么权限都在数据库中配置好,当请求到达的时候,动态查询,然后判断权限是否满足,这样做的好处是比较灵活,将来需要修改接口和权限之间的关系时,可以通过管理页面点击几下,问题就解决了,不用修改代码,松哥之前的 vhr 中就是这样做的。
4.2. 具体实践
4.2.1 旧方案回顾
第一个类是收集权限元数据的类:
@Component
public class CustomFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {@Overridepublic Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {//...}@Overridepublic Collection<ConfigAttribute> getAllConfigAttributes() {return null;}@Overridepublic boolean supports(Class<?> clazz) {return true;}
}
在 getAttributes 方法中,根据当前请求的 URL 地址(从参数 Object 中可提取出来),然后根据权限表中的配置,分析出来当前请求需要哪些权限并返回。
另外我还重写了一个决策器,其实决策器也可以不重写,就看你自己的需求,如果 Spring Security 自带的决策器无法满足你的需求,那么可以自己写一个决策器:
@Component
public class CustomUrlDecisionManager implements AccessDecisionManager {@Overridepublic void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {//...}@Overridepublic boolean supports(ConfigAttribute attribute) {return true;}@Overridepublic boolean supports(Class<?> clazz) {return true;}
}
decide 方法就是做决策的地方,第一个参数中可以提取出当前用户具备什么权限,第三个参数是当前请求需要什么权限,比较一下就行了,如果当前用户不具备需要的权限,则直接抛出 AccessDeniedException 异常即可。
最后,通过 Bean 的后置处理器 BeanPostProcessor,将这两个配置类放到 Spring Security 的 FilterSecurityInterceptor 拦截器中:
@Override
protected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {@Overridepublic <O extends FilterSecurityInterceptor> O postProcess(O object) {object.setAccessDecisionManager(customUrlDecisionManager);object.setSecurityMetadataSource(customFilterInvocationSecurityMetadataSource);return object;}}).and()//...
}
大致上的逻辑就是如此
4.2.2 新方案
不过以上代码在目前最新的 Spring Security6 中用不了了,不是因为类过期了,而是因为类被移除了!哪个类被移除了?FilterSecurityInterceptor。
FilterSecurityInterceptor 这个过滤器以前是做权限处理的,但是在新版的 Spring Security6 中,这个拦截器被 AuthorizationFilter 代替了。
老实说,新版的方案其实更合理一些,传统的方案感觉带有很多前后端不分的影子,现在就往更纯粹的前后端分离奔去。
由于新版中连 FilterSecurityInterceptor 都不用了,所以旧版的方案显然行不通了,新版的方案实际上更加简单。
虽然新旧写法不同,但是核心思路是一模一样。
我们来看下新版的配置:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests(register -> register.anyRequest().access((authentication, object) -> {//表示请求的 URL 地址和数据库的地址是否匹配上了boolean isMatch = false;//获取当前请求的 URL 地址String requestURI = object.getRequest().getRequestURI();List<MenuWithRoleVO> menuWithRole = menuService.getMenuWithRole();for (MenuWithRoleVO m : menuWithRole) {if (antPathMatcher.match(m.getUrl(), requestURI)) {isMatch = true;//说明找到了请求的地址了//这就是当前请求需要的角色List<Role> roles = m.getRoles();//获取当前登录用户的角色Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities();for (GrantedAuthority authority : authorities) {for (Role role : roles) {if (authority.getAuthority().equals(role.getName())) {//说明当前登录用户具备当前请求所需要的角色return new AuthorizationDecision(true);}}}}}if (!isMatch) {//说明请求的 URL 地址和数据库的地址没有匹配上,对于这种请求,统一只要登录就能访问if (authentication.get() instanceof AnonymousAuthenticationToken) {return new AuthorizationDecision(false);} else {//说明用户已经认证了return new AuthorizationDecision(true);}}return new AuthorizationDecision(false);})).formLogin(form -> //...).csrf(csrf -> //...).exceptionHandling(e -> //...).logout(logout ->//...);return http.build();
}
核心思路还是和之前一样,只不过现在的工作都在 access 方法中完成。
access 方法的回调中有两个参数,第一个参数是 authentication,很明显,这就是当前登录成功的用户对象,从这里我们就可以提取出来当前用户所具备的权限。
第二个参数 object 实际上是一个 RequestAuthorizationContext,从这个里边可以提取出来当前请求对象 HttpServletRequest,进而提取出来当前请求的 URL 地址,然后依据权限表中的信息,判断出当前请求需要什么权限,再和 authentication 中提取出来的当前用户所具备的权限进行对比即可。
如果当前登录用户具备请求所需要的权限,则返回 new AuthorizationDecision(true);
,否则返回 new AuthorizationDecision(false);
即可。
其实无论什么框架,只要能把其中一个版本掌握个 70%,以后无论它怎么升级,你都能快速上手!
相关文章:

Spring Security6 最新版配置该怎么写,该如何实现动态权限管理
Spring Security 在最近几个版本中配置的写法都有一些变化,很多常见的方法都废弃了,并且将在未来的 Spring Security7 中移除,因此又补充了一些新的内容,重新发一下,供各位使用 Spring Security 的小伙伴们参考。 接下…...
CommandLineRunner 和 ApplicationRunner 用于Spring Boot 应用启动后执行特定逻辑
CommandLineRunner 和 ApplicationRunner 都是 Spring Boot 中用于在应用启动后执行特定逻辑的接口。它们的主要区别在于传递的参数类型和执行顺序。下面我将为您详细解释它们的用途、使用案例以及执行顺序。 CommandLineRunner CommandLineRunner 是一个接口,它有…...

一、Dubbo 简介与架构
一、Dubbo 简介与架构 1.1 应用架构演进过程 单体应用:JEE、MVC分布式应用:SOA、微服务化 1.2 Dubbo 简介一种分布式 RPC 框架,对专业知识(序列化/反序列化、网络、多线程、设计模式、性能优化等)进行了更高层的抽象和…...

软考:中级软件设计师:文件管理,索引文件结构,树型文件结构,位示图,数据传输方式,微内核
软考:中级软件设计师: 提示:系列被面试官问的问题,我自己当时不会,所以下来自己复盘一下,认真学习和总结,以应对未来更多的可能性 关于互联网大厂的笔试面试,都是需要细心准备的 (1…...

实践-CNN卷积层
实践-CNN卷积层 1 卷积层构造2 整体流程3 BatchNormalization效果4 参数对比5 测试效果 1 卷积层构造 2 整体流程 根据网络结构来写就可以了。 池化 拉平 训练一个网络需要2-3天的时间。用经典网络来,一些细节没有必要去扣。 损失函数: fit模型&…...
【设计模式】MVC 模式
MVC 模式代表 Model-View-Controller(模型-视图-控制器) 模式。这种模式用于应用程序的分层开发。 Model(模型) - 模型代表一个存取数据的对象或 JAVA POJO。它也可以带有逻辑,在数据变化时更新控制器。Viewÿ…...

看康师傅金桔柠檬X国漫IP跨界出圈,打开IP合作新思路
Z世代年轻群体已经成为消费主力,其喜好和消费观念也呈现出全新态势。抓住年轻人的心,就是抓住了品牌未来的战场。 那么到底什么样的营销动作才能真正撬动年轻人? 对于互联网时代成长起来的Z世代年轻人来说,人气二次元IP无疑是能最…...

ElementUI的MessageBox的按钮置灰且不可点击
// this.$confirmthis.$alert(这是一段内容, 标题名称, {confirmButtonText: 确定,confirmButtonCLass: confirmButton,beforeClose: (action,instance,done) > {if (action confirm) {return false} else {done()}});}.confirmButton {background: #ccc !important;cursor…...

pc端与flutter通信失效, Method not found
报错情况描述:pc端与flutter通信,ios端能实现通信,安卓端通信报错 报错通信代码: //app消息通知window.callbackName function (res) {window?.jsBridge && window.jsBridge?.postMessage(JSON.stringify(res), "…...
linux 防火墙经常使用的命令
# 开启防火墙服务 systemctl start firewalld # 关闭防火墙服务 systemctl stop firewalld # 重启防火墙服务 systemctl restart firewalld # 开发端口 firewall-cmd --zonepublic --add-port8080/tcp --permanent # 移除端口 firewall-cmd --zonepublic --remove-port8080/tc…...
Docker desktop安装mysql
首先本地已经有 docker 环境存在,然后可以拉取 MySQL 镜像。 相关 mysql 仓库地址: https://hub.docker.com/_/mysql/ # 镜像拉取 docker pull mysql:8.0.26docker pull mysql:latest# 查看镜像列表docker image ls等待镜像完成之后就可以启动 mysql 了…...

Java SpringBoot Vue ERP系统
系统介绍 该ERP系统基于SpringBoot框架和SaaS模式,支持多租户,专注进销存财务生产功能。主要模块有零售管理、采购管理、销售管理、仓库管理、财务管理、报表查询、系统管理等。支持预付款、收入支出、仓库调拨、组装拆卸、订单等特色功能。拥有商品库存…...

什么是CSS中的渐变(gradient)?如何使用CSS创建线性渐变和径向渐变?
聚沙成塔每天进步一点点 ⭐ 专栏简介⭐ 渐变(Gradient)在CSS中的应用⭐ 线性渐变(Linear Gradient)语法:示例: ⭐ 径向渐变(Radial Gradient)语法:示例: ⭐ 写…...

【深度学习】PyTorch快速入门
【深度学习】学习PyTorch基础 介绍PyTorch 深度学习框架是一种软件工具,旨在简化和加速构建、训练和部署深度学习模型的过程。深度学习框架提供了一系列的函数、类和工具,用于定义、优化和执行各种深度神经网络模型。这些框架帮助研究人员和开发人员专注…...
学习Vue:组件通信
组件化开发在现代前端开发中是一种关键的方法,它能够将复杂的应用程序拆分为更小、更可管理的独立组件。在Vue.js中,父子组件通信是组件化开发中的重要概念,同时我们还会讨论其他组件间通信的方式。 父子组件通信:Props 和 Events…...
springboot项目打包后读取jar包里面的
ResourcePatternResolver resourcePatternResolver new PathMatchingResourcePatternResolver(); Resource[] resources resourcePatternResolver.getResources("classpath*:templates/*.*"); for ( Resource resource : resources ) {//获取文件,在打成…...
设计模式之七大原则
👑单一职责原则 单一职责原则告诉我们一个类应该只有一个责任或者只负责一件事情。 想象一下,如果一个类承担了太多的责任,就像一个人同时负责做饭、洗衣服和打扫卫生一样,那么这个类会变得非常复杂,难以理解和维护。而…...
pytorch入门-TensorBoard和Transforms
TensorBoard from PIL import Image from torch.utils.tensorboard import SummaryWriter from torchvision import transforms# python的用法 -》 tensor数据类型 # 通过transforms.ToTensor 去解决两个问题 # 1. transforms该如何使用(python) # 2. …...
【java】Java基础——接口和实现
当一个类实现一个接口时,必须提供接口中定义的所有方法的具体实现,除非这个类是抽象类。默认方法:default修饰接口中的方法,可实现方法体,在实现接口的类中可以不重写该方法 // 定义一个接口,接口不关心方…...

JetPack Compose 学习笔记(持续整理中...)
1.为什么要学? 1.命令式和声明式 UI大战,个人认为命令式UI自定义程度较高,能更深入到性能,内存优化方面,而申明式UI 是现在主流的设计,比如React,React Native,Flutter,Swift UI等等,现在性能也逐渐在变得更好 2.还有一个原因compose 是KMM 是完整跨平台的UI基础 3.…...

网络编程(Modbus进阶)
思维导图 Modbus RTU(先学一点理论) 概念 Modbus RTU 是工业自动化领域 最广泛应用的串行通信协议,由 Modicon 公司(现施耐德电气)于 1979 年推出。它以 高效率、强健性、易实现的特点成为工业控制系统的通信标准。 包…...

Qt/C++开发监控GB28181系统/取流协议/同时支持udp/tcp被动/tcp主动
一、前言说明 在2011版本的gb28181协议中,拉取视频流只要求udp方式,从2016开始要求新增支持tcp被动和tcp主动两种方式,udp理论上会丢包的,所以实际使用过程可能会出现画面花屏的情况,而tcp肯定不丢包,起码…...
C++:std::is_convertible
C++标志库中提供is_convertible,可以测试一种类型是否可以转换为另一只类型: template <class From, class To> struct is_convertible; 使用举例: #include <iostream> #include <string>using namespace std;struct A { }; struct B : A { };int main…...
逻辑回归:给不确定性划界的分类大师
想象你是一名医生。面对患者的检查报告(肿瘤大小、血液指标),你需要做出一个**决定性判断**:恶性还是良性?这种“非黑即白”的抉择,正是**逻辑回归(Logistic Regression)** 的战场&a…...

Redis相关知识总结(缓存雪崩,缓存穿透,缓存击穿,Redis实现分布式锁,如何保持数据库和缓存一致)
文章目录 1.什么是Redis?2.为什么要使用redis作为mysql的缓存?3.什么是缓存雪崩、缓存穿透、缓存击穿?3.1缓存雪崩3.1.1 大量缓存同时过期3.1.2 Redis宕机 3.2 缓存击穿3.3 缓存穿透3.4 总结 4. 数据库和缓存如何保持一致性5. Redis实现分布式…...

3.3.1_1 检错编码(奇偶校验码)
从这节课开始,我们会探讨数据链路层的差错控制功能,差错控制功能的主要目标是要发现并且解决一个帧内部的位错误,我们需要使用特殊的编码技术去发现帧内部的位错误,当我们发现位错误之后,通常来说有两种解决方案。第一…...

376. Wiggle Subsequence
376. Wiggle Subsequence 代码 class Solution { public:int wiggleMaxLength(vector<int>& nums) {int n nums.size();int res 1;int prediff 0;int curdiff 0;for(int i 0;i < n-1;i){curdiff nums[i1] - nums[i];if( (prediff > 0 && curdif…...
在四层代理中还原真实客户端ngx_stream_realip_module
一、模块原理与价值 PROXY Protocol 回溯 第三方负载均衡(如 HAProxy、AWS NLB、阿里 SLB)发起上游连接时,将真实客户端 IP/Port 写入 PROXY Protocol v1/v2 头。Stream 层接收到头部后,ngx_stream_realip_module 从中提取原始信息…...

剑指offer20_链表中环的入口节点
链表中环的入口节点 给定一个链表,若其中包含环,则输出环的入口节点。 若其中不包含环,则输出null。 数据范围 节点 val 值取值范围 [ 1 , 1000 ] [1,1000] [1,1000]。 节点 val 值各不相同。 链表长度 [ 0 , 500 ] [0,500] [0,500]。 …...
unix/linux,sudo,其发展历程详细时间线、由来、历史背景
sudo 的诞生和演化,本身就是一部 Unix/Linux 系统管理哲学变迁的微缩史。来,让我们拨开时间的迷雾,一同探寻 sudo 那波澜壮阔(也颇为实用主义)的发展历程。 历史背景:su的时代与困境 ( 20 世纪 70 年代 - 80 年代初) 在 sudo 出现之前,Unix 系统管理员和需要特权操作的…...