拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Security 6 + JWT 前后端分离认证实战:从过滤器链到无状态登录

Spring Security 6 + JWT 前后端分离认证实战:从过滤器链到无状态登录

Spring Security 这块内容我前后折腾了小半个月,从最开始照着老教程写WebSecurityConfigurerAdapter被各种报错劝退,到后面摸清 Spring Security 6 的配置套路,再把 JWT 无状态认证、前后端分离的流程整个跑通,踩过的坑确实不少。这篇教程我尽量把思路和代码串起来讲,不光是"怎么配",更多的是"为什么这么配",这样后面你遇到问题排查起来也有方向。

先说清楚这篇东西适合谁:项目用的是 Spring Boot 3.x + Spring Security 6.x,前端是 Vue/React 这类独立部署的工程,需要做登录认证、接口鉴权,希望接口返回 JSON 而不是跳转页面。如果你是做传统的服务端渲染页面,或者用的是 Spring Boot 2.x,部分写法会有差异,但核心思路是一致的,我也尽量在关键地方把差异标出来。

1. 项目环境与基础准备

1.1 版本选型:Spring Boot 3.x 带来的配置迁移

很多人在 Spring Security 这里卡住,第一个原因就是版本。网上搜出来的教程一大半还在教extends WebSecurityConfigurerAdapter、重写configure(AuthenticationManagerBuilder auth)这种写法,这套在 Spring Boot 3.x + Spring Security 6.x 里面已经完全不适用了,强行写会直接报错或者方法都找不到。

Spring Boot 3.x 基于 Spring Framework 6,Security 版本也升到了 6.x,最大的变化是两个:一是之前那个万能的WebSecurityConfigurerAdapter被彻底废弃移除,官方推荐直接用SecurityFilterChainBean 的方式来声明过滤链;二是很多spring.factories里的自动配置迁移到了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,导致某些老配置项失效,比如spring.security.filter.order这类配置就不再起作用了。

所以如果你的项目是新建的,直接选 Spring Boot 3.x 就好,没必要用 2.x 起步再迁移一遍。一个是我实际用下来的体验,另一个是 3.x 对 GraalVM Native Image 和 Jakarta EE 的支持是以后的大方向,后续升别的依赖也少一些兼容性问题。当然如果你的项目被迫留在 Spring Boot 2.x,那还是得用WebSecurityConfigurerAdapter或者SecurityFilterChain的早期写法,等下我在关键代码旁边会把两版的差异讲一下。

1.2 Maven 依赖引入

基础依赖就两个,一个是 Web,一个是 Security。额外要加 JWT 处理和注解支持的话,下面这个清单可以一起放进去。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- JWT 工具包 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <!-- 方法级权限注解:@PreAuthorize 等 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>

很多人把jjwt-jackson漏掉,结果运行时解析 token 直接报JwtException,因为缺了 JSON 序列化实现。这个坑不大但比较隐蔽,先记一下。

如果你要做数据库用户校验,再加spring-boot-starter-data-jpa或mybatis-plus、mysql-connector-j这几个依赖,这块根据你项目自身的持久层选型来,不是 Security 的核心,我不展开。

2. 认证流程与核心原理拆解

2.1 过滤器链到底是个什么

Spring Security 的核心机制是过滤器链,这一点必须要先理解透,不然后面配置看了会一头雾水。

每个请求进来之后,会按顺序经过一长串过滤器,每个过滤器只负责一件事。比如UsernamePasswordAuthenticationFilter负责从表单登录请求里捞用户名密码然后做认证,ExceptionTranslationFilter负责捕获认证/授权异常并决定是返回 401 还是 403,FilterSecurityInterceptor负责做最终的授权判断。这个设计很像流水线作业——请求就是一块原材料,每个工位(过滤器)加工一道工序,全部通过之后才到达你的 Controller。

Spring Security 6 里,这条链的核心是一个DefaultSecurityFilterChain,你通过SecurityFilterChainBean 配置的其实就是"哪些请求路径走哪些过滤器"。可以存在多个SecurityFilterChainBean 按顺序匹配,不过大多数项目一个就够了。

