ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Security前后端分离实战:JWT认证与过滤器链详解

Spring Security前后端分离实战:JWT认证与过滤器链详解

前后端分离项目里接入 Spring Security,最难的不是配置,而是理解它的设计思路。我最早接手一个电商后台管理系统的登录认证模块时,项目技术栈是 Spring Boot 3 + Vue 3,登录态选型定为 JWT。刚开始照着网上的教程写配置,结果跑起来全是 403、跨域拦截、token 解析不到各种问题。后来把 Spring Security 的过滤器链、认证流程彻底理清楚之后,才发现大部分坑都是在同一个点上:还没有真正理解 Security 的“请求处理链条”,就急着去写代码。

这篇文章我会围绕“怎么把 Spring Security 正确应用到前后端分离项目”这个主题,把我当时的选型思路、核心实现、权限设计、踩坑复盘完整讲一遍。适合正在做前后端分离项目、对 Spring Security 停留在“配过但没跑通”阶段的开发者参考,也能帮准备从 Session 认证迁移到 JWT 的团队节省不少排查时间。

1. 为什么前后端分离项目重写认证逻辑几乎是必然的

Spring Security 默认的认证行为,完全是围绕“服务端渲染页面”这个场景设计的。你要用前后端分离的思路去套它,第一反应肯定是“各种别扭”。理解了这个底层差异,后面所有配置改动才说得通。

1.1 从 Session 认证说起:为什么浏览器模型拯救不了移动端

早期 Web 应用是典型的同源场景:浏览器访问一个域名下的页面,用户名密码提交给服务端,服务端校验通过后把用户信息写到 Session,再往浏览器种一个 JSESSIONID Cookie。后续请求浏览器自动携带 Cookie,服务端根据 Session 里的登录态判断当前用户是谁。

这套机制在单体服务端渲染时代没什么问题,但前后端分离之后至少会遇到四个麻烦:

  1. 前端是 SPA 应用或原生 App,它不关心“页面从哪渲染”,只关心服务端 API 返回的 JSON。
  2. 跨域场景下 Cookie 携带非常麻烦。前端服务、后端服务可能分属两个域名,浏览器对跨域 Cookie 的限制越来越多。
  3. Session 存储在服务端,多实例部署时需要引入 Redis 共享,否则用户的请求落到另一台实例就判定未登录。
  4. 移动端根本没有浏览器 Cookie 的概念,你不可能要求 App 客户端维护一个 JSESSIONID。

所以前后端分离项目里,绝大多数团队都会选择“无状态 Token”方案,把登录态从服务端 Session 挪到客户端自己保存的 token 里。

1.2 Token 认证与 JWT 选型:不是什么场景都该上 JWT

Token 方案又分两种主流做法,我当时做了一个对比:

维度JWT 无状态 Token随机 Token 存 Redis
服务端存储不需要需要 Redis 或内存存储
登录态校验验签 + 解析 Payload查 Redis
强制下线/踢人难,需黑名单直接删 Key 即可
跨端适用性好,适合 App、SPA好
实现复杂度中低
微服务共享天然支持,网关验签需共享 Redis

很多教程上来就推荐 JWT,但实际项目中如果用户被踢下线的诉求很强,JWT 的“无状态”反而成了劣势。我当时选 JWT 的原因很现实:后台管理系统面向内部运营人员,token 过期时间控制在 2 小时,对“强制下线”的诉求不高;而且后面可能要把登录态校验逻辑下沉到网关统一处理,JWT 不需要每个下游服务都查一次 Redis。

如果你做的是用户量大、需要精确控制登录态的系统,我个人更建议随机 token 存 Redis,别被 JWT 的“高大上”带偏。这个决定会影响后面一整条链路的设计。

1.3 落到代码之前的三大改造范围

Spring Security 默认的登录页、Session、CSRF 这些机制,在前后端分离 + JWT 场景下基本都要重写。你至少要改动四件事:

  1. 关闭默认的表单登录、HTTP Basic 登录方式。
  2. 关闭 Session 管理,设置SessionCreationPolicy.STATELESS。
  3. 关闭 CSRF 防御。因为不使用 Cookie 保存登录态,CSRF 攻击的前提就不成立了。
  4. 自定义返回 401、403 的 JSON 结构,而不是让 Security 返回默认的错误页。

