Spring Security已经更新到了6.x,很多常见的方法都废弃了,并且将在未来的 Spring Security7 中移除,主要总结几点变化。
本文代码环境jdk版本17、boot版本3.1.2、security6.1.2
1、WebSecurityConfigurerAdapter 过期了
准确来说,Spring Security 是在 5.7.0-M2 这个版本中将 WebSecurityConfigurerAdapter 过期的,过期的原因是因为官方想要鼓励各位开发者使用基于组件的安全配置。没有过期的写法如下
@Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authz) -> authz .anyRequest().authenticated() ) .httpBasic(withDefaults()); } //配置 WebSecurity @Override public void configure(WebSecurity web) { web.ignoring().antMatchers("/ignore1", "/ignore2"); } }
现在的写法如下
/** * 5版本只需@Configuration一个注解,不需要@EnableWebSecurity, * 6需要同时引入,并且5是需要extends WebSecurityConfigurerAdapter类 */ @Configuration @EnableWebSecurity public class MySecurityConfig { /** * 5版本是override 方法: configure(HttpSecurity http),6是下面bean */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{ //authorizeHttpRequests:针对http请求进行授权认证,定义不需要认证就能的资源路径,如info http.authorizeHttpRequests(authorizeHttpRequests->authorizeHttpRequests .requestMatchers("/info").permitAll() .anyRequest().authenticated()); /**登录表单配置 * loginPage:登录页面请求地址 * loginProcessingUrl:登录接口 过滤器 * successForwardUrl:登录成功响应地址 * failureForwardUrl:登录失败响应地址 */ http.formLogin(formLogin->formLogin .loginPage("/toLoginPage").permitAll() .loginProcessingUrl("/login") .successForwardUrl("/index") .failureForwardUrl("/toLoginPage")); //关闭crsf 跨域漏洞防御 http.csrf(withDefaults());//相当于 http.csrf(Customizer.withDefaults());或者http.csrf(crsf->crsf.disable()); //退出 http.logout(logout -> logout.invalidateHttpSession(true)); return http.build(); } //配置 WebSecurity @Bean public WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web.ignoring().requestMatchers("/ignore1", "/ignore2"); } }
2、AuthenticationManager的获取
以前可以通过重写父类的方法来获取这个 Bean,类似下面这样
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
6.x中只能自己创建这个 Bean 了
@Configuration public class SecurityConfig { @Autowired UserService userService; @Bean AuthenticationManager authenticationManager() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userService); ProviderManager pm = new ProviderManager(daoAuthenticationProvider); return pm; } }
当然,也可以从 HttpSecurity 中提取出来 AuthenticationManager,如下:
@Configuration @EnableWebSecurity public class MySecurityConfig { AuthenticationManager authenticationManager; @Resource UserDetailsServiceImpl userDetailsService; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class); authenticationManagerBuilder.userDetailsService(userDetailsService); authenticationManager = authenticationManagerBuilder.build(); //不需要认证的请求 return http.authorizeHttpRequests(conf -> conf.requestMatchers("/login", "/info").permitAll().anyRequest().authenticated()) //通过new JwtFilter()的方式,而不是bean组件注入的方式 .addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class) .csrf(withDefaults()) .cors(withDefaults()).build(); } }
3、and去除,使用Lambda
以前使用and类似如下代码
@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); }
但是6中and() 方法被移除!使用Lambda写法如下
@Configuration @EnableWebSecurity public class MySecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { //不需要认证的请求 return http.authorizeHttpRequests(conf -> conf.requestMatchers("/login", "/info").permitAll().anyRequest().authenticated()) //通过new JwtFilter()的方式,而不是bean组件注入的方式 .addFilterBefore(new JwtFilter(), UsernamePasswordAuthenticationFilter.class) .csrf(withDefaults()) .cors(withDefaults()).build(); } }
4、自定义json登录
关于使用json登录有两种方式,自定json登录过滤器和自定义json登录接口
自定json登录过滤器如下
public class JsonLoginFilter extends UsernamePasswordAuthenticationFilter { @Override public 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)) { //说明请求参数是 JSON if (!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" property setDetails(request, authRequest); //执行真正的登录操作 Authentication auth = this.getAuthenticationManager().authenticate(authRequest); return auth; } else { return super.attemptAuthentication(request, response); } } }
然后对这个过滤器进行配置:
@EnableWebSecurity public class ServerSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired UserService userService; @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"); return filter; } @Bean AuthenticationManager authenticationManager() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userService); ProviderManager pm = new ProviderManager(daoAuthenticationProvider); return pm; } @Override protected void configure(HttpSecurity http) throws Exception { //放行不需要拦截的接口资源 http.authorizeRequests() .antMatchers(SystemConstants.HTTP_ACT_MATCHERS).permitAll() .anyRequest() .authenticated() //登录成功失败配置 .and() .formLogin() .and().addFilterAt(jsonLoginFilter(),UsernamePasswordAuthenticationFilter.class); //关闭csrf http.csrf().disable(); //开启跨域 http.cors(); //关闭iframe请求 http.headers().frameOptions().disable(); //禁用缓存 http.headers().cacheControl(); //不适用session http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } }
另外一种自定义 JSON 登录的方式是直接自定义登录接口,如下
@RestController public class LoginController { @Autowired AuthenticationManager 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 中。
最后再配一下登录接口就行了:
@EnableWebSecurity public class ServerSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired UserService userService; @Bean AuthenticationManager authenticationManager() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userService); ProviderManager pm = new ProviderManager(daoAuthenticationProvider); return pm; } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/doLogin").permitAll() .anyRequest() .authenticated(); } }
从 Spring Boot3(Spring Security6) 开始,上面这两种方案都出现了一些瑕疵。
具体表现就是:当你调用登录接口登录成功之后,再去访问系统中的其他页面,又会跳转回登录页面,说明访问登录之外的其他接口时,系统不知道你已经登录过了。
产生上面问题的原因,主要在于 Spring Security 过滤器链中有一个过滤器发生变化了:
在 Spring Boot3 之前,Spring Security 过滤器链中有一个名为 SecurityContextPersistenceFilter 的过滤器,这个过滤器在 Spring Boot2.7.x 中废弃了,但是还在使用,在 Spring Boot3 中则被从 Spring Security 过滤器链中移除了,取而代之的是一个名为 SecurityContextHolderFilter 的过滤器。
先来看 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 就会认为当前用户没有登录。
这就是问题的原因!
找到原因,那么问题就好解决了。
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 方法设置一下就行。
那么对于自定义登录接口的问题,解决思路也是类似的:
@RestController public class LoginController { @Autowired AuthenticationManager 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 中了。
5、动态权限管理
权限开发的思路,当我们设计好 RBAC 权限之后,具体到代码层面,我们有两种实现思路:
- 直接在接口 层方法上添加权限注解,这样做的好处是实现简单,但是有一个问题就是权限硬编码,每一个方法需要什么权限都是代码中配置好的,后期如果想通过管理页面修改是不可能的,要修改某一个方法所需要的权限只能改代码。
- 将请求和权限的关系通过数据库来描述,每一个请求需要什么权限都在数据库中配置好,当请求到达的时候,动态查询,然后判断权限是否满足,这样做的好处是比较灵活,只需数据库或者以后的系统的管理页面配置就行
旧方案实现动态权限验证是通过重写两个类来和实现,第一个类是收集权限元数据的类:
@Component public class CustomFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { @Override public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException { //... } @Override public Collection<ConfigAttribute> getAllConfigAttributes() { return null; } @Override public boolean supports(Class<?> clazz) { return true; } }
在 getAttributes 方法中,根据当前请求的 URL 地址(从参数 Object 中可提取出来),然后根据权限表中的配置,分析出来当前请求需要哪些权限并返回。
另外我还重写了一个决策器,其实决策器也可以不重写,就看你自己的需求,如果 Spring Security 自带的决策器无法满足你的需求,那么可以自己写一个决策器:
@Component public class CustomUrlDecisionManager implements AccessDecisionManager { @Override public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException { //... } @Override public boolean supports(ConfigAttribute attribute) { return true; } @Override public 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>() { @Override public <O extends FilterSecurityInterceptor> O postProcess(O object) { object.setAccessDecisionManager(customUrlDecisionManager); object.setSecurityMetadataSource(customFilterInvocationSecurityMetadataSource); return object; } }) .and() //... }
在Spring Security6 中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);
即可。