前后端分离项目里接入 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 里的登录态判断当前用户是谁。
这套机制在单体服务端渲染时代没什么问题,但前后端分离之后至少会遇到四个麻烦:
- 前端是 SPA 应用或原生 App,它不关心“页面从哪渲染”,只关心服务端 API 返回的 JSON。
- 跨域场景下 Cookie 携带非常麻烦。前端服务、后端服务可能分属两个域名,浏览器对跨域 Cookie 的限制越来越多。
- Session 存储在服务端,多实例部署时需要引入 Redis 共享,否则用户的请求落到另一台实例就判定未登录。
- 移动端根本没有浏览器 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 场景下基本都要重写。你至少要改动四件事:
- 关闭默认的表单登录、HTTP Basic 登录方式。
- 关闭 Session 管理,设置
SessionCreationPolicy.STATELESS。 - 关闭 CSRF 防御。因为不使用 Cookie 保存登录态,CSRF 攻击的前提就不成立了。
- 自定义返回 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 | 处理跨域 | 前后端分离项目必须配置 |
CsrfFilter | CSRF 防护 | 无状态 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); } }这里有几个容易被忽略的点:
- 返回的
UserDetails里的 password 是数据库中存储的加密后的密码,不能是明文。 - 权限集合不要只放角色名,最好把“权限标识”也放进去。比如后台系统的菜单权限、按钮权限都能映射成一个个权限字符串。
- 不要把业务字段塞进
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 返回 403 | JWT 过滤器位置不对 | 确认addFilterBefore(...)在UsernamePasswordAuthenticationFilter前面 |
| 前端登录正常,刷新后又 401 | token 过期或未正确携带 | 检查前端请求拦截器是否统一加 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 能直接猜出是哪个环节出的问题,那才算真正入门了。