ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Security + JWT 实战:从Session到无状态认证的完整重构

Spring Security + JWT 实战:从Session到无状态认证的完整重构

1. 为什么Session认证撑不住了:一次多端登录事故引发的重构

先讲个我自己的经历。之前维护过一套老管理系统,认证方式是最经典的HttpSession:用户登录后把用户信息塞进Session,后续每个请求靠JSessionID去服务端内存里找对应的会话。单机部署、用户量不大时一切岁月静好,直到某天业务扩张,同一账号需要同时被几个办公室的人登录使用,问题像连环爆一样出现——A在电脑上登录后,B在另一台电脑登录,A这边突然被踢下线;有时候用户什么都没干,过一会儿就提示会话过期;后台日志里挤满了Session already invalidated之类的报错。

排查到最后,问题根因很清楚:Session是服务端状态,而服务端状态在分布式部署下天然难搞。当时系统虽然还没上集群,但Nginx已经挂了多实例在做轮询转发,用户的请求被负载到不同实例上,而Session默认只存在第一次接收请求的那台机器内存里。哪怕后来引入了spring-session-data-redis做会话共享,也只是把"服务端有状态"这个从根上就麻烦的问题延后了。更关键的是,Session机制天然和Cookie绑定,移动端App、第三方接口调用、小程序这些非浏览器场景,处理Cookie的成本极高,动不动就要手动维护会话标识,烦不胜烦。

正是这次事故让我下决心重构认证方案:彻底抛弃Session,改用Token认证,具体实现就是Spring Security + JWT。如果你也遇到类似问题——多端互踢、会话共享困难、非浏览器客户端难以接入,或者单纯觉得传统Session认证不够灵活,那么这篇文章应该能帮到你。我会从原理讲到落地,把核心代码、配置细节和上线后踩过的坑一次说清楚。

1.1 先弄明白会话迁移背后的思维转换

Session认证和Token认证最本质的区别,一句话就能概括:Session是"服务端记住你是谁",Token是"客户端自证你是谁"。

Session模式下,用户登录后服务端生成一个ID(也就是JSessionID)返回给浏览器,服务端自己存一份userId -> 用户数据的映射。下次请求带上这个ID,服务端查表、比对、放行。这里有个隐含前提:服务端必须记住每一个已登录用户的状态,所以Session叫"有状态认证"。

Token模式完全不同。用户登录后,服务端把用户ID、过期时间、签名等信息打包成一个字符串(也就是JWT)返回给客户端,服务端自己不存任何东西。后续请求只要带上这个字符串,服务端验签通过、确认没过期,就信任这个请求的身份。所以Token叫"无状态认证"——服务端不需要记住谁,只需要会验证。

这个转变带来的直接好处有两个。第一,天然支持多端同时登录,因为每个Token是独立签发的,彼此不冲突,不像Session那样一个会话只能有一个归属;第二,天然适合分布式环境,因为验证Token只需要密钥,任何一台服务器都能独立完成校验,不需要共享存储。这俩痛点恰好是上面那次事故的直接解药。

1.2 Session和Token的对比,一张表看懂

我用一个表格把两者核心差异列清楚,方便你遇到技术选型时直接对照:

对比维度Session认证Token认证(JWT)
服务端状态有状态,需要存储会话无状态,服务端不存储
多端登录默认互踢,需要额外设计天然支持
分布式扩展需要Session共享方案无需额外依赖
跨端适配依赖Cookie,移动端麻烦请求头传输,全端通用
注销处理删除Session即可,立刻生效Token未过期前难以立即失效
安全性会话标识泄露可被伪造利用依赖签名,篡改会被识破

看到最后一行你可能会有疑问:既然Token不能立即失效,那注销登录怎么办?这个问题我放到后面专门讲,属于Token认证最容易被忽略、也最容易踩坑的细节。

2. 先摸清Spring Security的"家底":过滤器链与认证流程

