news 2026/9/10 7:48:19

SpringBoot3整合SpringSecurity6+JWT实现前后端分离认证授权实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot3整合SpringSecurity6+JWT实现前后端分离认证授权实战

1. 前后端分离下引入SpringSecurity之前的三个决策问题

我在接手一个基于SpringBoot3的新项目时,第一个要考虑的事情并不是怎么写业务代码,而是安全框架怎么落。市面上可选的无非是SpringSecurity、Shiro,或者干脆自己手写拦截器。考虑到项目的长期维护、社区活跃度以及和Spring生态的契合度,选SpringSecurity基本没什么悬念,但真正麻烦的是如何在前后端分离的架构里把它用得顺手。

SpringSecurity在传统服务端渲染项目中非常好理解。请求进来,经过过滤器链,如果没有登录就跳转到一个登录页,登录成功后把用户信息丢进Session,后续请求通过Cookie里的JSESSIONID识别身份。这套机制在服务端渲染时代很成熟,但到了前后端分离,前端是Vue、React这类单页应用,后端只暴露JSON接口,问题就全变了。

第一个问题:前端不认服务端跳转。如果Security发现未登录就302到登录页,前端拿到的是一个重定向而不是JSON,这没法用。正确的做法是让Security返回401状态码加一段JSON,由前端统一拦截处理。

第二个问题:Session不再合适。前后端分离后,前端和后端通常分开部署,Session在单实例下还能用,一旦后端多做几个副本,Session共享就得引入Redis等外部依赖,整体复杂度上去了。更麻烦的是,移动端App并没有Cookie的概念,天然不适配Session方案。替代方案一般是Token,实践中用得最多的又是JWT。

第三个问题:CSRF防护是不是该关掉。CSRF攻击依赖浏览器自动携带Cookie这个特性,如果我用了JWT放在Authorization头里,CSRF的威胁模型就不存在了,默认开启反而会拦截掉正常请求。所以前后端分离场景下要把它关掉。

搞清楚这三个问题之后,SpringSecurity在前后端分离下的改造思路就非常清晰了。总结下来就两句话:把默认的登录方式从表单改成接口认证加Token;把默认的会话管理改成无状态。下面我会从环境准备开始,一步步把整个方案落地。

2. SpringBoot3加Security 6的环境准备与版本差异

2.1 JDK版本与技术栈要求

SpringBoot3和之前的2.x版本有一个本质区别:它强制要求JDK17及以上。这一点我在第一次搭建项目时就踩过坑,当时机器上还装着JDK8,依赖一拉下来就直接编译不过,报错信息指向jakarta.servlet,后来才意识到是SpringBoot3把整个Java EE的命名空间从javax迁移到了jakarta。

所以第一步先把JDK升级到17或更高版本。如果你正在用IntelliJ IDEA,记得Project Structure里的SDK和Java版本都要同步改,包括Maven或者Gradle的JVM配置。这一步没做好,后面的代码怎么写都起不来。

另外SpringBoot3对应的SpringSecurity版本是6.x。相比5.x,Security6的配置API有很大变化,网上搜到的很多旧教程是Security5的写法,直接复制会碰到一堆过时甚至编译错误的方法。比如WebSecurityConfigurerAdapter这个老配置类,在Security6里已经被彻底移除了,配置方式变成了组件化注册SecurityFilterChain。

2.2 Maven依赖引入

用Maven管理依赖的话,只需要加入一个Security的Starter,SpringBoot会自动装配安全机制。同时建议加上SpringWeb和Lombok,一个是基础Web能力,一个是简化实体类代码,实测下来能省不少事。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

依赖引入之后,这时候启动项目会发现所有的接口都被保护起来了,访问任意路径都会看到一个默认的登录页或者401。这就是Security自动装配的效果,后面要做的事情就是把它改造成符合前后端分离需求的样子。

注意:如果项目里还需要接入Knife4j来生成接口文档,依赖方面也要选对版本。SpringBoot3对应的是knife4j-openapi3-jakarta-spring-boot-starter,而不是老版本的knife4j-spring-boot-starter。前者基于OpenAPI3规范,适配了jakarta命名空间,直接用老版本会在启动时报类找不到的错误。