这四件事是“地基”,地基打不好,后面的 JWT 过滤器、权限注解都会挂。我见过不少项目在旧写法WebSecurityConfigurerAdapter的思路上打转,实际上 Spring Security 5.7 之后官方已经废弃了这个基类,新项目必须用组件化的SecurityFilterChainBean 写法。

2. 读懂 Security 过滤器链,95% 的配置问题都出在这里

如果你只记住“怎么配”而不知道“为什么这样配”,一旦遇到 Security 相关的问题就会非常被动。Spring Security 本质上是构建在 Servlet 过滤器之上的一套大型过滤器链框架,理解了链,就理解了整个认证授权流程。

2.1 过滤器链是怎么串起来的

可以把它想象成机场安检通道:乘客(HTTP 请求)进入通道后,要过一个又一个检查点(过滤器)。有的检查点负责确认你有没有登机牌(未登录就让你去登录),有的负责盖放行章(授权检查),有的专门处理免检 VIP 通道(放行白名单)。

默认的过滤器链中,和我们关系最大的几个过滤器大致是:

过滤器或组件职责和我们项目的关系
SecurityContextPersistenceFilter请求开始时从上下文中加载认证信息,结束时保存无状态场景下由它维持当前请求的安全上下文
HeaderWriterFilter写安全响应头基本不用管
CorsFilter处理跨域前后端分离项目必须配置
CsrfFilterCSRF 防护无状态 JWT 下建议关闭
UsernamePasswordAuthenticationFilter处理表单登录请求前后端分离下基本不用它
FilterSecurityInterceptor访问控制决策核心授权逻辑靠它判断
ExceptionTranslationFilter捕获认证/授权异常并转发给入口点负责把 401/403 转成 JSON

配置http.formLogin()、http.authorizeHttpRequests()这些 API,本质就是在往默认过滤器链里装配、挪动、删减过滤器。所以当你发现“登录页怎么自己跳出来了”“为什么 401 变成了 403”时,不要先去怀疑逻辑代码,先查链上发生了什么。

2.2 加 JWT 过滤器:位置错了,全盘皆输

我们做 JWT 认证时,不会用默认的UsernamePasswordAuthenticationFilter,而是在过滤器链的某个位置插入一个自定义JwtAuthenticationFilter。

关键问题:它到底该加在哪?答案是在UsernamePasswordAuthenticationFilter之前。

因为过滤器链是从前往后执行的。授权过滤器(FilterSecurityInterceptor)在链的后面,它检查SecurityContext里有没有 Authentication 对象,再决定能不能放行。如果我们的过滤器加在授权检查之后,那授权过滤器已经判定“匿名用户”了,后面你再去解析 token、塞 Authentication 都没意义。

当时我做第一版的时候,因为没搞清楚顺序,直接把 JWT 过滤器加在了链的末尾,结果每个接口都返回 403。排查了很久才发现是顺序问题——所以请你一定要记住:自定义 JWT 过滤器在UsernamePasswordAuthenticationFilter前面。

2.3 一个能直接跑的 Security 配置骨架

以 Spring Boot 3 + Spring Security 6 为例,基础配置长这样:

@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .anyRequest().authenticated() ) .exceptionHandling(ex -> ex .authenticationEntryPoint(restAuthEntryPoint) .accessDeniedHandler(restAccessDeniedHandler) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }

注意几个细节:

  • csrf关闭的原因前面说过,前提是你的 token 不走 Cookie。如果前端非要把 token 存进 Cookie,CSRF 防护就不能关。
  • .sessionManagement()设成STATELESS,同时也就意味着服务端不会再创建 HttpSession。
  • .cors()需要提供一个CorsConfigurationSourceBean,后面讲跨域时会细说。
  • .addFilterBefore(...)这个 API 就是“把自定义过滤器加到某个过滤器前面”,这一行决定整个认证链的成败。

3. 核心实现:JWT 登录、Token 解析与无状态会话配置

配置骨架搭好之后,接下来就是把认证逻辑真正写出来。这个过程涉及四个关键类:UserDetailsService、JWT 工具类、登录控制器、自定义认证过滤器。