这里我给你的建议是:先大致认识几个关键过滤器在链上的位置,不需要一次性全背下来。等写完了配置,实际调试中遇到某类问题,再回来看对应的过滤器,理解效率要高很多。

2.2 AuthenticationManager 和 AuthenticationProvider 的关系

很多教程一上来就让你自定义AuthenticationManager,但没说清楚它是干嘛的。我用人话解释一下。

AuthenticationManager是认证的总入口,你问它"这个用户信息是否合法",它不会亲自去查,而是把问题抛给它手下的AuthenticationProvider们,谁有能力处理谁来处理。默认情况下,DaoAuthenticationProvider负责处理用户名密码这种认证方式,它会调用你提供的UserDetailsService去数据库捞用户信息,然后用PasswordEncoder比对密码是否一致。

所以如果你要自定义的只有"从数据库查用户、校验密码"这一步,你只需要干两件事:提供一个UserDetailsService实现,声明一个PasswordEncoderBean。AuthenticationManager会自动装配好,你不需要亲手创建它。只有当你的认证方式很特殊,比如短信验证码、LDAP、OAuth 第三方登录,才需要自定义AuthenticationProvider。

对前后端分离项目来说,绝大多数场景走的是用户名密码 + 数据库校验,所以自定义UserDetailsService就够用了。等到写 JWT 过滤器的时候,你会发现我们并不直接调用AuthenticationManager去认证,而是在登录接口里手动用它,登录取到 token 之后,后续请求靠 token 本身来确认身份,这就是无状态认证的思路,后面具体讲。

2.3 为什么前后端分离要抛弃 Session

说句实在话,Session 不是不能用,但在前后端分离架构下它有几个不好处理的点。

第一个是跨域问题。前端跑在 8080 端口,后端跑在 9090 端口,浏览器里这就是两个源,Session 靠 Cookie 传递,跨域请求要把withCredentials打开,后端还得配合把allow-credentials配好。CORS 加 Cookie 的组合很容易踩到边界情况,比如前端没配 axios 的withCredentials: true,或者后端allowedOrigin写成了*(这种情况浏览器会直接拒绝携带 Cookie)。

第二个问题是多端适配。同一个后端,可能要给 Web 管理端、移动 App、小程序提供接口。App 和小程序的网络库对 Cookie 的支持参差不齐,处理起来很别扭。而 token 放请求头里,全端通用。

第三个问题是服务端状态分散。Session 默认存内存里,一旦部署多实例,就得引入 Redis Session 共享之类的东西。JWT 本身是无状态的,服务端不需要存会话,天然适合水平扩容。

基于这几点,前后端分离 + JWT 算是当前最主流的方案,也是这篇教程采用的思路。

3. JWT 认证流程设计与对比分析

3.1 一次性讲透 JWT 的构成

JWT 是一串形如eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.abc123的字符串,由三部分拼起来,中间用点隔开:Header 和 Payload 只做 Base64 编码,任何人都能解开看内容,所以敏感信息不能往里放;真正保证防篡改的,是第三段——签名。

签名是用 Header 里声明的算法,把前两段内容加上一个服务端私有的密钥一起计算出来的。密钥只有服务端知道,所以如果有人偷改 Payload 里的用户名权限,签名就对不上,服务端一验就发现 token 被动了手脚。这就是 JWT"可信任"的基础。

结合实际场景,我给出两个实操注意点。第一,密钥不要写死在代码里,放到application.yml环境配置或环境变量中;越是多人协作的仓库越要注意。第二,token 里不要放密码、手机号这类敏感字段,因为 Payload 只是 Base64 编码,不是加密,抓到 token 的人把它拆开就能看到内容。

3.2 无状态认证的完整时序

用 JWT 做认证,整个流程可以概括成"一登二带三验"。

用户拿用户名密码请求/auth/login,服务端校验成功后签发一个 JWT 返回给前端。前端拿到 token 之后存起来(一般是localStorage或Pinia/Vuex),之后每次发请求都在Authorization请求头里带Bearer ${token}。后端除了登录接口和少数白名单接口不放行,其余接口都走JwtAuthenticationFilter,这个过滤器把 token 解出来、验签、解析用户信息,塞进SecurityContext里。后续的授权判断就从SecurityContext里拿当前用户身份去比对权限。