2.3 理解Security6的过滤器链机制

在动手写代码之前,我觉得有必要花一点时间理解SpringSecurity的执行机制,不然配置类里的那一堆lambda表达式很容易让人一头雾水。

SpringSecurity的核心是一个过滤器链,它由若干个过滤器组成,请求进来后按照顺序经过这些过滤器,每个过滤器只干一件事。有的负责构造认证信息,有的负责校验CSRF,有的负责权限判断。当整个过滤器链执行完成后,请求才会到达Controller层。

Security6的开放配置方式是把过滤器的默认行为配置到一个SecurityFilterChain组件里。你可以理解成有一串默认的过滤器,我们通过HttpSecurity这个配置对象来增减、调整它们的行为。比如我们要关闭CSRF,就把这环摘掉;我们要在认证前检查JWT,就自定义一个过滤器插到链中合适的位置。

3. 核心准备工作:用户模型、认证服务与JWT工具类

3.1 用户表结构与UserDetailsService实现

既然要接入Security,用户的加载过程就要交给框架来管理。Security定义了一个UserDetailsService接口,业务系统只需要实现它,告诉框架"给我一个用户名,我返回这个用户的账号、密码、角色等信息"。框架拿到之后会自己完成密码比对,我们不需要手动做比对逻辑。

用户表我一般这么设计:

字段名类型说明
idbigint主键
usernamevarchar登录名
passwordvarcharBCrypt加密后的密码
nicknamevarchar昵称
rolevarchar角色标识,如ADMIN、USER
statustinyint状态,1启用,0禁用
created_timedatetime创建时间

对应的实体类不用多说了,重点是实现UserDetailsService时怎么把数据库用户转换成Security认识的UserDetails对象。

@Service public class UserServiceImpl implements UserDetailsService { @Resource private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userMapper.selectByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } if (user.getStatus() == 0) { throw new DisabledException("账号已禁用"); } return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .roles(user.getRole()) .build(); } }

这里有一点要特别提醒:loadUserByUsername方法返回的UserDetails中的密码必须是加密后的密文,不能是明文。因为Security框架拿到密文之后,会用PasswordEncoder去和前端提交的明文做比对。如果数据库中存的是明文,要么报错,要么永远比对不通过。

3.2 BCrypt密码加密与DelegatingPasswordEncoder

关于密码加密,Security6默认使用的是BCryptPasswordEncoder。它的特点是同一个密码每次加密的结果都不一样,因为BCrypt算法内部会随机生成盐值,并把这些盐值包含在最终结果里,验证时从密文中提取盐值再比对。相比MD5这种老算法,安全性高得多。

注册新用户时,一般通过PasswordEncoder的encode方法生成密文再入库。实现在容器中声明一个PasswordEncoder的Bean即可:

@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }

我在实际项目中见过有人为了方便测试,直接在前端传了一个明文密码,然后后端又用了NoOpPasswordEncoder。这种做法非常危险,一旦数据库泄露,所有账号密码直接暴露。生产环境千万不要图省事绕过加密,省下来的那点开发时间,后面可能用几倍的代价来还。

3.3 JWT工具的封装思路

JWT本质上是一段包含用户信息且经过签名防篡改的字符串。通常包含三部分:头部、载荷、签名。头部声明算法和类型,载荷放业务数据比如用户ID、用户名、过期时间,签名用密钥生成,用于验证令牌是否被篡改。

我封装的JwtUtils主要提供三个方法:生成token、解析token、校验token是否过期。生成时把用户ID和用户名放进去,过期时间一般设24小时,实际项目有更高安全需求就缩短并配合RefreshToken机制,这里先不做展开。

@Component public class JwtUtils { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private long expire; public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expire * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } public boolean isTokenExpired(String token) { return parseToken(token).getExpiration().before(new Date()); } }