3.1 定制 UserDetailsService:用户从哪来

Spring Security 定义了一个统一接口UserDetailsService,它只有一个方法:根据用户名加载用户信息。Security 在做认证时,会拿它查出来的用户信息去比对密码。

@Service public class AppUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public AppUserDetailsService(UserMapper userMapper) { this.userMapper = userMapper; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserDO user = userMapper.selectByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } List<String> permissions = userMapper.selectPermissionsByUserId(user.getId()); List<SimpleGrantedAuthority> authorities = permissions.stream() .map(SimpleGrantedAuthority::new) .toList(); return new User(user.getUsername(), user.getPassword(), authorities); } }

这里有几个容易被忽略的点:

  1. 返回的UserDetails里的 password 是数据库中存储的加密后的密码,不能是明文。
  2. 权限集合不要只放角色名,最好把“权限标识”也放进去。比如后台系统的菜单权限、按钮权限都能映射成一个个权限字符串。
  3. 不要把业务字段塞进UserDetails里,保持它的职责单一。业务信息可以通过后面的 JWT payload 或单独查询获取。

3.2 JWT 工具类与认证过滤器:解析 Token 的正确姿势

JWT 工具类负责生成 token 和解析 token。我当时用的是 JJWT 库,HS256 签名,单服务内部够用。如果后面做微服务,建议换成 RSA(RS256),各服务用公钥验签。

@Component public class JwtUtil { private final SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); public String generateToken(String username, Long userId, Collection<? extends GrantedAuthority> authorities) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("authorities", authorities.stream() .map(GrantedAuthority::getAuthority) .toList()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expireTime)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody(); } }

接下来是认证过滤器。它做的事情很简单:从请求头里拿出 token,验签,解析出用户身份,然后构建一个 Authentication 放进SecurityContextHolder。

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final AppUserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { Claims claims = jwtUtil.parseToken(token); String username = claims.getSubject(); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception ex) { // token 无效或过期,这里不要抛异常,让请求继续走 // 后面授权过滤器会判定未认证,再由 AuthenticationEntryPoint 返回统一 JSON SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } }

这里我踩过一个坑:一开始在catch块里直接 throw RuntimeException,结果所有带无效 token 的请求都返回 500。合理的处理方式是:解析失败不要中断请求链,把它当匿名请求处理,最后统一走 401 逻辑。这样客户端看到的错误信息也是可控的统一 JSON。

可能你会问,为什么继承OncePerRequestFilter而不是普通Filter?因为 Servlet 容器在 forward 和 include 场景下,过滤器可能被调用多次。OncePerRequestFilter能保证同一个请求内过滤器只执行一次,避免重复解析 token、重复设置上下文。

3.3 登录接口:用 AuthenticationManager 而不是自己写密码校验

登录接口我们不直接在控制器里查数据库比对密码,而是交给AuthenticationManager.authenticate()完成校验。Spring Security 会自己调用UserDetailsService拿到用户信息,再用PasswordEncoder比对密码。

@RestController @RequestMapping("/api/auth") public class AuthController { private final AuthenticationManager authenticationManager; private final JwtUtil jwtUtil; @PostMapping("/login") public Result<LoginResponse> login(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword())); UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String accessToken = jwtUtil.generateToken( userDetails.getUsername(), currentUserId, // 根据实际情况从 UserDetails 或缓存中取得 userDetails.getAuthorities()); return Result.ok(new LoginResponse(accessToken)); } }

注意AuthenticationManager并不是自动暴露为 Bean 的。Spring Security 6 里需要自己声明:

@Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }

如果你不加这个 Bean,直接@Autowired进来会启动失败。

登录成功后,一般还需要把 Refresh Token 一并返回,让前端在 access token 过期时静默刷新。这里篇幅有限不展开,但提醒一句:refresh token 不要放在 JWT payload 里存永久有效期,建议单独生成、服务端保存或至少带一个很短的过期时间。

3.4 无状态会话配置与 CORS 联动

无状态配置我们已经在 SecurityConfig 里写了SessionCreationPolicy.STATELESS,这一步会让 Spring Security 不再创建 HttpSession,也会让SecurityContextHolder的策略变为每个请求独立。

跨域配置要写一个CorsConfigurationSourceBean:

@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; }