这个模式的精髓在于服务端不存"这个用户是否已登录"这类状态,每次请求自带身份证明,服务端只验证证明的真伪,所以叫无状态。

3.3 Spring Security 6 中配置迁移的关键变化

如果你之前只看过旧版教程,这里给你列一个变化清单,新项目直接按新版写即可:

旧版写法(Security 5)新版写法(Security 6)
继承WebSecurityConfigurerAdapter定义SecurityFilterChainBean
http.csrf().disable()链式调用http.csrf(AbstractHttpConfigurer::disable)风格
WebSecurityConfigurerAdapter里重写configure(AuthenticationManagerBuilder)直接注入AuthenticationManager
antMatchers(...)requestMatchers(...)
.and()连接配置去掉.and(),全部用 lambda 表达风格

新版这种写法适应了 Java 函数式编程的风格,也让多SecurityFilterChain的组装更自然。刚开始觉得不习惯,写两遍就好了。

4. 核心配置与代码实现

4.1 SecurityConfig 配置类

配置类是整个安全体系的中枢。下面是我在实际项目中使用的配置,注释标得比较全,你可以直接对照着"抄作业",然后再按自己的需要调整细节。

@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Resource private JwtAuthenticationFilter jwtAuthenticationFilter; @Resource private RestAuthenticationEntryPoint authenticationEntryPoint; @Resource private RestAccessDeniedHandler accessDeniedHandler; /** * 密码加密器,BCrypt 是 Spring 推荐的标准实现 */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 前后端分离项目,SSR 场景不需要 .csrf(AbstractHttpConfigurer::disable) // 使用无状态会话,不在服务端保存 Session .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth -> auth // 登录、注册、验证码等接口放行 .requestMatchers("/auth/login", "/auth/register", "/error").permitAll() // 需要 ADMIN 角色的接口 .requestMatchers("/admin/**").hasRole("ADMIN") // 其余所有请求都需要认证 .anyRequest().authenticated() ) // 自定义 JWT 过滤器,在 UsernamePasswordAuthenticationFilter 之前 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // 认证异常和授权异常的自定义返回 .exceptionHandling(exception -> exception .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) ); return http.build(); } }

@EnableMethodSecurity是新版替代@EnableGlobalMethodSecurity的注解,开启后就能在 Controller 上写@PreAuthorize("hasRole('ADMIN')")这类方法级权限控制。如果你的项目里只有接口级权限控制,这个注解可以不写,但绝大多数管理系统都会用到方法级权限细分,建议保留。

关于requestMatchers的匹配规则,写的时候确实容易混:/admin/**只匹配/admin开头的路径,不匹配/administrator这种;如果你用hasRole("ADMIN"),那用户授权信息里必须带ROLE_ADMIN这个权限标识,ROLE_前缀是框架自动拼接的,开发的时候可以打印一下当前用户权限核对。我一开始写的时候,数据库里直接存了ADMIN而不是ROLE_ADMIN,结果hasRole一直不通过,查了好一会儿才发现是前缀的问题。

4.2 自定义 UserDetailsService 与用户模型

@Service public class UserDetailsServiceImpl 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("用户不存在"); } // 查询用户角色列表,比如 ["ADMIN", "USER"] List<String> roles = userMapper.selectRolesByUserId(user.getId()); // 权限标识可以直接用角色名,也可以细粒度到具体权限点 List<SimpleGrantedAuthority> authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getStatus() == 1, true, true, true, authorities ); } }

这里有几个细节要提醒。密码字段必须是从数据库取出来的加密后的密文,不能是明文,否则PasswordEncoder比对永远失败。账号是否启用的布尔值,建议直接从用户状态字段映射,而不是写死true,这样封禁功能后面可以复用。

返回的这个UserDetails对象最终会存进SecurityContext,后面在业务代码里通过SecurityContextHolder.getContext().getAuthentication()拿到的就是它。

注意:如果用户同时有多个角色,用ROLE_前缀统一格式化,这样配置层的hasRole("ADMIN")和方法层的hasRole('ADMIN')才能统一识别。这点越早约定好,后面越省心。

4.3 JwtUtils 工具类

JWT 的产生和验证我集中放在一个工具类里,方便复用,密钥和过期时间从配置读取。

@Component public class JwtUtils { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-time}") private Long expireTime; private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } /** * 生成 token,subject 存用户名,额外存用户ID和角色 */ public String generateToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); claims.put("username", userDetails.getUsername()); claims.put("roles", userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); return Jwts.builder() .setClaims(claims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expireTime)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } /** * 解析 token,如果签名不对或过期,会抛出 JwtException */ public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } /** * 从请求头提取 token */ public String resolveToken(HttpServletRequest request) { String bearer = request.getHeader("Authorization"); if (bearer != null && bearer.startsWith("Bearer ")) { return bearer.substring(7); } return null; } }