使用JWT的时候有几个细节需要提前注意。JWT是无法在服务端主动失效的,它的有效性只取决于签名和过期时间,所以如果系统有封禁用户的需求,光靠JWT是不行的,需要额外的黑名单机制配合。另外如果项目里配置了多个微服务实例,各实例必须共享同一个secret,否则A服务签发的token在B服务上会解析失败。生产环境的secret建议放到配置中心统一管理。

4. 认证过滤器和SecurityConfig:整套方案的心脏

4.1 自定义JWT认证过滤器

接下来是这个方案里最核心的自定义组件:JwtAuthenticationTokenFilter。它的职责很单一:从请求头中读取token,解析成功后把用户信息放进SecurityContext里。这样后续的过滤器就能判断"当前用户是谁,有什么权限"。

这个过滤器需要继承OncePerRequestFilter,保证一个请求只执行一次。里面大致逻辑是:从Authorization头中取token,如果没有就放行,让后面的过滤器去处理;如果有则解析token,从token中取出用户名,再去数据库查一次完整用户信息,查到了就构建一个Authentication对象放进SecurityContextHolder。

@Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Resource private JwtUtils jwtUtils; @Resource private UserServiceImpl userService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { Claims claims = jwtUtils.parseToken(token); String username = claims.getSubject(); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { // token无效时不设置认证信息,后续会走401逻辑 } } filterChain.doFilter(request, response); } }

这里有个关键点需要解释:为什么查了一遍UserDetailsService还要检查SecurityContextHolder里的Authentication是否为空。因为有可能会话中已经有认证信息了,重复设置反而可能导致旧的认证信息被覆盖,产生不必要的麻烦。这种写法也符合Security框架的设计习惯。

4.2 SecurityConfig配置类的详细解析

配置类是整套方案的粘合点。前面写了用户服务、写了JWT工具、写了过滤器,但如果不把它们在配置类里关联起来,Security框架根本不知道这些组件的存在。

下面是完整配置类,我会对每一段配置说明它的作用:

@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Resource private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/login", "/api/auth/captcha", "/doc.html", "/webjars/**", "/v3/api-docs/**", "/favicon.ico" ).permitAll() .anyRequest().authenticated() ) .exceptionHandling(handler -> handler .authenticationEntryPoint((request, response, authException) -> { response.setContentType("application/json;charset=utf-8"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setContentType("application/json;charset=utf-8"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.getWriter().write("{\"code\":403,\"msg\":\"权限不足\"}"); }) ) .authenticationProvider(authenticationProvider()) .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userService); provider.setPasswordEncoder(passwordEncoder()); return provider; } }

这段配置有几个细节必须说清楚。

第一,requestMatchers替代了老版本里的antMatchers。Security6之后,原来的antMatchers方法被移除了,换成requestMatchers。如果不小心用了旧写法,代码直接编译不过。permitAll意味着这些路径不需要认证就能访问,登录接口、文档地址、静态资源都放这里。

第二,exceptionHandling里配置的两个处理器非常关键。authenticationEntryPoint处理的是未认证的请求,也就是401场景;accessDeniedHandler处理的是已认证但没有权限的场景,也就是403场景。前后端分离下这两个处理器必须自定义,否则Security会按默认的页面跳转逻辑来,这不符合接口化需求。

第三,addFilterBefore方法。这句话表示把我们自定义的JWT过滤器插到UsernamePasswordAuthenticationFilter之前执行。也就是说,请求会先经过我们的过滤器尝试解析token,解析成功就放行,后面的认证过滤器一看已经认证过了就直接跳过。这个顺序很重要,放错位置会导致token解析了但认证信息没生效。

4.3 无状态会话和跨域配置的联动

上面的配置里有一行.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)),它的作用是告诉Security不要创建HttpSession,也不要从HttpSession中读取认证信息。这行配置是前后端分离的核心,少了它,JWT方案和Session方案会同时生效,产生一些很奇怪的交互问题。

同时,现在的项目基本都会遇到跨域请求。如果前端和后端不在同一个域名或者端口,浏览器会拦截跨域响应,需要后端配置CORS。Security的跨域配置和SpringMVC的配置是两套体系,只配了SpringMVC的CORS还不够,因为Security的过滤器链会先于SpringMVC执行,跨域请求可能在Security这一层就被拦住了。

推荐在SecurityConfig里加上这一段,并把CORS处理器配置到Security链路中:

@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(List.of("*")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; }

然后把上面的配置类中http链路的开头加上.cors(cors -> cors.configurationSource(corsConfigurationSource()))即可。

注意:setAllowedOriginPatterns和setAllowedOrigins是有区别的。如果开了allowCredentials(true),用setAllowedOrigins("*")会报错,而setAllowedOriginPatterns可以支持通配,所以推荐用它。

5. 登录接口与权限控制的完整实现

5.1 登录接口的实现逻辑

配置都接好后,就轮到业务层实现登录接口了。登录接口的路径对应前面permitAll里放行的/api/auth/login。这个接口接收用户名和密码,调用AuthenticationManager完成校验,校验成功后签发JWT返回给前端。

@RestController @RequestMapping("/api/auth") public class AuthController { @Resource private AuthenticationManager authenticationManager; @Resource private JwtUtils jwtUtils; @PostMapping("/login") public Result login(@RequestBody LoginDTO loginDTO) { UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginDTO.getUsername(), loginDTO.getPassword()); Authentication authentication = authenticationManager.authenticate(token); UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String jwt = jwtUtils.generateToken(userDetails.getUsername()); return Result.success(jwt); } }

这里有个思想上的转变要说明白:在传统Security的登录流程中,提交表单这件事是框架帮你做的,你不需要自己写Controller。但在前后端分离方案中,我们完全绕开了框架默认的登录入口,所以要手动调用AuthenticationManager来完成"账号密码校验"这件事。如果校验失败,认证管理器会抛出BadCredentialsException,你可以统一捕获返回"用户名或密码错误"。

5.2 如何在Controller里获取当前登录用户

前端拿到token后,每次请求都会带上Authorization头。经过JWT过滤器后,SecurityContextHolder里已经有了用户信息。在业务代码里有很多地方需要知道当前登录用户是谁,我一般用下面两种方式获取。

第一种是直接从SecurityContextHolder里取:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String username = authentication.getName();

这里能不能getName拿到用户名的前提,是在JWT过滤器构建UsernamePasswordAuthenticationToken时传入了UserDetails对象,否则getName拿到的会是null或者"anonymousUser"。

第二种是SpringMVC支持的参数注入方式,加上@AuthenticationPrincipal注解后直接拿到UserDetails对象:

@GetMapping("/me") public Result getMe(@AuthenticationPrincipal UserDetails userDetails) { return Result.success(userDetails.getUsername()); }

这种方式更优雅,但要注意@AuthenticationPrincipal注入的是Authentication对象的principal属性。我们前面构建过滤器时存入的是org.springframework.security.core.userdetails.User对象,所以这里可以直接转型。

5.3 方法级权限控制

有时候接口层面的放行和拦截还不够,需要做到方法级别。比如某些接口只允许管理员调用,或者某个业务接口只有特定角色可以用。这时就需要开启方法级权限控制。

在配置类中已经加了@EnableMethodSecurity注解,这之后就可以在方法上使用注解了:

@GetMapping("/admin/user/list") @PreAuthorize("hasRole('ADMIN')") public Result listUser() { return Result.success(userService.listAll()); } @GetMapping("/user/profile") @PreAuthorize("hasAnyRole('ADMIN', 'USER')") public Result profile() { return Result.success(userService.getProfile()); }

hasRole('ADMIN')和hasAuthority('ADMIN')的区别经常有人搞混。简单来说,hasRole内部会自动加上ROLE_前缀去匹配,也就是说数据库或者UserDetails里角色字段存的是ADMIN,用hasRole('ADMIN')就能匹配;如果用hasAuthority,则必须存的是ROLE_ADMIN才匹配得到。所以用哪种方式,取决于你存角色信息的时候带没带前缀,两边保持一致就行。

5.4 Knife4j接口文档在安全体系中的放行配置

现在很多项目都会集成Knife4j来生成接口文档,而Security默认拦截所有请求,如果不对文档相关路径做放行,开发环境想看个接口文档都进不去。

SpringBoot3 + Knife4j的集成依赖如下:

<dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId> <version>4.4.0</version> </dependency>

按照Knife4j的文档,需要放行的路径一般是:

  • /doc.html:文档主页面
  • /webjars/**:webjars资源
  • /v3/api-docs/**:OpenAPI3的JSON数据源
  • /swagger-resources/**:Swagger的资源配置

这些路径就是前面SecurityConfig中permitAll配置里写的那几项。因为Knife4j本身是一个纯前端的静态资源访问,加上OpenAPI数据源的获取,都不涉及业务认证逻辑,所以放行是安全的。但放行仅限测试环境的意识要有,生产环境如果不需要对外暴露文档,还是建议把这几项从permitAll中移除。

6. 常见问题排查与实战填坑记录

6.1 未登录请求返回空白页而不是JSON

这个问题几乎每个刚切换SpringBoot3的人都会遇到。明明配置了authenticationEntryPoint,但请求未登录接口时返回的居然是一段HTML错误页面,而不是自定义的JSON结构。

排查思路是:看看是不是Security自动装配的默认表单登录和HTTP Basic认证还在生效。如果你没有在SecurityFilterChain中明确禁用formLogin和httpBasic,Security会默认启用它们,当请求未认证时,表单登录逻辑会先响应重定向,导致自定义的authenticationEntryPoint没有被调用。

解决办法是在http链路中显式关闭这两个默认机制:

.formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable())

关闭之后,未认证请求才会走我们配置的authenticationEntryPoint,返回401状态码加JSON数据。

6.2 授权头带上了token但还是返回401

这个坑还挺隐蔽的,症状是前端登录正常,请求其他接口也带了Authorization头,但后端始终提示未认证。

排查时我先确认了token本身没有过期,然后检查过滤器中解析token的代码,最后发现原因出在过滤器没有正常执行。什么情况下过滤器不会执行?就是JwtAuthenticationTokenFilter这个类没有被Spring扫描到,比如没有加@Component注解,或者包扫描范围没有覆盖到过滤器所在的位置。

另一种常见情况是拦截到的请求路径没有匹配上配置。比如前端请求的是/api/user/info,但Security配置里放行的是/api/auth/**,而其他路径都需要认证。如果过滤器链整体没生效,所有接口都会变成匿名访问或者直接401,这个要看日志确认过滤器到底执行了没有。建议在过滤器入口加一行日志,这样定位问题快很多。

6.3 BCryptPasswordEncoder导致登录报错"Encoded password does not look like BCrypt"

报错信息本身已经说得很清楚了,框架拿到的password不是有效的BCrypt格式字符串。出现这个问题的场景通常是两种:一是数据库里存的密码是明文,二是工具类中手动生成的密码没有经过BCrypt加密。

解决方案也很直接:重新生成密文。用PasswordEncoder的encode方法为每个人生成新的密文并更新到数据库。这里提醒一下,如果系统里有导入历史数据的功能,密码字段在导入时一定要统一做加密处理,别图方便塞明文,后面登录必定报错。

6.4 SpringBoot3项目启动失败,提示Servlet类不存在

如果项目本身用的是Servlet容器,比如SpringBoot默认的Tomcat,在SpringBoot3下必须使用jakarta.servlet包下的接口。如果你的代码或者第三方依赖里还在引用javax.servlet,启动就会报NoClassDefFoundError。

这种问题大多是依赖版本没对齐造成的。比如一些老的第三方SDK还在javax命名空间下,和SpringBoot3的Tomcat10.1不兼容。解决思路是找到兼容jakarta版本的第三方库切换过来,或者放弃基于Servlet的集成方式,改用WebFlux这类非Servlet技术栈。

6.5 权限注解不生效,方法级拦截没有任何反应

有时候配置好了@PreAuthorize注解,也加了@EnableMethodSecurity,但访问接口时注解就是不生效。这个问题通常出在过滤器链顺序上。如果SecurityFilterChain没有被Spring容器正确加载,HTTP层面的拦截可能还在生效,但方法级安全是由AOP动态代理实现的,两者是不同维度的东西。

排查建议:首先确认SecurityFilterChain是否生效,可以访问一个配置为permitAll的路径看是否正常;然后确认Controller的Bean是否被Spring管理;最后检查@EnableMethodSecurity是否放在了配置类上。这三步走完基本能定位到问题。

6.6 SecurityContext在异步线程中丢失

这个问题在业务比较复杂的项目里很常见:Controller层开了一个异步线程去执行耗时的任务,异步线程里通过SecurityContextHolder.getContext()拿用户信息,发现是null。

原因是SecurityContextHolder默认使用的是ThreadLocal,认证信息只保存在当前线程中。异步线程是另外的线程,自然拿不到主线程里的数据。解决方案有几个,一个是任务的入参显式传递用户信息,另一个是使用SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)让子线程继承主线程的上下文,还有一个是使用DelegatingSecurityContextExecutor包装线程池。

从代码可维护性角度,我更推荐第一种方案,也就是显式传参。用户信息在调用异步任务时作为参数传进去,这样代码结构更清晰,也不会因为线程复用产生上下文串数据的问题。

6.7 前后端联调时的跨域与预检请求

联调阶段Firebug或者浏览器开发者工具里经常会看到CORS报错。这个需要分两层来处理。

第一层是后端要允许跨域,也就是前面讲的CorsConfigurationSource配置。第二层是前端要在请求头中明确标注Content-Type为application/json的POST请求会触发OPTIONS预检。如果OPTIONS请求被Security拦截了,那么真实的POST请求也不会发出。

在Security配置里,OPTIONS请求方法建议直接放行:

.authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() ... )

这里放行OPTIONS不会带来安全问题,因为预检请求本身不会携带业务数据,只是浏览器在正式请求前发送的探测信息。

写在最后的一些个人体会

这套方案我在两个实际项目中完整落地过,第一次花了差不多两天时间调通,第二次半天就搞定了。回头看,最大的障碍其实不是代码本身,而是对Security过滤器链工作机制的理解。一旦搞懂了各个过滤器的职责和顺序,配置类里的每一行代码都有了明确的意义,排查问题时也知道该从哪里下手。

如果按照本文的思路去实践,我建议先不要在旧项目上改,而是开一个最小的SpringBoot3工程,把用户服务、JWT工具、过滤器、配置类依次加进去,跑通一个登录接口再逐步扩展到业务中。小项目上排错成本低,掌握了之后再迁移到老项目就会顺畅很多。另外,生产环境使用JWT时务必配置HTTPS,否则token在传输过程中被劫持的风险依然存在。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 7:47:23

Ryzen AI MAX+395显存分配调优:Windows 11 UMA深度控制指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 7:46:50

电脑监控软件怎么选?从部署方式到核心功能配置的实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 7:45:51

Joplin 端到端加密(E2EE)密文结构与同步快照格式深度解析

Joplin 端到端加密(E2EE)密文结构与同步快照格式深度解析 【免费下载链接】joplin Joplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS. 项目地址: https://gitcode.com/GitHub_Trending/jo/joplin Jopl…

作者头像 李华
网站建设 2026/9/10 7:42:35

实木板材真的环保吗?揭秘甲醛释放与环保等级的真相

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 7:42:26

轻量级AI Agent运行时设计:消息循环与工具调用实战

1. 项目定位与整体设计思路1.1 从“Hermes”这个名字聊起&#xff1a;Agent的本质是替人跑腿“hermes-agent”这名字起得有点意思。Hermes是希腊神话里的信使&#xff0c;职责是在众神之间传递消息、搬运指令。如果你把现代AI Agent拆开看&#xff0c;真正干活的角色其实也就是…

作者头像 李华