如果你用allowCredentials(true),那么addAllowedOrigin("*")会出问题,必须用addAllowedOriginPattern("*")。这是早期我踩过的一个相当隐蔽的错误,浏览器直接报 CORS 错误。

有一点再强调:CORS 过滤器必须在 JWT 过滤器前面跑,否则预检的 OPTIONS 请求带着 token 请求头也会被安全链路处理。Security 内部已经帮我们处理了 CorsFilter 的位置,前提是你确实调用了.cors(...)并注入了配置源。

4. 权限控制实战:方法注解、动态权限与角色模型细节

认证解决的是“你是谁”,授权解决的是“你能干什么”。前后端分离项目里,接口权限不放在页面控制,必须放在服务端做硬校验。

4.1 基于注解的接口级权限控制

在配置类上加@EnableMethodSecurity之后,就可以在方法上使用@PreAuthorize:

@RestController @RequestMapping("/api/order") public class OrderController { @GetMapping("/list") @PreAuthorize("hasAnyAuthority('order:list', 'order:export')") public Result<PageResult<OrderVO>> list(PageQuery pageQuery) { return Result.ok(orderService.page(pageQuery)); } @DeleteMapping("/{id}") @PreAuthorize("hasAuthority('order:delete')") public Result<Void> delete(@PathVariable Long id) { orderService.delete(id); return Result.ok(); } }

权限字符串我建议统一用“资源:操作”的格式,比如order:list、order:delete。相比角色(ROLE_ADMIN),权限标识更加精细,也方便后续做菜单/按钮的动态控制。

为什么要用@PreAuthorize而不是直接在SecurityFilterChain里用 URL 配置?在前后端分离项目中,接口路径和业务模块之间很可能不是一一对应关系,方法注解粒度更细、更贴近业务,也更容易在测试时看到具体权限需求。URL 级配置更适合做“精确到路径”的防火墙规则。

4.2 登录用户身份怎么拿

权限注解只能控制“能不能访问”,业务代码里还需要拿到“当前是谁”。最直接的取法是:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

更优雅一点的做法是用@AuthenticationPrincipal注入UserDetails:

@GetMapping("/me") public Result<UserInfoVO> me(@AuthenticationPrincipal UserDetails userDetails) { String username = userDetails.getUsername(); // 你还可以通过 username 或自定义参数再去查用户详情 return Result.ok(userService.getProfile(username)); }

如果你需要拿到 userId 这类业务字段,一个思路是写一个HandlerMethodArgumentResolver,从 SecurityContext 里取出当前登录信息,解析成自己的LoginUser对象注入控制器方法。这样做的好处是控制器不再依赖 Spring Security 的 API,业务代码更干净。

4.3 动态权限与数据权限的简单方案

方法注解适合固定权限,但在实际系统中经常有动态权限。比如运营后台里“某个用户拥有某些商品的管理权”,这可能是动态配置的。

我的做法是:在登录时把用户权限完整加载进UserDetails.getAuthorities()。如果你的权限数据不多变更、用户量不大,登录时加载完全够用,也最简单。

如果权限变更频繁,建议每次请求只从 JWT 里解析 userId,再通过 Redis 缓存查询该用户的最新权限,用最新权限构建 Authentication。这个方案既保留了 JWT 的跨服务优势,又解决了 JWT “权限变更不生效”的核心痛点。

至于数据权限(比如当前用户只能看自己部门的订单),简单场景可以在 Service 层通过切面传入当前用户数据范围,把部门和用户 ID 拼进查询条件。复杂场景建议引入专门的权限框架,这里不展开。

5. 常见问题复盘:403、跨域、Filter 顺序等

最后把我和身边同事遇到的高频问题完整复盘一遍。这些坑在网上零零散散都有,但真正串起来讲清楚的不多。

5.1 匿名请求返回 403 而不是 401

Spring Security 默认对“匿名用户访问受保护资源”的处理是触发AccessDeniedException,最终返回 403。但对前后端分离项目来说,前端要区分“未登录”和“无权限”,必须在 403 和 401 之间划清楚。

解决办法是自定义AuthenticationEntryPoint和AccessDeniedHandler:

@Component public class RestAuthEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpStatus.UNAUTHORIZED.value()); 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(HttpStatus.FORBIDDEN.value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"message\":\"没有访问权限\"}"); } }