密钥长度要注意一下。HS256 算法要求密钥长度至少 256 位,也就是 32 个字节。如果你在配置文件里写了一个很短的 key,比如jwt.secret=mysecret,启动时可能不报错,但实际生成或解析 token 时hmacShaKeyFor会直接抛WeakKeyException。最稳妥的办法是用openssl rand -base64 64这类工具生成一个随机长字符串放到配置里。

如果要额外控制 token 的失效周期,比如"登录后 7 天有效",把expire-time单位统一成毫秒即可,例如86400000表示 24 小时。业务上还有需求要过期自动续期的话,可以再写一个刷新 token 接口,但会增加复杂度,前期不建议一上来就做。

4.4 JwtAuthenticationFilter 实现

这个过滤器是整个链路的最后一环,负责把"有 token 的请求"转化为"已认证的请求"。

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Resource private JwtUtils jwtUtils; @Resource private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = jwtUtils.resolveToken(request); // token 非空且当前 SecurityContext 尚无认证信息时,做认证 if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { try { Claims claims = jwtUtils.parseToken(token); String username = claims.get("username", String.class); if (username != null) { // 重新加载用户,拿到最新的角色权限;用户被删了或禁用,这里会抛异常 UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); // 额外记录一下当前请求的详情,方便审计日志之类的场景 authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (JwtException | IllegalArgumentException e) { // token 无效或过期,不设置认证信息,后续会被 EntryPoint 拦下返回 401 logger.debug("Invalid JWT token: " + e.getMessage()); } } filterChain.doFilter(request, response); } }

这个过滤器里我要强调一个很关键的小细节:解析完 token 里的用户名之后,不要直接根据claims里的角色信息构造认证对象,而是再调一次userDetailsService.loadUserByUsername(username)从数据库拉取最新用户信息。

为什么?因为用户可能在你签发 token 之后被删了、被禁用了、或者角色被调整了。如果你完全信任 token 里的旧数据,这个用户在你的系统里就永远"删不掉"了——改了数据库权限也不生效,他手上的 token 依然能访问。虽然每次请求多一次数据库查询,但对后台管理系统来说,安全优先级远高于这点性能开销。如果以后要优化,可以引入短时缓存(比如 5 分钟)或用 Redis 做二级存储,但不要去信任 token 里的静态权限。

4.5 登录接口与前端的联调闭环

登录接口本身不需要任何 Security 的复杂配置,就是一个普通@RestController,核心是我们手动调用AuthenticationManager。