很多人觉得Spring Security难,不是难在写代码,而是难在它是一个天生就带着完整流程的框架,你得顺着它的流程做事。所以先花一点篇幅把它的核心机制讲清楚,后面实现起来才不会云里雾里。

2.1 过滤器链:认证其实发生在请求进入Controller之前

Spring Security对请求的拦截,靠的是一条很长的过滤器链(Filter Chain)。一个请求到达你的Controller之前,会先经过这条链上一个接一个的过滤器,每个过滤器只干一件事,然后传给下一个。你可以把它想成进机场安检——你还没到登机口,就已经过了好几道查验:身份核验、行李扫描、随身物品检查,任何一道不过都进不去。

这条链上几个关键的过滤器大概是这样的顺序:SecurityContextPersistenceFilter负责从请求里恢复上下文,UsernamePasswordAuthenticationFilter负责处理表单登录,BasicAuthenticationFilter负责处理HTTP Basic认证,ExceptionTranslationFilter负责把异常转成合适的HTTP状态码,AuthorizationFilter负责最终的权限判断。

对我们做Token认证来说,最重要的动作是:把我们自定义的JWT校验逻辑,作为一个过滤器,插入到这条链中合适的位置。具体插在哪,后面写代码的时候细说。

2.2 SecurityContextHolder:一个线程私有的小黑板

过滤器链上那么多环节,它们之间怎么传递"当前这个请求是谁"这个信息?靠的是SecurityContextHolder。它本质上是个基于ThreadLocal的容器,每个请求线程都有自己的一个"小黑板"——SecurityContext,里面放着当前用户的认证信息Authentication。

你的业务代码里随时能通过SecurityContextHolder.getContext().getAuthentication()拿到当前登录用户,原理就在这。但这里有一个非常隐蔽的坑:ThreadLocal是线程私有的,如果某个请求线程处理完了没有清理上下文,而线程池复用了这个线程,下一个请求就可能读到上一个请求的登录信息,造成用户身份串号。Spring Security在过滤器链的末尾有清理逻辑,但如果你是自己手动往SecurityContextHolder塞东西,一定要记得在finally块里清空,或者确保你的过滤器包裹在框架清理逻辑之内。

2.3 我们要重新定义的认证流程应该长什么样

理解了过滤器链和SecurityContextHolder之后,基于Token的认证流程其实就可以自己推导出来了:

  1. 客户端登录,带着用户名密码访问登录接口。
  2. 服务端校验用户名密码,成功后签发一个JWT返回给客户端。
  3. 客户端后续每次请求,在请求头里带上Authorization: Bearer <JWT>。
  4. 服务端有一个专门的过滤器(就是我们要写的JwtAuthenticationFilter),拦截所有请求,从请求头里取出JWT。
  5. 过滤器验证JWT的签名和有效期。如果没问题,就解析出用户信息,造一个Authentication对象塞进SecurityContextHolder。
  6. 后续的授权过滤器(AuthorizationFilter)根据SecurityContextHolder里的信息判断该不该放行。

你会发现,整个流程里Spring Security框架只帮你处理了第6步和一部分第1步,第4、5步是需要自己实现的。不过别慌,这部分代码量不大,核心就一个过滤器和一个工具类。

3. 手写JWT工具类:结构、签名与校验

市面上操作JWT的Java库不少,我用得最多的是jjwt,因为它API设计直观、文档齐全、社区活跃。下面所有代码基于jjwt 0.11.x版本,如果用了其他版本,API会有差异,这点留意一下。先在pom.xml里加依赖:

<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>

3.1 JWT三段式结构拆解

一个JWT字符串长这样,用点号分成三段:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9.签名部分
  • Header(第一段):声明签名算法和令牌类型,典型内容是{"alg":"HS256","typ":"JWT"}。
  • Payload(第二段):放实际的数据,比如用户ID、用户名、签发时间(iat)、过期时间(exp)等,这一部分只是Base64编码,不是加密的,任何拿到Token的人都能解码看到内容,所以绝对不要放密码之类的敏感信息。
  • Signature(第三段):对前两段做签名计算。服务端持有密钥,把前两段内容和密钥一起丢进签名算法算出一串摘要,放进第三段。