然后在SecurityFilterChain里挂上这两个 Bean。

常见问题排查表:

现象可能原因解决方案
带错误 token 返回 500过滤器里抛异常了捕获异常后清空上下文,不中断请求链
未带 token 返回 403没有配 AuthenticationEntryPoint自定义返回 401 JSON
带 token 返回 403JWT 过滤器位置不对确认addFilterBefore(...)在UsernamePasswordAuthenticationFilter前面
前端登录正常,刷新后又 401token 过期或未正确携带检查前端请求拦截器是否统一加 Authorization 头

5.2 跨域预检请求被安全链路拦截

前后端分离环境里,非简单请求会先发一个 OPTIONS 预检。如果安全配置没有处理 OPTIONS 请求,会被拦截并返回 403,浏览器就把真实的 GET/POST 请求给拦截了。

当时我的现象是:登录接口在 Postman 里一切正常,浏览器里请求直接 CORS 报错。排查下来发现是CorsFilter没有生效,因为我只写了 SecurityConfig 的 CORS 配置却忘了提供CorsConfigurationSourceBean。后来补齐 Bean 后,Security 会自动在链首插入 CorsFilter,OPTIONS 请求在进入认证逻辑之前就被处理掉了。

还有一种比较粗暴的做法是在authorizeHttpRequests里显式放行 OPTIONS 方法:

.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()

但我不建议这么做,治标不治本,正确的 CORS 配置才是正道。

5.3 过滤器被调两次、Token 解析不到的问题

有一次我排查一个问题:日志里打印的过滤器执行日志出现了两次。原因是自定义过滤器直接实现了Filter接口,而项目里某个转发逻辑触发了重复执行。改成继承OncePerRequestFilter之后问题消失。

如果日志里 Token 解析不到,优先看请求头到底带了什么。可以用浏览器开发者工具查看 Authorization 头的值,常见问题是前端写了Bearer少了空格、大小写不对、或者中间层网关把 Header 名改了。

5.4 修改用户角色后,旧 token 依然有效

这是用 JWT 之后最容易被吐槽的痛点。如果权限全部塞在 JWT 的 claim 里,那用户的角色被改了,已经发出去的旧 token 在过期之前依然“拥有”旧权限。

我的折中方案是:JWT 里只放 userId 和 username,不放权限集合;每次请求到过滤器时,根据 userId 从 Redis 缓存加载最新权限列表,再构建 Authentication 对象。

这样角色变更后,清一下 Redis 缓存,用户下一次请求立刻拿到新权限。Redis 里没有缓存时再查数据库,查完写回缓存。代价是每一次请求多一次 Redis 读,但换来的是权限可控性大幅提升,且不需要立刻把 token 吊销掉,适合后台管理系统这类场景。

如果你的系统要求更严格,比如用户封号后必须立刻失效,那应该在 Redis 维护一个“token 黑名单”或“用户可用状态”,过滤器里每次校验时检查一下。这就是从“纯无状态 JWT”往“可控状态”上妥协,实践中非常常见。

结尾

写到这里,Spring Security 在前后端分离项目里的主线思路应该已经比较清晰了:理解过滤器链的先后顺序、关掉不适用的默认机制、自定义 JWT 认证入口、把权限模型设计得可控可变更。这套组合我先后在订单管理、内容发布等几个后台系统里验证过,稳定性和可维护性都还可以。

最后分享一点我自己的体会:学 Spring Security,千万不要上来就抱着源码啃。先把SecurityFilterChain、AuthenticationManager、UserDetailsService、SecurityContextHolder这几个核心接口的协作关系吃透,再去做项目会顺手得多。很多时候报错信息看不懂,不是代码的问题,是你还没有建立起“请求经过哪些过滤器”的心智模型。等你哪一天看到一个 401 能直接猜出是哪个环节出的问题,那才算真正入门了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进