@RestController @RequestMapping("/auth") public class AuthController { @Resource private AuthenticationManager authenticationManager; @Resource private JwtUtils jwtUtils; @Resource private UserDetailsService userDetailsService; @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) { // 这里会触发 UserDetailsService.loadUserByUsername 和密码比对 Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 认证通过 UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String token = jwtUtils.generateToken(userDetails); return ResponseEntity.ok(new LoginResponse(token, userDetails.getUsername())); } @GetMapping("/me") public ResponseEntity<?> me(Authentication authentication) { // 只要走到了这里,说明 JWT 过滤器已经认证通过 UserDetails userDetails = (UserDetails) authentication.getPrincipal(); return ResponseEntity.ok(userDetails.getUsername()); } @PostMapping("/logout") public ResponseEntity<?> logout() { // 无状态模式下,服务端无需处理,前端丢弃 token 即可 return ResponseEntity.ok("logged out"); } }

目前这套设计里,登录接口默认是放行的,因为SecurityConfig里已经permitAll()了/auth/login。如果借鉴开源项目,可以加一个登录失败次数的校验,比如 5 次失败锁账号,这块可以在AuthenticationManager前面加业务代码做。

前端异步请求时,需要带上Authorization请求头。以 axios 为例,拦截器里统一设置即可:

axios.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) axios.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { // token 过期,清空本地状态并跳转登录页 localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(error) } )

提示:有些同学会把 token 放在localStorage,有些放在 cookie。放在localStorage的问题是 XSS 脚本可以读到它,放在 cookie 里要处理 CSRF 和withCredentials。没有绝对安全的选择,核心原则是项目里一定要做输入过滤和输出转义,不然 token 存哪儿都不安全。

5. 异常处理与权限控制细化

5.1 认证失败与权限不足的统一 JSON 返回

Spring Security 默认的异常处理对前后端分离项目极为不友好——未登录时它可能返回重定向到/login页面,或者返回一小段纯文本。我们需要自己接管,让它在接口场景下返回application/json格式的错误信息。

关键是定义两个组件。AuthenticationEntryPoint处理的是"未认证"情况,比如没带 token、token 过期失效;AccessDeniedHandler处理的是"已认证但没权限"情况,比如普通用户访问了管理员接口。

@Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未认证或登录已过期\"}"); } }
@Component public class RestAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"message\":\"没有权限访问该资源\"}"); } }

这两个类不需要加@RestControllerAdvice之类的全局异常注解,因为它们是 Spring Security 过滤器链内部的处理器,配置到exceptionHandling里才会被调用。这里顺手把 401 和 403 的语义区分讲清楚:401 是身份问题——"我不知道你是谁";403 是权限问题——"我知道你是谁,但你没有资格"。前端拦截器一般只对 401 做强制跳登录,403 通常是提示无权限。

5.2 方法级权限注解用法

接口级权限用SecurityConfig里的requestMatchers能覆盖大部分场景。但真实项目里经常有这种需求:同一份资源列表,普通用户只能看自己创建的,管理员能看全部;同一个删除接口,只有拥有"删除任务"权限点的用户才能调用。这种细粒度控制,接口级配置写起来会很笨拙,方法级注解就好使了。

开启方式前面已经写过:@EnableMethodSecurity。之后在 Service 或 Controller 方法上直接加注解:

@GetMapping("/admin/users") @PreAuthorize("hasRole('ADMIN')") public List<UserVo> listUsers() { ... } @PostMapping("/tasks/{id}/delete") @PreAuthorize("hasPermission(#id, 'TASK', 'delete')") public void deleteTask(@PathVariable Long id) { ... }

hasRole('ADMIN')要求当前用户拥有ROLE_ADMIN;hasAuthority('xxx')是精确匹配权限标识,不自动加前缀;hasAnyRole('ADMIN', 'MANAGER')是多角色其一即可;hasPermission这类更高级的用法需要自定义PermissionEvaluator,一般项目用不上,知道有这回事就行。

实际项目里我的建议是这样划分:涉及整个资源模块的粗粒度控制,放到SecurityConfig的requestMatchers里;单个方法内的细粒度校验,用@PreAuthorize写在方法上。这样配置和代码都不至于太分散,也好排查。

5.3 获取当前登录用户信息的统一封装

业务代码里经常要拿到"当前登录的人是谁",最直接的写法是:

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

但每个方法都这样写太啰嗦。我一般在代码里封装一个工具类,统一从SecurityContext提取当前用户信息,比如把它封装成当前项目的LoginUser对象,Service 层直接调用:

