登录认证和权限授权这两件事,基本上每个后端项目都绕不开,也是我最怕看到"先跑通再说"的地方。跑通很容易,一个拦截器加一个 token 校验就够了;但真到了要区分角色、要支持多端登录、要在集群里保持状态、要防止别人拿着抓包工具改几个字段就混进来的时候,前面偷的懒会一次性全部还回来。这篇就围绕 SpringSecurity、JWT、session 这套经典组合,把我自己在实际项目里趟过的路、踩过的坑和最后沉淀下来的做法整理一遍。不管你是刚开始接触权限模块的新手,还是已经用过但总觉得哪里不踏实的开发者,应该都能从里面捞到点能直接抄的东西。
先说清楚这篇讲的是什么:一个典型的 Web 后端认证授权体系,认证解决"你是谁",授权解决"你能干什么"。认证层用 SpringSecurity 兜底过滤器链,凭证载体在两个方向里选——无状态的 JWT 和有状态的 session;授权层用角色 / 权限码配合注解做方法级拦截。适合谁看:写过登录接口但没系统梳理过权限模型的同学,以及手里项目马上要加权限、想少走弯路的同学。我会尽量把每个选择背后的原因讲透,而不是甩一段配置让你照抄。
1. 认证授权方案的整体设计与选型思路
1.1 先把两个概念拆干净:认证和授权不是一回事
很多人把这两个词混着用,结果代码里login和hasPermission搅在一起,后面越写越乱。我习惯用进小区的类比来解释:认证就是门口保安核对你的脸和门禁卡,确认你是这个小区的业主;授权是你进了小区之后,能开自己家的门,但开不了别人家的门,也进不去设备间。两者的输入输出完全不同——认证的产出是一个"身份凭证",授权的产出是一个"允许 / 拒绝"的布尔结果。
这个区分落到代码上,边界就清晰了:认证相关的逻辑放在UserDetailsService和登录接口里,负责查库、校验密码、发放凭证;授权相关的逻辑放在过滤器和注解里,负责拿到凭证后解析身份,再判断这个身份有没有访问当前资源的资格。SpringSecurity 本身也是按这个思路设计的,AuthenticationManager管认证,AccessDecisionManager(新版本里是AuthorizationManager)管授权,两者通过SecurityContext串起来。你要是把这两块职责混在一处写,后面加一个"登录后强制改密码"或者"管理员可以模拟登录"的需求,就会发现无从下手。
还有一个容易被忽略的点:认证失败和授权失败在 HTTP 语义上是不一样的。没带凭证或者凭证过期,应该是 401;带了凭证但权限不够,应该是 403。很多项目图省事全返回 200 加一个code: 500,前端就得分情况猜,联调的时候天天扯皮。我在项目初期就把这两个状态码定死,前端只按状态码做跳转逻辑,省掉大量沟通成本。
1.2 三种会话保持方式,本质差异在哪
JWT 和 session 经常被拿来对立讨论,其实它们解决的是同一个问题的两种思路:服务端怎么在多次请求之间认出同一个用户。session 的做法是服务端存一份数据,客户端只拿一个没有含义的 ID;JWT 的做法是把数据本身放进凭证里,服务端不存,靠签名保证内容没被改过。
| 对比维度 | Session | JWT(无状态) | JWT + Redis(有状态) |
|---|---|---|---|
| 数据存放位置 | 服务端 | 客户端 | 客户端 + 服务端 |
| 服务端存储压力 | 有,随在线人数增长 | 无 | 有,但可精确控制 |
| 横向扩展 | 需要共享存储 | 天然支持 | 需要共享存储 |
| 主动踢人 / 强制下线 | 容易 | 困难 | 容易 |
| 凭证体积 | 小(几十字节) | 大(几百字节起) | 大 |
| 跨域携带 | 依赖 Cookie,较麻烦 | 走 Header,方便 | 走 Header,方便 |
| 典型适用场景 | 传统服务端渲染、内部系统 | 移动端、开放 API | 前后端分离的主流选择 |
这张表我建议每个做权限的同学都自己画一遍,画完很多纠结就自然消解了。JWT 最大的优势是无状态,最大的劣势也是无状态——你没法"撤回"一个已经发出去的 token,除非引入额外的黑名单或白名单机制,而一旦引入,它就不再是无状态的了。很多人吹 JWT 的时候只讲前半句,不提后半句,这是不负责的。
1.3 我在中小项目里的默认选型
说结论:前后端分离的项目,我默认选 JWT 存 access token + Redis 存刷新凭证的组合;传统服务端渲染或者公司内网系统,我直接上 session,不折腾。理由很实在——前后端分离场景下,前端可能跑在完全不同的域名甚至不同端(H5、小程序、App),Cookie 的跨域携带和 SameSite 策略会带来一堆莫名其妙的登录态丢失问题,Header 里带 token 反而最省心。
而为什么要在 JWT 之外再加一层 Redis?因为纯无状态 JWT 有三个绕不过去的硬伤:一是无法主动失效,用户改了密码或者管理员封了号,旧 token 在过期前依然有效;二是刷新体验差,要么让用户频繁重新登录,要么把过期时间设得很长,安全性又下去了;三是权限变更无法即时生效,你把某个用户的角色降级了,他手里的 token 里还写着老角色。
所以我的做法是:access token 有效期设短,比如 30 分钟,只用来扛正常请求;refresh token 有效期 7 天,存 Redis,用户来换新 token 的时候校验一次 Redis 里的记录,同时顺便检查这个用户有没有被拉黑。登出的时候删掉 Redis 里的 refresh token,达到"准主动下线"的效果。这个方案不纯粹,但工程上非常好用,成本也就多一个 Redis key 的事。
提示:不要为了追求架构上的"纯粹无状态"而牺牲可运维性。能主动踢人、能查在线用户、能审计登录记录,这些在真实项目里比架构洁癖重要得多。
2. SpringSecurity 过滤链拆解与基础配置落地
2.1 过滤器链的执行顺序,决定了你的逻辑什么时候生效
SpringSecurity 的核心就是一个过滤器链,请求进来之后依次穿过这些过滤器,任何一个环节判定不通过就直接返回,不会走到 Controller。理解顺序比背 API 重要得多,因为很多"我的自定义过滤器不生效""跨域配置没起作用"的问题,根源都是顺序。
大致的执行顺序是这样的:SecurityContextPersistenceFilter(新版是SecurityContextHolderFilter)先把上下文从存储里恢复出来,然后UsernamePasswordAuthenticationFilter处理表单登录,接着BearerTokenAuthenticationFilter之类的处理 token 认证,再往后是ExceptionTranslationFilter负责把认证授权异常翻译成 401/403,最后FilterSecurityInterceptor(新版对应AuthorizationFilter)做最终的授权判定。
实操中最常见的两个坑:第一,跨域过滤器如果放在 SpringSecurity 的过滤器链后面,预检请求 OPTIONS 会被拦下来返回 401,前端看到的就是"跨域失败",其实根本不是跨域问题。解决办法是让 CORS 过滤器排在认证之前,http.cors()开起来,同时把.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()放开。第二,自定义的 JWT 过滤器要用addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)插进去,别用addFilter,否则位置不确定,行为会很诡异。
顺带说一句放行路径的写法,新版 SpringSecurity 用的是requestMatchers,老版本是antMatchers,网上很多教程还是老写法,直接抄会编译不过。放行清单我一般包含:登录、注册、验证码、公开的静态资源、健康检查、以及 OPTIONS 预检。这个清单越短越好,我见过有人图省事直接.anyRequest().permitAll(),等于把大门拆了。
2.2 认证管理器与 UserDetailsService 的真实配合方式
AuthenticationManager的工作流程是这样的:你传进去一个未认证的Authentication(通常是用户名密码),它委托给配置好的AuthenticationProvider,而DaoAuthenticationProvider会调用你实现的UserDetailsService去加载用户,拿加载出来的密码和传进来的密码做比对,比对通过后返回一个已认证的Authentication。
这个链条里有两个地方最容易出问题。第一个是密码编码器不一致。你的UserDetailsService返回的是数据库里的 BCrypt 密文,而PasswordEncoder配的是NoOpPasswordEncoder,比对必然失败,而且报错信息很模糊,你只会看到"用户名或密码错误"。我的做法是全局只允许存在一个PasswordEncoderBean,用 BCrypt,注册和登录都走它,不给第二种可能。第二个是UserDetailsService里抛异常。用户名不存在的时候,标准做法是抛UsernameNotFoundException,而不是返回 null。返回 null 的话,SpringSecurity 内部会包一层,最后呈现出来的日志可能让你完全摸不着头脑。
@Service public class DbUserDetailsService implements UserDetailsService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; public DbUserDetailsService(UserMapper userMapper, PasswordEncoder passwordEncoder) { this.userMapper = userMapper; this.passwordEncoder = passwordEncoder; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = userMapper.selectByUsername(username); if (user == null) { // 统一抛这个异常,别返回 null throw new UsernameNotFoundException("用户不存在"); } if (user.getStatus() == 0) { throw new DisabledException("账号已被停用"); } List<String> perms = userMapper.selectPermCodesByUserId(user.getId()); List<GrantedAuthority> authorities = perms.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), authorities); } }注意这里权限码的加载。我直接把权限码字符串塞进GrantedAuthority,后面在注解里用hasAuthority('user:add')判断。角色和权限我建议分开存:角色是粗粒度分组(一个人是"运营"还是"财务"),权限是细粒度动作("订单导出"还是"订单退款")。只做角色控制的话,加到十几个角色之后会失控,每次加需求都得新建角色。
2.3 授权决策与注解体系的实际用法
授权这块,SpringSecurity 提供了三层能力:URL 级别的配置、方法级别的注解、以及自定义的表达式。我实际项目的做法是:粗粒度用 URL 配置兜底,细粒度全用方法注解,理由是权限点跟着业务方法走,代码可读性最好,谁负责哪个接口一目了然。
方法注解要在配置类上加@EnableMethodSecurity(旧版是@EnableGlobalMethodSecurity(prePostEnabled = true)),然后就可以在 Service 方法上写:
@PreAuthorize("hasAuthority('order:refund')") public void refund(Long orderId) { ... } // 支持 SpEL,可以拿到方法参数做数据级校验 @PreAuthorize("@permChecker.canAccessDept(#deptId)") public DeptVO getDept(Long deptId) { ... }第二种写法是重点。很多系统做到接口级权限就停了,但真实业务里经常需要数据级权限——同一个"查看订单"接口,A 只能看自己部门的单子,B 能看全公司。这种逻辑塞不进hasAuthority,得靠自定义 Bean 写判断。我一般会起一个permChecker之类的组件,把数据归属判断收在里面,这样权限逻辑集中,改起来不至于到处翻。
注意:
@PreAuthorize失效最常见的原因是同类内部方法调用。A 方法调 B 方法,B 上的注解不生效,因为走的是this调用而不是代理对象。要么把 B 挪到另一个 Bean,要么注入自己。
3. JWT 的生成、校验与防篡改实战
3.1 三段结构拆解与签名算法怎么选
JWT 长这样:xxxxx.yyyyy.zzzzz,三段分别是用 Base64Url 编码的头部、载荷和签名。头部声明算法类型,载荷放业务数据(标准的叫 claim),签名是对前两段加上密钥计算出来的结果。这里有个必须说三遍的事——Base64 只是编码,不是加密,任何人把中间那段拿去做个 Base64 解码就能看到里面的明文。所以载荷里绝对不能放密码、身份证号、手机号这类敏感信息。我见过有人把整个用户对象塞进去,这就等于把用户资料挂在了公网上。
签名算法上,HS256 是对称加密,用同一个密钥签名和验签;RS256 是非对称,私钥签名、公钥验签。什么时候选哪个?单体应用或者内部服务用 HS256 完全够,简单快。如果是多个服务都要验签、但只有认证中心能签发的情况,用 RS256,把公钥分发下去,避免密钥到处扩散——密钥扩散得越广,泄露概率越高。
密钥本身的管理也得当回事。硬编码在配置文件里是及格线,至少别提交到代码仓库;更好的做法是走环境变量或者配置中心,并且确保密钥有足够的长度(HS256 建议至少 256 位,也就是 32 字节以上)。我见过用"secret"当密钥的项目,这跟没签名区别不大。
3.2 生成与解析的核心代码
用 jjwt 这套库写起来挺直接的,我贴一份自己常用的工具类结构:
@Component public class JwtTokenProvider { private final SecretKey key; private final long accessExpireMs; public JwtTokenProvider(@Value("${jwt.secret}") String secret, @Value("${jwt.access-expire-ms:1800000}") long accessExpireMs) { // HS256 要求密钥长度不低于 256 bit this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.accessExpireMs = accessExpireMs; } public String createAccessToken(Long userId, String username, List<String> perms) { Date now = new Date(); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("perms", perms) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + accessExpireMs)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parse(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }解析方法这里有个细节:parseClaimsJws在签名不对或者 token 过期时会抛JwtException,必须捕获并转成"未认证"状态,别让它冒到全局异常处理器变成一个 500。我在项目里会专门捕获ExpiredJwtException和SignatureException分开处理——前者走刷新逻辑,后者直接当作攻击尝试记一笔日志,因为正常情况下客户端不可能拿到一个签名错误的 token。
3.3 防篡改、防重放、防越权的三道防线
热词里在问"JWT 如何防止数据被篡改",这个问题的答案其实就藏在签名里。你改了载荷里的userId,签名就对不上了,服务端验签直接失败。所以防篡改这件事,只要你不关掉验签、不把parseClaimsJws换成parseClaimsJwt(这个不带签名校验,用它等于自废武功),就已经做到了。
但防篡改只是最低要求,真正难的是防重放和防越权。防重放的意思是别人拿到了你的 token 直接复用,这个光靠签名防不住,因为 token 本身是合法的。常见的几种缓解手段:一是缩短有效期,把窗口压到几十分钟;二是给 token 加jti(唯一 ID),服务端对敏感操作做一次性的消费记录;三是对特别敏感的操作要求二次验证,比如改密码、大额转账时重新输密码。第三种虽然麻烦,但安全性提升最明显,金融类系统基本都这么干。
防越权这块,我踩过最深的坑是"信任 token 里的权限"。有段时间我把用户的角色列表放进了 token,接口里直接读claims.get("perms")来判断,结果运营给某个用户降级之后,他手里的旧 token 在过期前还是能拿到管理员权限。后来改成:token 里只放userId这类不变的身份标识,权限每次从缓存或数据库读,缓存加个短 TTL。多一次查询,但换来了权限变更的即时生效,这个交换很划算。
// 不推荐:权限写死在 token 里 boolean canEdit = claims.get("perms", List.class).contains("user:edit"); // 推荐:token 只带身份,权限实时查 Long userId = Long.valueOf(claims.getSubject()); Set<String> perms = permCache.get(userId); // 缓存 TTL 5 分钟 boolean canEdit = perms.contains("user:edit");3.4 Token 续签的两种落地方案
续签是我被问得最多的问题之一。用户正在填一个长表单,token 突然过期了,提交时跳回登录页,体验极差。两种做法,我都在项目里用过。
第一种是滑动过期:每次请求进来,如果 token 距离过期还有不到一半时间,就签发一个新 token 放在响应头里,前端拦截器拿到就替换本地的。这个方案改动小,但有个副作用——只要用户一直有操作,token 就永远不过期,安全边界被拉平了。所以要配合一个绝对过期时间,比如无论怎么滑动,登录后 12 小时必须重新登录。
第二种是双 token:access token 短命(30 分钟),refresh token 长命(7 天)且只存在服务端。前端发现 401 就用 refresh token 换一对新的,换的时候服务端检查 refresh token 是否在 Redis 白名单里,不在就拒绝。这个方案能精确控制下线,也能记录刷新日志,是我更推荐的做法。实现上要注意刷新接口本身要做并发控制,多个请求同时 401 的时候可能触发多次刷新,一般让前端加个队列,刷新完再重放挂起的请求。
// 前端 axios 拦截器骨架:避免并发刷新 let refreshing = false; let queue = []; instance.interceptors.response.use(null, async (err) => { const { config, response } = err; if (response?.status !== 401 || config._retry) return Promise.reject(err); if (refreshing) { return new Promise((resolve) => queue.push({ resolve, config })); } refreshing = true; config._retry = true; try { const newToken = await doRefresh(); queue.forEach(({ resolve, config }) => { config.headers.Authorization = `Bearer ${newToken}`; resolve(instance(config)); }); queue = []; return instance(config); } finally { refreshing = false; } });4. Session 方案的真实适用场景
4.1 Session 存储选型与集群共享
Session 方案的核心就一句话:服务端存数据,客户端拿 ID。看起来简单,但在多实例部署的时候立刻出问题——用户第一次请求打到 A 机器,session 存在 A 的内存里,第二次请求被负载均衡打到 B 机器,B 说"没见过这个 session ID",用户就被踢回登录页了。
解决办法有三种。一是粘性会话,让同一个用户的请求固定打到同一台机器,配置简单但机器挂了会话就丢,扩缩容也不方便,只适合临时方案。二是会话复制,机器之间互相同步 session,节点一多同步开销爆炸,不建议。三是集中存储,把 session 放到 Redis 里,所有节点共享,这是我现在唯一会用的方案。
Spring Boot 里接 Redis 存 session 只需要加依赖和几行配置:
spring: session: store-type: redis timeout: 30m redis: namespace: "app:session" flush-mode: on_save data: redis: host: 127.0.0.1 port: 6379配上@EnableRedisHttpSession注解就完事了,业务代码一行都不用改。这里有个细节值得留意:flush-mode设成on_save可以避免每次请求都全量写回 Redis,减少无谓的网络开销,只在 session 真正被修改时才写。另外namespace一定要设,不然多个应用共用同一个 Redis 实例的时候 key 会互相踩。
4.2 Session 固定攻击的防御思路
Session 固定攻击(Session Fixation)的原理是:攻击者先拿到一个合法的 session ID,想办法让受害者用这个 ID 去登录,登录之后服务端把身份绑在了这个 ID 上,攻击者拿着同一个 ID 就能直接访问受害者的账号。这个攻击成立的前提是服务端在登录前后不换 session ID。
SpringSecurity 默认已经帮你处理了,它会在认证成功时创建新的 session 并迁移数据,配置项是sessionManagement().sessionFixation().migrateSession()(旧版本默认就是这个)。但如果你自己写了登录接口,绕过了 SpringSecurity 的认证流程,比如手动往SecurityContextHolder里塞Authentication,那这个默认行为就不生效了,必须自己手动调用request.changeSessionId()。
// 自定义登录接口里的正确写法 @PostMapping("/login") public Result login(@RequestBody LoginDTO dto, HttpServletRequest request, HttpServletResponse response) { Authentication auth = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword())); // 关键一行:登录成功后换掉 session ID,防固定攻击 request.changeSessionId(); SecurityContextHolder.getContext().setAuthentication(auth); // 显式保存上下文,确保写入 session securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response); return Result.ok(); }这个小动作很多人不知道,出了安全问题才知道后悔。我个人的习惯是,只要涉及会话状态的变更(登录、提权、切换租户),都要重新生成会话标识。
4.3 本地会话资源占用异常的排查思路
热词里有个"local session manager 占用 CPU 过高",这个是 Windows 系统层面的本地会话管理服务,跟 Web 应用的 session 完全是两码事,但既然很多人搜到,我也顺带说一下排查思路,免得混淆概念。
Local Session Manager是 Windows 里负责管理远程桌面和本地登录会话的服务。它 CPU 飙高,通常不是它自己的问题,而是有人在频繁建连断连。排查顺序我一般是:先看任务管理器里的详细信息,确认是lsass.exe还是svchost.exe挂载的这个服务;再查系统日志里的安全事件,看有没有大量重复的登录失败记录,这往往意味着有程序在尝试暴力枚举;接着检查有没有第三方的远程管理软件在后台反复重连;最后考虑是不是系统更新后的驱动兼容问题,重启或者回滚更新能缓解。这里要提醒一句,Windows 的服务和 Java 应用里的HttpSession没有任何关系,搜索资料的时候别被关键词带偏,浪费半天时间。
5. 常见问题与排查技巧实录
5.1 认证类问题速查表
认证这块的问题表现都很像——登录不上、登录上又被踢、接口时不时 401。但原因差得远,我整理了一张表,基本覆盖了我遇到过的九成情况。
| 现象 | 常见原因 | 排查动作 |
|---|---|---|
| 登录一直提示用户名或密码错误 | 密码编码器不一致,存的是 BCrypt 校验用的是别的 | 确认全局只有一个 PasswordEncoder,且注册登录都走它 |
| 登录成功但下一个请求就 401 | token 没放进请求头,或者前缀多了 / 少了 Bearer | 抓包看请求头,确认格式是Authorization: Bearer xxx |
| 时不时 401,刷新一下又好 | 多实例部署,JWT 密钥不一致 | 检查各实例的密钥来源是否统一 |
| 跨域请求全部失败 | OPTIONS 预检被安全过滤器拦了 | 放行 OPTIONS,CORS 过滤器前置 |
| 手机上登录态莫名其妙丢失 | Cookie 的 SameSite 策略限制了跨站携带 | 前后端分离场景建议改用 Header 带 token |
| token 明明没过期却验签失败 | 密钥被重新生成过,或字符编码不一致 | 固定密钥来源,统一用 UTF-8 取字节 |
这张表我建议贴在项目文档里,新人上手能少问一半问题。特别是第一条,我见过太多次了,因为 SpringSecurity 在密码不匹配的时候统一返回"Bad credentials",不会告诉你到底是用户不存在还是密码错了,光看日志根本定位不到。
5.2 授权异常与 403 排查
403 的排查比 401 容易,因为方向明确:身份是认出来了,就是不让你干。常见的几个原因。第一个是权限码对不上,注解里写的是user:add,数据库里存的是user:create,一个字符的差别,查半天。我的习惯是权限码统一用常量类管理,数据库初始化的 SQL 也从常量类对应的文档里生成,避免手写。第二个是权限没加载进去,UserDetailsService里查权限的 SQL 写错了关联条件,返回空列表,结果就是所有人都是零权限。第三个是@PreAuthorize没生效,忘了加@EnableMethodSecurity,或者方法被同类内部调用绕过了代理。
还有一种比较隐蔽的:权限判断通过了,但数据层面还是查不出来。比如接口能访问,但返回的列表是空的,这通常是数据权限的过滤条件写错了,或者多租户的租户 ID 没带进查询。这种问题排查起来最费劲,因为不报错。我的做法是在数据权限的过滤逻辑里加 debug 日志,把最终拼出来的条件打出来,比对一下就知道哪里的条件多加了或者少加了。
提示:把权限不足的场景统一返回"无权访问该资源",不要返回"权限不足,需要 xxx 权限"。后者等于告诉攻击者你的权限模型长什么样,属于不必要的信息泄露。
5.3 几个踩过的坑
第一个坑是关于免认证路径的配置。早期我图省事,用通配符放行了一大批路径,结果某次加了个新的内部接口,路径刚好落在通配符范围内,直接暴露在外网。后来我改成白名单最小化,并且加了一个测试用例,遍历所有 Controller 的映射,检查除了显式声明的公开接口外,其余接口在匿名访问时都必须返回 401。这个测试写起来不复杂,但价值极高,等于给权限配置上了个保险。
第二个坑是登出逻辑。JWT 方案下,登出不能让服务端"删除"token,因为 token 在客户端手里。我最初的做法是前端删掉本地存储就算登出,后来发现这样在共享设备上很危险,而且服务端完全无感知。改进后的做法是:登出时把 token 的jti写进 Redis 黑名单,过期时间设为 token 的剩余有效期,过滤器里顺便查一下黑名单。多一次 Redis 查询,但能实现真正的登出。
第三个坑是时钟问题。JWT 的过期时间依赖服务器时间,如果部署在多台机器上,而机器之间的时间有偏差,就会出现"刚签发就过期"或者"过期了还认"的情况。解决办法很简单,所有机器统一走 NTP 同步,部署脚本里加一步时间校验。这个坑不大,但排查起来绕,因为本地测试永远复现不了。
最后一个是我觉得最值得说的经验:权限模块千万不要等到业务写完再补。我参与过的一个项目就是先做了三个月业务,最后临时加权限,结果每个接口都要回头改,还漏了几个,上线后被安全扫描扫出来一批未授权访问。正确顺序是先定权限码规范、定好拦截骨架、把白名单机制跑通,业务接口照着模板写,后面加权限点只是往表里插数据的事。
如果你现在手里的项目还没做权限,我的建议是先花半天把拦截骨架搭起来,哪怕先只放行所有请求,把规范和结构定下来,也比业务铺开之后再补要轻松十倍。这套东西后续还能自然扩展:加数据权限、加多租户隔离、加审计日志,都是在现有的过滤器链和权限码体系上做加法,不用推倒重来。我自己后来加数据权限的时候,因为前面的骨架留了口子,只改了一个自定义判断组件就接上了,几乎没动业务代码。