为什么叫"防篡改"?因为有人改动了Payload里的任何一个字符,重新计算后的签名和第三段对不上,服务端一验签就识破了。当然,前提是密钥绝对不能泄露。

3.2 选择HS256的权衡与实现

JWT常用签名算法有HS256(对称加密)和RS256(非对称加密)。对称的意思是"加密和解密用同一个密钥",非对称则是"公钥加密、私钥解密"。

单体应用、前后端分离项目、没有第三方系统需要独立验签的场景,用HS256完全够用,配置简单、性能好。如果以后有多个服务需要各自独立验证Token,那就要考虑RS256——每个服务只持有公钥,验签公钥随便分发,私钥只要保存在签发中心。网上很多教程默认只讲HS256,我建议你架构上如果有多服务共存的规划,提前想清楚这一点,不然后面重构验签逻辑会动到所有服务的配置。

下面是我用的JwtUtil工具类,基于HS256:

@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; // 单位:秒 @Value("${jwt.header}") private String header; public String generateToken(String userId, String username) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration * 1000); Key key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); return Jwts.builder() .setSubject(userId) .claim("username", username) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { Key key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }

这里有几个细节值得展开说。

第一,signWith方法的坑。jjwt 0.10之前的老写法是signWith(SignatureAlgorithm.HS256, secret),直接传字符串密钥,但这种写法在新版本里已经废弃了,而且它内部会把字符串当作字节数组用,密钥太短会直接抛异常。正确做法是像我这样用Keys.hmacShaKeyFor(...)把密钥字节数组转成Key对象。HS256要求密钥长度至少32字节,所以配置里的jwt.secret别偷懒写个"abc123"就完事,建议用一个不少于32位的随机字符串。

第二,.setSubject(userId)和.claim("username", username)怎么选。Subject是JWT规范里约定好的字段,我习惯放唯一标识(用户ID),因为它是主键、不长、不随用户名变更而变化。用户名通过自定义claim放进去,方便后续业务直接展示。别的角色、邮箱、部门等字段都可以用claim方式塞进去,但一定要克制——Token越短越好,塞太多无关信息会让请求头体积膨胀,而且Payload里的东西等于公开的,别放敏感数据。

3.3 过期时间的策略与会话并发

过期时间expiration不是随便拍脑袋定的。太短,用户频繁重新登录,体验差;太长,Token泄露后有效期过长,风险大。我个人习惯:普通后台管理系统设2小时左右,需要较长会话的可以设24小时,但一定要配套刷新机制。很多项目上线后出现"用户明明一直在用,却突然被要求重新登录"的抱怨,多半就是因为没有刷新机制,Token过期后没有自动续期。

刷新机制的做法很多,我提供一个简单版本:在登录接口的响应里同时返回accessToken和refreshToken,accessToken短期有效(比如2小时),refreshToken长期有效(比如7天)。每次请求如果accessToken过期了,客户端用refreshToken去换新的accessToken。这个方案逻辑清晰,但代码量会多一点。如果项目刚起步,可以先用一个过度方案:Token有效期设长一点,等用户量起来、对安全要求明确后再做完整刷新。

4. 落地核心代码:SecurityConfig + JwtAuthenticationFilter

工具类有了,接下来是真正让Spring Security跑起来的部分,一共三块:配置类、登录接口、认证过滤器。这三块配合好,整个认证链路就通了。

4.1 配置类:关掉什么、放开什么、加什么过滤器

直接上代码,这是基于Spring Security 5.7+推荐的新写法(用SecurityFilterChainBean替代继承WebSecurityConfigurerAdapter,后者在新版本里已经废弃):

@Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .cors().and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/auth/login", "/api/auth/refresh").permitAll() .antMatchers("/doc/**", "/error").permitAll() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }

逐行解释一下每个配置的意图:

csrf().disable()。CSRF防护是防止跨站请求伪造的,它的原理依赖Session——给页面渲染时注入一个随机token,提交表单时校验。但我们现在做的是前后端分离、Token认证,请求头里已经带了Authorization,CSRF防护反而会碍事,尤其是接口被前端用multipart/form-data调用时,没有CSRF token就直接403。所以静态分析社区里有个共识:无状态Token认证下,CSRF防护基本可以关掉。

sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)。这句话非常关键,它告诉Spring Security不要创建也不要使用Session。你可能会想:我们本来就没打算用Session,不写这句不行吗?不写还真不行——Spring Security默认的很多机制都会去找Session用,不显式声明无状态,它可能悄悄给你创建Session,导致"明明是无状态认证,服务端却总有会话残留"的尴尬局面。

authorizeRequests()里.permitAll()和.authenticated()。登录、刷新Token这些接口必须放行,不然用户连登录都进不来;其余接口全部要求认证后才可访问。这是个最简版规则,实际项目中你还可以针对不同接口做细分,比如/api/admin/**放行管理员角色、/api/user/**放行普通用户角色,用.hasRole("ADMIN")这类方法。

addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)。这句话决定了过滤器插队的位置:插在UsernamePasswordAuthenticationFilter之前。为什么插这里?因为UsernamePasswordAuthenticationFilter是用来处理表单登录的,而我们不需要它处理Token,必须让JwtAuthenticationFilter先于它执行——先把Token解析成认证信息塞进上下文,后面框架再做授权判断时数据就齐了。网上有些人插在BasicAuthenticationFilter后面,也有人直接插在最前面,效果类似,但我还是建议插在UsernamePasswordAuthenticationFilter前面,这是最符合"登录流程被自定义认证替代"这一语义的位置。

4.2 登录接口:Token从哪里来

登录接口做的事可以拆成四步:接收用户名密码、校验参数、匹配密码、签发Token。一个简化版实现:

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @Autowired private JwtUtil jwtUtil; @Autowired private PasswordEncoder passwordEncoder; @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest request) { User user = userService.findByUsername(request.getUsername()); if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(new ApiResponse(401, "用户名或密码错误", null)); } String token = jwtUtil.generateToken(user.getId(), user.getUsername()); return ResponseEntity.ok(new LoginResponse(token, jwtUtil.getExpiration())); } @PostMapping("/logout") public ResponseEntity<?> logout(@RequestHeader("Authorization") String authorization) { // 前端的logout逻辑:客户端丢弃Token即可 return ResponseEntity.ok(new ApiResponse(200, "注销成功", null)); } }

两个细节说明。

密码校验一定不能用明文比对,必须用PasswordEncoder的matches方法。我推荐BCryptPasswordEncoder,它内部会自动加盐、自动生成格式化的哈希串,同一个密码每次比对生成的哈希都不一样,安全性可靠。新手容易犯的错误是直接把数据库里的密码拿出来和用户输入的字符串做equals,一旦这么干,数据库泄露等同于密码全部泄露。

关于logout接口。如果你仔细想想就会发现,在纯无状态Token方案里,服务端是无法让一个已经签发的Token失效的,Token就像一张盖了章的餐券,在到期之前拿着它随时能进门。所以最基础版的注销就是前端把本地存的Token删掉,服务端什么都不用做。但这样做有个问题:如果用户的Token被偷了,偷的人拿它来调接口,服务端照样放行。要解决这个问题,就得引入"Token黑名单"机制——在服务端维护一个黑名单列表,Token被注销后加进去,每次请求来的时候查一下。这个机制后面第5节展开讲。

4.3 JwtAuthenticationFilter:每次请求的"查证"逻辑

这个过滤器是整个方案的心脏,也是唯一一段需要跨OncePerRequestFilter实现的逻辑:

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { try { Claims claims = jwtUtil.parseToken(token); String userId = claims.getSubject(); String username = claims.get("username", String.class); if (userId != null) { UserDetails userDetails = new UserDetails() { // 实现必要的方法,可以简化 }; UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (JwtException | IllegalArgumentException e) { SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer = request.getHeader(SecurityConstants.HEADER_STRING); // "Authorization" if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { return bearer.substring(7); } return null; } }

几个容易写错的地方,我都标记一下:

第一,OncePerRequestFilter的意义。直接实现Filter接口的话,一个请求可能被这个过滤器执行多次(比如转发场景下)。OncePerRequestFilter保证一个请求线程只执行一次过滤器逻辑,避免重复解析Token。这一点在Spring Cloud Gateway之类的网关场景里尤其关键。

第二,Token解析失败的处理。parseToken只要验签不过、过期、格式错误,都会抛异常。我在catch块里做了SecurityContextHolder.clearContext(),然后把请求继续往下传。你不要一看到异常就直接返回401,因为Spring Security后面还有ExceptionTranslationFilter在管异常转换——你把请求放行,它会根据有没有认证信息来决定是继续走还是返回401,这样更符合框架语义。当然,你也可以在过滤器里直接写response.sendError(401),效果一样,但会让后面的授权过滤器失去意义。我个人的习惯是交给框架处理,代码更干净。

第三,权限和角色的处理。上面代码为了方便阅读,UserDetails那块被简化了。正常项目里,你应该从数据库或Token的claim里加载用户角色,填充到Authentication的authorities里,这样后续才能用@PreAuthorize("hasRole('ADMIN')")或/admin/**这种规则做细粒度权限控制。如果Token里已经带了角色信息,直接转换就行,不用再查库;如果为了角色变更实时生效,那就从库拉,时间换准确性,二选一。

5. 上线后遇到的坑与安全加固

代码跑通只是第一步,把方案上线到生产环境,真正的问题才慢慢浮出水面。这里记录的每一段都是实际踩过坑之后沉淀下来的,希望你能少走点弯路。

5.1 第一个坑:注销后Token依然有效,怎么处理

前面已经提到,无状态Token理论上无法立即失效,但业务上又需要"注销后立刻不能访问"的能力,怎么解决?

我见过不少项目直接把Token失效需求砍掉,理由是"无状态就是这样",但其实有个折中的方案:引入一层轻量的服务端存储,专门存"黑名单Token"。核心思路是:注销时把Token的jti(JWT ID,唯一标识)或Token本身,连同过期时间一起写进Redis,TTL设成和Token剩余有效期一致。每次请求的过滤器里,解析Token后先去Redis查一下这个Token在不在黑名单里,在就直接拒绝。Redis查一次的开销极小,而且如果选用spring-session-data-redis等组件,接入成本也不高。

假如你用的是spring-boot-starter-data-redis,大致逻辑是这样:

// 注销时 redisTemplate.opsForValue().set("blacklist:" + token, "1", jwtUtil.getRemainingTime(token), TimeUnit.SECONDS); // 过滤器里 String blacklistKey = "blacklist:" + token; if (Boolean.TRUE.equals(redisTemplate.hasKey(blacklistKey))) { // 拒绝访问 }

这个方案被业界大量采用,也是Spring Security官方文档里提到"如何在无状态认证下支持注销"的常见解法。代价是多了一次Redis查询,换来的是注销逻辑真正可控。我在实际项目中就是这么用的,用户数量到了几十万级别,Redis查询性能也扛得住。

5.2 第二个坑:密钥管理与续期策略

密钥管理这件事,很多教程一句"放配置文件"带过,但生产环境绝不能这么随意。我把几个常见的错误和对应做法列出来:

错误做法问题正确做法
密钥硬编码在代码里源码泄露=密钥泄露放配置中心或环境变量,代码只引用变量名
所有环境用同一个密钥测试环境泄露=生产环境沦陷用不同的密钥隔离环境
密钥太短(<32字节)HS256签名强度不足生成64位以上随机字符串
无感知旋转密钥需要换Key时,所有存量Token全部失效设计多密钥切换机制,新旧Key共存过渡

多密钥旋转的做法不复杂:配置里维护一个issuer -> secret的映射,签发Token时用当前生效的那个Key,验签时先根据Token里的kid(Key ID)找到对应的Key再来验证。这样换Key时旧Token还能继续验签直到过期,不会造成大规模掉线。

续期策略上,我实践下来比较顺的方案是RefreshToken + AccessToken双层,前面已经提过。这里补一个具体设定:AccessToken有效期2小时、RefreshToken有效期7天;RefreshToken只允许用在/api/auth/refresh接口换取新的AccessToken;换取时校验RefreshToken是否合法、是否在黑名单里;RefreshToken一旦使用,立即作废换发新的;客户端定期在AccessToken过期前静默刷新。这套机制上线后,用户基本感知不到"过期"这回事,用户体验和安全性做到了平衡。

5.3 第三个坑:白名单和静态资源放行

很多项目启动后出现一个玄学问题:接口调通了,但静态资源或某个特定路径死活进不去,一直401。尤其是有Swagger文档、/actuator监控端点、websocket握手连接这类特殊路径的场景。

原因很简单:authorizeRequests()里的放行规则如果没配全,Spring Security会默认拦下所有没有明确放行的请求。所以配置的时候要仔细梳理哪些路径是无需认证即可访问的。拿Swagger举例,它暴露的不是一个路径,而是一串:/swagger-ui.html、/swagger-ui/**、/v3/api-docs/**、/webjars/**等,漏一个就白屏。WebSocket握手请求也一样,握手阶段没法带请求头鉴权(或者说带了也不方便处理),一般选择放行握手路径,在消息层做业务鉴权。

我自己吃过的亏是:放行了/doc/**,结果实际请求路径是/swagger-ui/index.html,根本不匹配,排查了半天最后发现是路径前缀不统一。所以我的经验是:放行路径写成明确的、还原真实结构的,不要想当然用通配符图省事。

5.4 日志脱敏与请求头泄露

上线后另一个容易忽视的安全点,是日志系统把Token打了出来。项目中每个关键操作都打了请求日志,某次排查问题时发现日志文件里User-Agent、Authorization、请求参数都记录着,Token赫然在列。日志文件如果被有心人拿到,等于直接把用户身份拱手相送。

解决思路两招一起用:第一,全局日志过滤器做脱敏,拦截HTTP请求内容,检测Authorization字段值,用*****替换后再进入日志;第二,日志配置过滤关键字,在logback.xml里给authorization、token、password这些敏感字段加上过滤规则。这个措施看着简单,但在我经历过一次客户安全审计之后,才意识到它有多重要。

5.5 关于CORS和失效响应状态码的坑

前后端分离项目必然面对CORS(跨域资源共享)。我遇到过的问题是:前端页面部署在http://localhost:8080,接口服务在http://localhost:9000,跨域环境下前端先发一个OPTIONS预检请求,被安全配置拦截直接401,真正的请求根本没发出去。解决方法很标准,要么用CorsFilter统一处理,要么在配置里加.cors()然后提供一个CorsConfigurationSourceBean。如果你用的是上面4.1节的配置,cors().and()已经开启了,但别忘了在CorsConfigurationSource里把允许的来源、请求头、请求方法都配好,特别是允许的请求头里包含Authorization,否则跨域请求带上Token照样被浏览器拦。

最后一个细节:401和403要分清。Token缺失或过期,应该返回401(未认证);Token有效但权限不足,应该返回403(无权限)。很多项目混用这两个状态码,前端处理起来会非常混乱。正确区分的好处是,前端拿到401就知道该跳登录页了,拿到403就知道是权限不够,不该乱跳。这个看着零碎,但对联调效率影响很大。

我在整个项目重构过程中最大的体会是:Spring Security的复杂度不在于单个API怎么用,而在于你要理解它背后的流程哲学,然后顺着它的流程做扩展。Token认证本身不难,难的是把无状态方案放到真实业务里,处理注销、续期、密钥、跨域这些边角问题。希望这一整套下来,你能少走一些我走过的弯路。

RELATED READING

延伸阅读

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