public class SecurityUtils { public static LoginUser getLoginUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser)) { // 走到这里说明 filter 没有正确设置认证信息,需要排查 throw new RuntimeException("当前无登录用户"); } return (LoginUser) authentication.getPrincipal(); } public static Long getUserId() { return getLoginUser().getId(); } }

这里有个前提:JWT 过滤器里设置Authentication时,principal必须是你的自定义LoginUser对象,而不是 default 的 SpringUser。建议把项目里的用户实体设计成实现UserDetails接口的LoginUser,这样从数据库加载和从 SecurityContext 获取的是同一个类型,省掉一层转换。

6. 常见问题排查与避坑记录

6.1 401/403 高频成因排查表

我把前后端分离项目中遇到过的认证授权问题整理成了速查表,之前带的新人照着这个排查,命中率很高。

现象大概率原因排查方向
登录接口直接 403CSRF 没关闭确认配置了csrf(AbstractHttpConfigurer::disable)
登录成功但后续请求全部 401前端没带Authorization头看浏览器 Network 请求头,是否出现Bearer xxx
请求头带了 token 还是 401JWT 解析失败或过期先在没有 Security 的接口里打印原始 token,单独用JwtUtils.parseToken测试;对照服务端时间是否同步
hasRole一直不通过权限标识缺ROLE_前缀打印authentication.getAuthorities()检查实际值
放行接口仍被拦截requestMatchers路径写错或顺序不对permitAll的路径要放在anyRequest之前,匹配规则用/**结尾
静态资源(图片/JS)无法访问没给静态资源路径放行或放行顺序有问题用requestMatchers("/static/**", "/*.js", "/*.css").permitAll()显式声明
出现无休止重定向到/login过滤器链拦截了你的接口且 session 模式不是 STATELESS把sessionCreationPolicy设置为STATELESS
Failed to process URI之类的跨域错误CORS 配置和请求头不一致后端allowedOriginPatterns不能是*,且要前端显式声明 Origin
启动报WeakKeyExceptionJWT 密钥太短检查jwt.secret长度是否超过 32 字节

这里特别展开说下 CORS。前后端分离项目跨域问题几乎必现,Spring Security 的 CORS 配置要在 Security 过滤器链里显式声明,单独在 WebMvc 里配有时候不生效,因为 Security 的过滤器优先级高于 Spring MVC。

@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(Arrays.asList("http://localhost:*", "https://your-domain.com")); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(Collections.singletonList("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; }

然后在SecurityConfig的http链上加上.cors(cors -> cors.configurationSource(corsConfigurationSource()))。

allowedOriginPatterns和allowedOrigins的区别是前者支持通配符,后者不支持。如果你用了allowCredentials(true),allowedOrigins就不能写*,这是浏览器的硬性限制,不是 Spring 的限制。OPTIONS预检请求也必须在允许的方法列表里,否则前端预检直接失败,实际业务请求发不出去。

6.2 调试工具与断点技巧

排查 Security 问题,用好调试工具能省一半时间。

第一个是日志级别调整。在application.yml里临时加一段配置:

logging: level: org.springframework.security: DEBUG

然后启动项目访问一个受保护接口,控制台会打印整个过滤器链的执行顺序,以及是哪个过滤器拦截了请求,返回了什么状态码。我看过最多的场景是:日志里显示"FilterInvocation denied because ..."就跟着往下排查具体原因,效率极高。

第二个是直接观察SecurityContext的内容。在过滤器或 Controller 里加一个临时断点或者输出语句,打印SecurityContextHolder.getContext().getAuthentication()及其authorities,一眼就能发现问题出在"根本没认证"还是"认证了但权限不对"。

第三个是写单元测试。Spring Security 提供了@WithMockUser注解,可以快速在测试方法里模拟一个用户,不用真的请求登录接口。

@SpringBootTest @AutoConfigureMockMvc public class SecurityTest { @Test @WithMockUser(username = "admin", roles = {"ADMIN"}) public void adminEndpointShouldReturnOk() throws Exception { mockMvc.perform(get("/admin/users")) .andExpect(status().isOk()); } @Test public void anonymousShouldBeRejected() throws Exception { mockMvc.perform(get("/admin/users")) .andExpect(status().isUnauthorized()); } }

@WithMockUser不经过真实认证流程,适合测试授权规则;如果要测试登录接口的完整链路,还是要手动调用authenticationManager.authenticate或直接发 HTTP 请求。

6.3 热搜问题选用:Spring Boot 3 配置迁移细节

师出同门,这里多说一句 Spring Boot 3 中 Security 配置迁移的关键差异。如果你是从旧项目升级,大概率会碰到spring.security.filter.order这个配置失效的问题,因为在 Spring Boot 3 里 Security 过滤链的排序改由SecurityProperties.DEFAULT_FILTER_ORDER内部管理,你原来的显式排序配置需要移除或改用SecurityFilterChainBean 的@Order注解。

另外一点是,如果自定义过滤器里依赖了其他 Bean,并且你把它注册成了普通FilterBean,那它可能会被 Spring Boot 默认注册到全局过滤器链里,导致同一个过滤器执行两遍。解决办法是不要给它加@Component注解,只通过addFilterBefore注册到 Security 的链上,这样它只走 Security 那套流程,避免在 Servlet 容器层面也执行一次。

6.4 一个真实的踩坑案例

最后讲一个实际遇到过的非常典型的坑。有一个接口需要导出 Excel 文件,前端通过window.open('/export/user')直接发起 GET 请求——这个请求没法在window.open里手动加Authorization头,于是后台一直 401。

一开始我先放行了/export/**,但这样等于把导出接口暴露为匿名可访问,安全隐患很大。后来换了一种思路:把 token 作为 query 参数传过去,在JwtAuthenticationFilter里取不到Authorization头时,再尝试从request.getParameter("token")里取一次。这种方案要在产品层面充分评估风险,因为 query 参数会被记录到各种访问日志、反向代理日志里,token 泄露风险明显高于请求头。实在要支持window.open场景,可以对这种单次下载类的 token 做短时效处理,比如 5 分钟有效,用完即作废。

从这个案例引出的经验是:任何安全方案都是在便利性、风险、成本之间做权衡,没有银弹。作为开发人员,理解了过滤链和 token 机制之后,再针对场景设计对应的折中方案,比死记硬背"必须怎么配"要可靠得多。

7. 个人经验总结与后续优化方向

这一节写得短一些,我想聊聊这套方案在实际项目落地后关于性能和安全的一些体会。

先说性能。JWT 无状态确实带来了扩容的便利,但也把"用户状态"的存储压力转移到了 token 本身。每次请求都要验签、解析、查数据库加载用户,在高并发场景下数据库查询压力会逐渐显现。我当时处理的办法是给UserDetailsService加了 5 分钟的本地缓存,只缓存有效用户的 ID、用户名、角色列表,不缓存密码等敏感字段。注意这里不能缓存禁用状态,因为账号封禁需要立即生效。

再谈安全。网上很多帖子推荐" Access Token 有效期 2 小时 + Refresh Token 7 天"的双 token 方案,这个设计确实能平衡安全和体验,但实现复杂度不低。做之前先想清楚一个哲学问题:你的项目到底服务谁。内部管理系统并发低、用户少,Access Token 直接搞 12 小时甚至 24 小时都行,出问题能忍;面向 C 端的项目,token 泄露带来的风险高,双 token 的成本才值得付。

我在多个项目里反复验证过的结论是:JWT 这套方案真正的护城河不是 token 本身,而是对 SecurityContext 和过滤器链的准确理解。很多网上复制粘贴的代码能跑,但一旦出现定制需求,比如多端登录互斥、刷新 token、强制下线,理解不深就会无从下手。

这份教程写到这,基本上把 Spring Security 6 在前后端分离项目里的核心链路都过了一遍。从过滤器链、认证流程,到 JWT 设计、SecurityConfig 配置、异常处理器、CORS 联调,再到排查问题的方法论,每一块都对应一个真实的开发阶段。如果你能跟着代码完整跑一遍,再结合自己的业务改一改,这套东西就算真正是你的了。后面有时间的话,我再写一篇针对 Redis 做 token 黑名单和在线用户管理的进阶版,算是这个方案的下一步演进。

返回列表