前后端分离架构下接入 Spring Security,是很多团队在项目从单体页面转向接口化服务时绕不开的一道坎。我做过好几个从零搭建和从老项目迁移的案例,几乎每一次都会在登录态传递、跨域、权限粒度这几件事上反复折腾。这个主题的核心,说白了就是:在浏览器端和后端服务彻底解耦、后端只吐 JSON 不渲染页面的前提下,如何用 Spring Security 把认证和授权这两件事做扎实,让接口既安全又好用。它解决的是传统 Session 模式在分布式、多端场景下失效的问题,适合已经熟悉 Spring Boot 基础、正在做接口服务或者准备把老系统改造成前后端分离的中高级开发者参考。下面我按自己实际落地的顺序,把设计思路、关键细节、完整实操和踩坑记录一次讲透。
1. 整体设计与方案选型拆解
1.1 为什么前后端分离后老一套就不灵了
传统 Spring Security 的默认玩法是服务端渲染配合 Session:用户登录成功后,服务端把认证信息塞进 HttpSession,浏览器拿着 JSESSIONID 这个 Cookie 每次请求自动带上,服务端靠它找回会话。这套机制在同一个域名、同一个应用里非常顺滑,因为 Cookie 是浏览器自动管理的,开发者几乎不用操心。
但前后端分离之后,情况变了。前端往往独立部署在另一个域名或端口,后端只提供 REST 接口。这时候有几个现实问题:跨域请求默认不带 Cookie,就算配置了携带,多端场景(Web、App、小程序)也没法统一靠 Cookie 维持会话;服务端如果做水平扩展,Session 存在单机上会导致请求打到另一台机器就认不出用户;更关键的是,很多移动端根本没有 Cookie 这个概念。所以核心诉求变成了:认证状态不能依赖服务端内存,得让客户端每次请求自带凭证,服务端无状态地校验。这就是 Token 方案(尤其是 JWT)成为主流选择的根本原因。
我见过不少团队图省事,直接开SessionCreationPolicy.IF_REQUIRED加跨域 Cookie,短期能跑,一上集群或者接 App 就崩。所以方案选型这一步必须想清楚,别等到上线才发现要重构。
1.2 Token 方案与 Session 方案的取舍逻辑
我把两种方案的关键差异列成表,方便你对照自己项目做判断:
| 对比维度 | Session 方案 | Token(JWT)方案 |
|---|---|---|
| 状态存储 | 服务端内存/集中存储 | 客户端持有,服务端无状态 |
| 水平扩展 | 需共享 Session(如集中缓存) | 天然支持,多实例无压力 |
| 多端支持 | 依赖 Cookie,移动端不友好 | 请求头携带,全端通用 |
| 注销控制 | 服务端删 Session 即失效 | 需额外黑名单或短过期策略 |
| 跨域复杂度 | 需处理 Cookie 跨域 | 只需处理请求头与 CORS |
| 信息携带 | 仅存标识,取信息要查库 | 可自带少量声明信息 |
选 JWT 不是因为它完美,而是它在分布式和多端上的优势太明显。但它有个天然短板:签发后服务端无法主动让它失效,除非引入黑名单或者把过期时间设得很短配合刷新令牌。这一点在后面的注销和强制下线场景里会重点讲。
1.3 过滤器链的整体编排思路
Spring Security 的本质是一条过滤器链,请求进来后依次经过各个过滤器,每个过滤器负责一件事。前后端分离场景下,我通常这样编排:先放行登录、注册、验证码这类公开接口,然后是一个自定义的 JWT 认证过滤器,它从请求头里取出 Token、解析、校验,把认证信息塞进 SecurityContext;接着是授权过滤器,根据配置的规则判断当前用户有没有权限访问目标接口;最后是异常处理,把认证失败和授权失败分别转成统一的 JSON 响应,而不是默认跳转登录页。
这里有个容易被忽略的点:Spring Security 默认的认证失败处理是重定向到登录页,这在接口服务里完全没法用,前端拿到一个 302 会一脸懵。所以必须自定义AuthenticationEntryPoint(处理未认证)和AccessDeniedHandler(处理无权限),让它们返回规范的 JSON。这个设计决策看似小,但直接决定了前端能不能顺畅地做统一错误处理。
2. 核心细节解析与实操要点
2.1 依赖引入与基础配置骨架
先看依赖。Spring Boot 3.x 之后包名从javax换成了jakarta,Security 版本也跟着变,这一点在复制老教程代码时特别容易踩坑。核心依赖就两个:spring-boot-starter-security和 JWT 工具库。JWT 库我一般用jjwt,它 API 清晰、维护活跃。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency>配置类的骨架我习惯用组件式写法(Spring Security 6 推荐用 Lambda DSL,老式的and()链式写法已经废弃)。核心是构建SecurityFilterChainBean,关掉 CSRF(因为不用 Cookie 会话,CSRF 攻击面基本消失)、设置无状态、配置放行路径、把自定义过滤器插到UsernamePasswordAuthenticationFilter之前。
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/register").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .exceptionHandling(ex -> ex .authenticationEntryPoint(restAuthEntryPoint) .accessDeniedHandler(restAccessDeniedHandler)) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }注意:
csrf.disable()在纯 Token 场景下是安全的,因为攻击者无法通过第三方站点自动携带你的 Authorization 头。但如果你的 Token 存在 Cookie 里,那就另当别论,必须重新评估 CSRF 防护。
2.2 JWT 的生成、解析与密钥管理
JWT 由三部分组成:头部(算法声明)、载荷(业务声明,如用户 ID、角色、过期时间)、签名(防篡改)。生成时我用 HS256 对称加密,密钥长度必须够——HS256 要求密钥至少 256 位,也就是 32 字节。很多人随手写个短字符串当密钥,jjwt 新版本会直接抛WeakKeyException,这是好事,逼你重视密钥强度。
private static final String SECRET = "your-256-bit-secret-key-must-be-long-enough-here"; private final SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); public String generateToken(String username, List<String> roles) { Date now = new Date(); Date expiry = new Date(now.getTime() + 2 * 60 * 60 * 1000); // 2小时 return Jwts.builder() .subject(username) .claim("roles", roles) .issuedAt(now) .expiration(expiry) .signWith(key) .compact(); }解析时要注意异常分类处理:ExpiredJwtException是过期,SignatureException是签名不对(可能被篡改),MalformedJwtException是格式错误。这三种情况给前端的提示应该不同,过期可以引导刷新,签名错误则要警惕。密钥管理上,生产环境绝对不能硬编码在代码里,我一般放配置中心或环境变量,并且支持密钥轮换——旧密钥保留一段时间用于验证老 Token,新 Token 用新密钥签,平滑过渡。
2.3 自定义认证过滤器的实现细节
这个过滤器是整个方案的心脏。它的职责很单一:从Authorization头里取出Bearer xxx,解析校验,成功则构建Authentication放进SecurityContextHolder,失败则什么都不做(交给后续的 EntryPoint 处理),千万别在这里直接抛异常或者写响应,否则会破坏过滤器链的正常流转。
@Component public class JwtAuthFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { Claims claims = jwtService.parse(token); String username = claims.getSubject(); List<String> roles = claims.get("roles", List.class); var authorities = roles.stream() .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) .toList(); var auth = new UsernamePasswordAuthenticationToken(username, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException e) { SecurityContextHolder.clearContext(); } } chain.doFilter(request, response); } }继承OncePerRequestFilter而不是直接实现Filter,是为了保证一次请求只执行一次,避免异步转发时重复解析。另外ROLE_前缀这个坑必须提:hasRole("ADMIN")内部会自动加ROLE_前缀去比对,所以你在构建权限时要么加前缀用hasRole,要么不加前缀用hasAuthority,两者混用必然匹配失败。我见过太多人卡在这里排查半天。
2.4 跨域与预检请求的正确处理
前后端分离必然涉及跨域。CORS 配置有两个关键点:一是允许的源要精确配置,别图省事用*配合allowCredentials(true),浏览器会直接拒绝;二是预检请求(OPTIONS 方法)必须放行,否则复杂请求根本到不了业务接口。
@Bean public CorsConfigurationSource corsConfigSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("https://front.example.com")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; }预检请求不带 Authorization 头,所以它会在认证过滤器那里被判定为未认证。解决办法是在authorizeHttpRequests里对HttpMethod.OPTIONS全部放行,或者让认证过滤器识别 OPTIONS 直接跳过。我倾向后者,在过滤器开头加一句if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { chain.doFilter(...); return; },干净利落。
3. 完整实操流程与关键环节实现
3.1 登录接口与认证管理器对接
登录接口本身要放行,但它内部要调用AuthenticationManager完成用户名密码校验。这里有个设计选择:是手动查库比对密码,还是走 Security 的认证流程?我强烈建议走后者,因为AuthenticationManager会自动调用UserDetailsService加载用户、用PasswordEncoder比对密码,还能顺带处理账号锁定、过期等状态,逻辑统一且不易出错。
@PostMapping("/api/auth/login") public Result login(@RequestBody LoginRequest req) { var auth = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(req.username(), req.password())); var principal = (UserDetails) auth.getPrincipal(); List<String> roles = principal.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .map(a -> a.replace("ROLE_", "")) .toList(); String token = jwtService.generateToken(principal.getUsername(), roles); return Result.ok(Map.of("token", token, "username", principal.getUsername())); }UserDetailsService的实现里,从数据库按用户名查用户,把密码(已加密)和权限列表封装成UserDetails返回。密码加密用BCryptPasswordEncoder,它是自带盐值的慢哈希,比 MD5、SHA 这类快哈希安全得多。注册时用同一个 encoder 加密存储,登录时框架自动比对,你不需要自己写matches。
3.2 权限粒度控制:从接口级到方法级
接口级控制在配置类里用requestMatchers声明,适合粗粒度划分,比如/api/admin/**只允许管理员。但真实业务里权限往往更细,比如"只能删自己的评论",这就得靠方法级注解@PreAuthorized配合 SpEL 表达式。
@PreAuthorized("hasRole('ADMIN') or #userId == authentication.name") @DeleteMapping("/api/comments/{userId}/{commentId}") public Result deleteComment(@PathVariable String userId, @PathVariable Long commentId) { commentService.delete(commentId, userId); return Result.ok(); }要启用方法级注解,配置类上必须加@EnableMethodSecurity(Spring Security 6 的新注解,替代了老的@EnableGlobalMethodSecurity)。SpEL 里authentication.name就是当前登录用户名,#userId是方法参数,这种组合能覆盖大部分"本人或管理员"的场景。更复杂的权限我建议抽成自定义 Bean,在 SpEL 里用@beanName.check(...)调用,别把表达式写得太长,可读性会崩。
3.3 统一异常响应与前端对接约定
认证和授权失败必须返回结构化 JSON,前端才能统一拦截处理。AuthenticationEntryPoint处理"没登录或 Token 无效",返回 401;AccessDeniedHandler处理"登录了但没权限",返回 403。两者都写 JSON,字段约定好,比如{code, message}。
@Component public class RestAuthEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest req, HttpServletResponse resp, AuthenticationException e) throws IOException { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); } }前端拿到 401 就跳登录页或触发刷新令牌,拿到 403 就提示无权限,逻辑清晰。这里有个细节:响应体里的中文要确保编码正确,setContentType里带上charset=UTF-8,否则某些环境下会乱码。
3.4 令牌刷新与注销的落地策略
JWT 无法主动失效是硬伤,我的常规做法是双令牌:访问令牌(Access Token)短过期,比如 30 分钟;刷新令牌(Refresh Token)长过期,比如 7 天,且存服务端(数据库或缓存)以便撤销。访问令牌过期后,前端用刷新令牌换新的访问令牌,刷新令牌本身一旦注销就从服务端删除,立即失效。
注销时,把刷新令牌从服务端删掉,同时可以把当前访问令牌的 jti(唯一标识)加入一个短期的黑名单缓存,缓存过期时间设为访问令牌剩余有效期,这样既实现了即时失效,又不会让黑名单无限膨胀。这套组合拳下来,安全性和体验能兼顾。如果项目对即时失效要求不高,也可以只做短过期加刷新,省掉黑名单的复杂度。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
实际落地中遇到的问题五花八门,我把最典型的整理成表,方便你对照排查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 所有请求都 401 | 过滤器未生效或 Token 未带 | 检查过滤器注册顺序、请求头格式 |
| 登录成功但后续 403 | 权限前缀不匹配 | hasRole与hasAuthority是否混用 |
| 预检请求被拦截 | OPTIONS 未放行 | 过滤器或配置放行 OPTIONS |
| 跨域报错 credentials | 源用了通配符 | 精确配置 allowedOrigins |
| Token 解析抛 WeakKey | 密钥长度不足 | 密钥补足 32 字节以上 |
| 重启后 Token 全失效 | 密钥随机生成 | 密钥固定配置,勿每次启动随机 |
4.2 几个只有踩过才知道的坑
第一个坑是过滤器重复执行。如果你把自定义过滤器注册成了普通@Component又手动addFilterBefore,Spring Boot 可能把它同时注册到 Servlet 容器和 Security 链里,导致执行两次。解决办法是给过滤器加@Component的同时,在配置里用FilterRegistrationBean把它禁用自动注册,或者干脆不标@Component,在配置类里 new 出来。
第二个坑是SecurityContext的线程传递。在异步接口(@Async或CompletableFuture)里,SecurityContextHolder默认是 ThreadLocal 的,子线程拿不到认证信息。要么配置SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL),要么在异步任务里手动传递上下文。这个在导出报表、批量处理这类场景特别容易翻车。
第三个坑是密码编码器不一致。注册时用 BCrypt 存,登录时如果配置了NoOpPasswordEncoder或者别的编码器,比对永远失败,而且报错信息很模糊。确保全局只有一个PasswordEncoderBean,注册和认证共用。
提示:调试认证问题时,把 Spring Security 的日志级别调到 DEBUG(
logging.level.org.springframework.security=DEBUG),过滤器链的执行顺序、认证结果一目了然,比盲猜高效得多。
4.3 性能与安全的平衡心得
JWT 每次请求都要解析验签,虽然开销不大,但高并发下也是成本。我的经验是:验签结果可以在单次请求内复用,别在过滤器里解析完又在业务层解析一遍;载荷里只放必要信息(用户 ID、角色),别塞大对象,否则 Token 变长,每次传输和解析都吃亏。安全方面,务必用 HTTPS,Token 在网络上明文传输,没有 TLS 保护等于裸奔;访问令牌过期时间别设太长,宁可让前端多做几次无感刷新。
另外,角色信息放在 Token 里意味着权限变更后旧 Token 仍带旧权限,直到过期。如果项目对权限实时性要求高,可以在过滤器里加一层校验,比对 Token 里的角色版本号和数据库里的当前版本,不一致就拒绝并要求重新登录。这个机制我一般用"权限版本号"字段实现,用户权限一变就自增,成本很低但很管用。
我在几个项目里反复验证下来,这套"无状态 JWT + 双令牌 + 统一异常 + 方法级细粒度控制"的组合是最稳的,既能扛住集群和多端,又不会把复杂度堆得太高。真正决定成败的往往不是框架本身,而是这些边界细节有没有处理干净——过滤器顺序、权限前缀、跨域预检、密钥管理,任何一处疏忽都会让联调变成拉锯战。把这些点提前想透,后面基本就是顺水推舟。