1. 项目背景与核心思路:为什么非要用全局过滤不可
先从一个真实的场景聊起。我之前做过一个对外开放的接口平台,刚上线时接口不多,大概六七个,每次加一个接口就复制粘贴一段验签和鉴权代码,逻辑上倒也够用。后来业务快速扩张,接口涨到二十多个,问题就全冒出来了:有的接口记了验签,有的漏了,有的验签顺序和密钥配置不一致,对外部调用方来说,同一个平台的接口签名规则竟然不统一,这种体验非常糟糕。更要命的是,新增接口的人如果只抄了代码、没理解签名规则,审核的时候也很难一眼看出来。
这个项目的目标很简单:把验签、鉴权这些横切逻辑收敛到一个全局过滤器里,所有外部请求统一经过它,验签通过、鉴权通过才放行到业务层。这样一来,业务代码里不需要任何安全逻辑,新增接口也天然受到保护,再也不用担心哪个接口漏了验签。
先说清楚这里面的三个概念,避免后面绕晕。
- 验签:验证请求的签名是否合法。它同时做两件事,一是确认请求确实来自持有合法密钥的调用方,二是确认请求参数在传输过程中没有被改动过。签名验证的是“数据的真实性和完整性”。
- 鉴权:确认调用方是谁、有没有权限访问目标接口。常见做法是通过 Token 或 JWT 识别用户身份,再结合接口权限配置做放行或拒绝。
- 全局过滤:在请求进入业务代码之前,统一拦截和处理。它处在请求生命周期的入口,所有符合条件的请求都会先到这里过一遍。
这三个动作放在一起,才能形成一条完整的请求安全链路。如果只做验签不做鉴权,你只能证明“这个请求没被篡改”,却不知道是谁在调;如果只做鉴权不做验签,Token 再安全,请求参数也可能被恶意篡改。两个必须配合起来,全局过滤则是把它们落到实处的载体。
1.1 逐接口写验签鉴权的痛点在哪儿
很多人会问,为什么非得搞全局过滤器,在项目里写一个拦截器或者在每个接口里手动调用一下不行吗?我举几个实际例子你就明白了。
第一个问题是重复代码。接口验签的逻辑其实相当固定,取参数、排序、拼接、加时间戳和 nonce、算签名、比对。每个接口都写一遍,几十个接口下来就是几十份重复代码,哪天签名规则要升级改造,你得全局搜索所有接口改一轮,改漏一个就出事故。
第二个问题是人为失误。业务开发压力上来了,有些人真的会忘了给新接口加验签。接口一旦裸奔上线,外部调用方可以直接绕过签名规则,把 payload 改了照样能调通。这在生产环境是致命的,尤其是涉及资金、订单、用户信息类的接口。
第三个问题是逻辑不统一。有人把签名字段放进 Header,有人把签名指标记写在参数里,有人验签顺序是先查 Redis 再排序,有人反过来。外部客户对接 A 接口和 B 接口要写两套签名 SDK,骂娘是必然的。
所以全局过滤的本质不是“省几行代码”,而是把安全规则收敛到一个不可绕过、统一执行的边界上。只要过滤器没有 bug,任何请求都跳不过验签和鉴权,业务层面对安全可以完全放心。
1.2 全局过滤的主流程设计思路
设计这个过滤器时,我脑子里先过了一遍完整链路。一个外部请求进来之后,顺序是这样:
- 请求先到过滤器,此时还没有进入 Spring 的 Controller。
- 过滤器检查请求路径是否在白名单里,白名单直接放行。
- 读取原始请求体,缓存一份字节数组,方便后续验签读取,同时保证业务层还能再读一次。
- 从请求头或参数中取出 timestamp、nonce、sign。
- 校验时间戳是否超出允许的时间偏移,防止重放攻击。
- 校验 nonce 是否被使用过,进一步防重放。
- 按约定规则构建待签名字符串,调用验签服务校验签名。
- 签名通过后,再解析 Token,完成鉴权。
- 鉴权通过后的用户信息放入请求上下文和 ThreadLocal,供业务代码直接获取。
- 执行过滤器链,进入 Controller。
- 请求处理结束后,在 finally 中清理上下文。
这个顺序不是随便排的,每一步都有讲究。白名单为什么放在最前面?因为登录、注册、健康检查这类接口本身就没有 Token,也没法用外部密钥验签,它们必须先被排除出去。验签为什么要放在鉴权前?因为如果签名都不过,说明请求来源或数据已经被污染,这时候解析 Token 没有任何意义。防重放为什么要放在签名校验前?因为 nonce 和 timestamp 是签名的组成部分,先确认它们合法,后面构建签名内容才可信。
1.3 技术选型:Filter、Interceptor、AOP 三选一咋选
做全局拦截,技术上一般有三条路:Servlet 的 Filter、Spring MVC 的 Interceptor、Spring AOP 切面。我在设计之初就对比过,这里整理成表格方便你直接看。
| 对比项 | Filter | Interceptor | AOP |
|---|---|---|---|
| 执行时机 | 请求进入 Servlet 容器后、进入 DispatcherServlet 之前 | 请求已进入 Spring MVC,HandlerMapping 定位到处理器之后 | 进入业务方法前后,通过代理实现 |
| 拦截范围 | 能拦截所有请求,包括静态资源、错误转发路径 | 只能拦截经过 DispatcherServlet 的请求,静态资源默认不经过 | 只能切入 Spring 管理的 Bean 方法 |
| 获取请求体 | 能直接读 HttpServletRequest | 能拿到请求对象,但同样读一次流后会丢 | 需要通过参数解析器或封装对象获取 |
| 与 Spring 容器关系 | 可以注册为 Spring Bean,也可以纯 Servlet 方式配置 | 必须由 Spring 管理 | 必须由 Spring 管理 |
| 适合做验签鉴权 | 最适合,因为它是请求最靠前的入口,可以完整控制后续链路 | 合适,但静态资源和错误转发场景会有遗漏 | 不太适合做全局请求级拦截,适合做方法级权限控制 |
所以我选了 Filter。还有一个关键细节:在 Spring Boot 中要使用OncePerRequestFilter,而不是直接实现javax.servlet.Filter。这个类名已经把用途写得很直白了,它的作用就是保证同一个请求在过滤器链中只执行一次。如果直接实现 Filter,遇到 Servlet 容器把请求转发给另一个 Servlet、或者发生错误转发时,过滤器可能会执行多次,重复验签会带来不必要的性能损耗,极端情况下还可能出现第一次验签过了、第二次验签因为流已读而失败的问题。
2. 方案细节:验签、鉴权与放行策略怎么设计
2.1 签名字符串怎么拼才不出错
全局验签最核心、也最容易出问题的地方,就是构建待签名字符串。这一步的规则需要客户端和服务端严格一致,否则两边算出来的签名永远对不上。
我实际项目中采用的约定比较简单,主要有这几条:
- 参与签名的业务参数包括 URL 中的 query 参数和请求体中的原始 body 字符串。如果接口是 JSON 格式,就直接把原始 body 字符串作为一个特殊的
body参数参与签名。 - query 参数按参数名字典序排列,使用
TreeMap自动排序。 - 跳过值为空串或者 null 的参数,避免客户端和服务端处理空值方式不一致导致签名差异。
- 把
timestamp、nonce两个公共参数也拼进待签名内容,防止这两项被篡改。 - 拼上约定的密钥,非对称模式下拼的是客户端私钥签名、服务端公钥验证,对称模式下拼的是共享密钥。
- 参数字段的格式统一是
key1=value1&key2=value2×tamp=xxx&nonce=yyy。
这里要特别提醒一个坑:签名参数本身绝对不能参与签名。假设请求里带了一个sign=abc,如果构建签名内容时把sign也加进去,那你算出来的签名结果理论上永远无法匹配,因为原始签名值本身就是待签名内容的一部分,形成了循环依赖。所以第一步必须把签名参数从参数集合里排除掉。
另外 JSON body 的处理是有讲究的。最粗暴的方式是解析 JSON 之后把内层字段拍平再参与签名,但这种方式对嵌套复杂结构很不友好,字段顺序也容易造成歧义。更稳的做法是直接用原始 body 明文参与签名,客户端在发送请求前拿到的是最原始的字节流,服务端验签时读到的也是原始字节流,只要两边都对原始内容计算摘要,结果就能保持一致。唯一需要注意的是,解析 body 时必须用 UTF-8 编码,一方用了 GBK,签名结果直接翻天覆地。
2.2 验签算法选型:RSA、SM2 还是 HMAC
签名算法这块,我在实际项目中遇到过三种主要选择,这里展开聊一下。
RSA 是非对称算法里最常用的,组合方式通常是 SHA256withRSA。调用方持有私钥,服务端只保存公钥,两者分离。私钥泄露的风险面小,服务端即使被拖库,攻击者拿到的也只是公钥,没法伪造签名。它的缺点是计算相对慢,一次验签大概要消耗零点几毫秒到一毫秒,在高并发场景下是实打实的 CPU 开销。
HMAC 是另一个方向,对称密钥。两边共用同一个 secret,计算速度极快,实现也简单。但问题在于只要服务端泄露了 secret,攻击者就能自己造签名;而且调用方和服务端之间共享密钥,责任边界模糊,出了事很难判断是哪边泄露的。所以我一般只在内部服务之间用 HMAC-SHA256,对外部开放平台用 RSA。
SM2 是国内商用密码标准里的非对称算法,合规性上有天然优势。如果你的项目涉及金融、政务类场景,或者验收方明确要求国密改造,那就需要优先考虑 SM2。技术实现上,SM2 在 Java 里通常要借助第三方密码库来提供底层实现,部署时还需要注意去掉 JDK 的加密强度限制,本质上和 RSA 的流程是类似的,只是算法替换。
我的选择逻辑总结成一句话:对外公网接口,用 RSA 或 SM2,确保调用方身份可追溯;内部服务间调用,用 HMAC-SHA256,性能和简单性优先。如果项目有合规硬指标,那就直接上 SM2,一步到位。
2.3 鉴权方案:Token 里的门道
验签通过之后做鉴权,一般聚焦在怎么解析 Token 上。我项目里用的是 JWT,结构是Header.Payload.Signature三段式,客户端在登录成功后拿一个加密签名的 Token,后续请求放在Authorization: Bearer <token>头里。
JWT 解析其实没什么高深的,核心要验证三件事:签名是否合法、是否过期、是否包含必要的用户标识。签名不合法直接拒绝,过期也拒绝。为了避免 Token 长期生效带来的安全风险,我在实际项目里采用了双 Token 模型:access token 有效期设 15 分钟,refresh token 有效期设 7 天。access token 过期后,用 refresh token 换取新的 access token,尽量保证用户无感刷新,又缩短了核心凭证的暴露窗口。
有个细节很多人会忽略:JWT 一旦签发就无法主动失效,不能像 session 那样说踢就踢。如果你的系统有强制的下线场景,比如用户修改密码后需要让所有旧 Token 失效,那单纯的 JWT 方案是不够的。这时要么引入 Token 黑名单机制,把失效的 jti 存到 Redis 里,要么在每次鉴权时查一下 Redis 中的用户状态。我做了个折中:关键接口鉴权时额外查一次 Redis 用户状态,普通接口只解析 JWT。代价是多一次缓存查询,但安全等级明显提升。
2.4 与网关的重复拦截问题
排查实录里有一个问题我要提前讲,就是微服务架构下业务层过滤器和网关过滤器经常撞车。我见过一个系统,网关已经做了验签和鉴权,下游服务每个模块又各自做了全局验签,结果一次请求被验两次签,耗时翻倍,出了问题还互相甩锅。
后来的处理方式分了几种情况。如果是内部互信的服务集群,下游服务可以信任网关传递过来的用户信息头,业务层过滤器只做解析不做二次验签。如果下游服务还可能被外网直接访问,那验签就必须保留,只是尽量复用网关已经解析好的用户信息,减少重复 CPU 计算。核心原则是:验签要尽可能放在最外层,越靠外拦截越早,成本越低;内层如果确实需要重复校验,那就只做最小必要校验,不要全链路重复。
3. 实操全解:全局过滤器从零到能用的落地过程
3.1 工程准备与关键依赖
在 Spring Boot 项目里落地这个东西,基础依赖很简单。如果是 Spring Boot 2.x 时代,用javax.servlet相关 API;Spring Boot 3.x 之后换成了jakarta.servlet。代码逻辑不变,只是包名换了。JWT 我推荐使用开源库,比如 JJWT,封装度比较高;如果是国密 SM2 场景,还需要引入第三方密码库。Redis 操作用RedisTemplate即可,主要用于 nonce 去重和用户状态查询。
下面进入核心实现。
3.2 全局过滤器主导航代码
主过滤器继承OncePerRequestFilter,整个流程我在前面已经梳理过。代码骨架如下:
@Slf4j @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class ApiAuthFilter extends OncePerRequestFilter { private static final long EXPIRED_MILLIS = 5 * 60 * 1000L; private static final List<String> WHITE_LIST = List.of( "/api/auth/login", "/api/auth/register", "/health" ); @Autowired private SignatureService signatureService; @Autowired private TokenService tokenService; @Autowired private RedisTemplate<String, String> redisTemplate; private final PathMatcher pathMatcher = new AntPathMatcher(); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { long startTime = System.currentTimeMillis(); String requestId = UUID.randomUUID().toString().replace("-", ""); MDC.put("requestId", requestId); HttpServletRequest wrappedRequest = request; try { if (isWhiteList(request)) { filterChain.doFilter(request, response); return; } wrappedRequest = new RepeatedlyReadRequestWrapper(request); boolean signValid = signatureService.verify(wrappedRequest); if (!signValid) { writeError(response, 401, "ILLEGAL_SIGN", "sign verify failed"); return; } AuthUser authUser = tokenService.parseAndVerify(wrappedRequest); if (authUser == null) { writeError(response, 401, "ILLEGAL_TOKEN", "token verify failed"); return; } request.setAttribute("authUser", authUser); AuthContext.set(authUser); filterChain.doFilter(wrappedRequest, response); } catch (Exception e) { log.error("api auth filter error, path={}", request.getRequestURI(), e); writeError(response, 500, "INTERNAL_ERROR", "internal error"); } finally { AuthContext.clear(); MDC.remove("requestId"); long cost = System.currentTimeMillis() - startTime; if (cost > 500 || log.isDebugEnabled()) { log.info("api auth finish, path={}, cost={}ms", request.getRequestURI(), cost); } } } private boolean isWhiteList(HttpServletRequest request) { String path = request.getRequestURI(); for (String pattern : WHITE_LIST) { if (pathMatcher.match(pattern, path)) { return true; } } return false; } private void writeError(HttpServletResponse response, int status, String code, String message) throws IOException { response.setStatus(status); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":" + status + ",\"message\":\"" + message + "\"}"); } }这段代码把主干流程都串起来了,几个关键点我再单独展开讲。
3.3 解决 Body 只能读一次的问题
这是全局过滤器落地时遇到的最经典的问题。ServletInputStream是单向流,流被读取到末尾之后,再读就是 EOF。验签时需要读取请求体内容,业务层的@RequestBody也需要读取请求体,如果过滤器读完流直接放行,Controller 就会收到一个空的 body,报“Required request body is missing”。
解决办法是自定义一个请求包装类,在构造时就先把原始字节流完整读取并缓存起来,后续每次读取都从缓存字节数组返回。代码实现如下:
public class RepeatedlyReadRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public RepeatedlyReadRequestWrapper(HttpServletRequest request) throws IOException { super(request); this.body = request.getInputStream().readAllBytes(); } @Override public ServletInputStream getInputStream() { ByteArrayInputStream bais = new ByteArrayInputStream(body); return new ServletInputStream() { @Override public boolean isFinished() { return bais.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { // 不使用异步读取,空实现即可 } @Override public int read() { return bais.read(); } }; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }这里有一个细节必须强调:readAllBytes()在处理大请求体时会占用内存。如果接口设计上有超大文件上传的场景,需要评估一下内存影响,或者只对受保护的 JSON 接口做缓存,文件上传接口走另一条链路。绝大多数业务接口的 body 都在 KB 级别,内存开销可以忽略。
3.4 验签逻辑完整实现
验签服务的实现是整个全局过滤器的核心。我需要把参数提取、时间戳校验、nonce 去重、签名计算、密钥验证这几个动作完整做一遍。
@Slf4j @Component public class SignatureService { @Value("${api.auth.secret}") private String secret; @Value("${api.auth.public-key}") private String publicKey; @Autowired private RedisTemplate<String, String> redisTemplate; public boolean verify(HttpServletRequest request) { String timestamp = request.getHeader("X-Timestamp"); String nonce = request.getHeader("X-Nonce"); String sign = request.getHeader("X-Sign"); if (StringUtils.isEmpty(timestamp) || StringUtils.isEmpty(nonce) || StringUtils.isEmpty(sign)) { return false; } long clientTime = Long.parseLong(timestamp); long now = System.currentTimeMillis(); if (Math.abs(now - clientTime) > EXPIRED_MILLIS) { return false; } if (!tryConsumeNonce(nonce)) { return false; } String content = buildSignContent(request, timestamp, nonce); return verifySign(content, sign); } private String buildSignContent(HttpServletRequest request, String timestamp, String nonce) { Map<String, String> params = new TreeMap<>(); Map<String, String[]> queryMap = request.getParameterMap(); for (Map.Entry<String, String[]> entry : queryMap.entrySet()) { if (entry.getValue().length > 0) { params.put(entry.getKey(), entry.getValue()[0]); } } String body = getBody(request); if (!StringUtils.isEmpty(body)) { params.put("body", body); } StringBuilder content = new StringBuilder(); for (Map.Entry<String, String> entry : params.entrySet()) { if (StringUtils.isEmpty(entry.getValue())) { continue; } content.append(entry.getKey()).append("=").append(entry.getValue()).append("&"); } content.append("timestamp=").append(timestamp); content.append("&nonce=").append(nonce); content.append("&secret=").append(secret); return content.toString(); } private boolean verifySign(String content, String sign) { try { Signature signature = Signature.getInstance("SHA256withRSA"); signature.initVerify(loadPublicKey(publicKey)); signature.update(content.getBytes(StandardCharsets.UTF_8)); return signature.verify(Base64.getDecoder().decode(sign)); } catch (Exception e) { return false; } } private boolean tryConsumeNonce(String nonce) { String key = "api:nonce:" + nonce; Boolean success = redisTemplate.opsForValue() .setIfAbsent(key, "1", Duration.ofMinutes(6)); return Boolean.TRUE.equals(success); } private String getBody(HttpServletRequest request) { try { if (request instanceof RepeatedlyReadRequestWrapper wrapper) { byte[] bytes = wrapper.getInputStream().readAllBytes(); return new String(bytes, StandardCharsets.UTF_8); } } catch (IOException e) { log.warn("read body failed", e); } return ""; } }这里值得说两件事。
第一,tryConsumeNonce的实现用到了setIfAbsent加过期时间。这一步必须原子完成,不能先SETNX再单独EXPIRE,否则并发下可能出现 Key 刚建好还没设过期时间就宕机,Redis 里残留无用 Key。Redis 的setIfAbsent支持同时指定过期时间,这个用法是标准做法。
第二,buildSignContent中把原始 body 作为body参数参与签名,这是我的个人选择。这样做的好处是简单可靠,不需要递归解析 JSON 再决定哪些字段参与签名;坏处是客户端调整了 JSON 中字段的顺序,签名结果就会变。所以约定里必须写明:JSON body 必须使用原始字符串参与签名,并且传输过程中不能重新序列化。
3.5 鉴权信息解析与线程上下文透传
验签通过之后,过滤器进入鉴权环节。Token 解析部分我用 JWT 库来实现,这里给出关键代码:
public AuthUser parseAndVerify(HttpServletRequest request) { String authorization = request.getHeader("Authorization"); if (StringUtils.isEmpty(authorization)) { return null; } String token = authorization.startsWith("Bearer ") ? authorization.substring(7) : authorization; try { Claims claims = Jwts.parser() .setSigningKey(authSecretKey) .parseClaimsJws(token) .getBody(); Date expiredAt = claims.getExpiration(); if (expiredAt == null || expiredAt.before(new Date())) { return null; } String userId = claims.get("userId", String.class); String role = claims.get("role", String.class); String appId = claims.get("appId", String.class); return new AuthUser(userId, role, appId); } catch (Exception e) { return null; } }解析成功后,用户信息有两个下落。一个是放入 request attribute,业务 Controller 可以通过request.getAttribute("authUser")获取;另一个是放入ThreadLocal封装的AuthContext,方便非 Controller 层(比如 Service)也能拿到当前用户。但ThreadLocal有一个必须处理的隐患:容器线程是复用的,如果请求结束后不清理,下一个请求可能读到上一个用户的信息,就会产生严重的越权事故。
所以过滤器在finally中必须执行AuthContext.clear()。这一点不是可选项,而是必选项。我经历过一次线上串用户的案例,排查了很久才发现是某个拦截器漏了清理,从此所有涉及 ThreadLocal 的代码都强制要求在 finally 里清空。
3.6 统一异常响应与日志落点
全局过滤器的失败响应必须和业务错误格式保持一致。我在项目里约定了一个精简的 JSON 结构,包含 code、message 两个字段。失败时设置对应的 HTTP 状态码,然后直接写响应即可。一次性放行剩下的过滤器链是不必要的,因为验签失败本身就说明请求不该进入业务层。
日志落点这块,我特别重视请求追踪。过滤器里在开始时生成一个 requestId 放入 MDC,这样从过滤器到 Controller、Service 再到数据库访问,全链路日志都带着同一个请求号。排障时直接按 requestId 一查就能还原完整调用链。需要注意的是日志打太多也会拖慢吞吐,所以我去掉了每条请求都info的做法,只对耗时超过 500ms 的请求打印明细,其余按 debug 输出。
4. 常见问题与排查实录:踩过的坑和应对方案
4.1 过滤器没生效怎么排查
最常遇到的尴尬情况是:过滤器写了、注册了,但请求进来完全不经过它。我总结过几类原因。
第一类是类上漏了@Component,或者@Order配得不对,导致过滤器没被 Spring 容器管理。第二类是过滤器注册路径配置错误,比如限定了urlPatterns却漏掉了目标接口的前缀。第三类是在 Spring Boot 中把FilterRegistrationBean的setEnabled(false)了。排查时先看启动日志里有没有ApiAuthFilter的初始化记录,再断点打到doFilterInternal的第一行,基本上能立刻定位问题。
4.2 验签总是失败:编码、排序和密钥
验签失败是全局过滤器上线期间最折磨人的问题,而且失败信息往往只有一句“sign verify failed”,客户端和服务端互相不认账。我把遇到过的原因整理成一张速查表,方便你直接对照。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 客户端在 Windows 验签通过,服务端在 Linux 验签失败 | 字符编码不一致,Windows 上默认 GBK 或带 BOM 的 UTF-8 | 两端强制统一使用 UTF-8,去掉 BOM |
| 签名算法参数相同,偶尔失败 | 参数集合中混入了签名参数本身,或者空值处理不一致 | 排除 sign 参数,统一跳过空串和 null |
| RSA 验签失败 | Base64 字符串带了换行符或 URL 编码 | 客户端 Base64 编码后不要加换行,服务端按原始编码解码 |
| JSON body 部署后失败 | 网关层重新序列化了 JSON,导致字段顺序变化 | 客户端和服务端约定 body 原文参与验签,不重新序列化 |
| 时间窗口内请求偶发失败 | 服务器时间与客户端时间差异较大,或者 nonce 被重复使用 | 同步 NTP 时钟,调整时间偏移阈值,确认 nonce 去重逻辑正确 |
验签调试阶段最实用的技巧是:在服务端过滤器里加一个 debug 日志,把构建出的待签名字符串打印出来,再让客户端打印同样的待签名字符串,两边一对比就知道差在哪里。
4.3 白名单和安全漏洞的边界
白名单设计得过宽会让全局过滤器形同虚设。我见过一个项目,为了省事把/api/**都配到了白名单前缀里,结果内部好几个查询接口裸奔在外,外部调用方只要绕过网关就能直接访问。后来我把所有白名单接口收敛到配置中心,并加了一个审计流程:只有明确不需要鉴权的接口才能申请加入白名单,且每次加白名单都要经过权限评审。
还有一个容易被忽略的是方法级白名单。有些接口 GET 请求允许匿名访问,但 POST 请求必须鉴权。如果白名单只按路径匹配,POST 请求也会被一起放行。所以白名单判断时我建议把 HTTP method 也纳入条件,宁可多写几条规则,也不能留下安全隐患。
4.4 性能损耗与优化手段
全局过滤器对性能的影响主要在三个地方:请求体重读、RSA 验签、Redis nonce 查询。
请求体重读本身影响较小,就是一次字节数组复制,但如果业务接口有大报文上传,这个开销会放大。优化思路是白名单接口直接放行,不包装请求体;对于必须验签的接口,限制 body 大小,超过阈值直接拒绝。
RSA 验签是计算密集操作。优化手段主要有两个:一是把公钥对象解析结果缓存起来,复用PublicKey实例,不要每次请求重新解析公钥字符串;二是如果并发量非常高,可以把非对称验签结果做短期缓存,比如同一签名和相同参数在很短时间内直接放行。但这会引入额外的缓存一致性判断,权衡下来我选择只做公钥缓存,避免各种边界问题。
Redis nonce 查询每次消耗一次网络往返。极端性能场景下,可以改成基于本地内存的去重,加上定时清理,但多实例部署时本地去重无法全局生效,会有重复请求漏过去。我的选择是:一般项目用 Redis,单实例且性能敏感时再做本地优化。
4.5 过滤器、拦截器、AOP 的执行顺序
这个知识点是新手上路最容易踩的。Filter 执行是 Servlet 容器层,最先跑;然后才轮到 DispatcherServlet 内部的 Interceptor;最后才是 AOP 代理。三者执行顺序粗略如下:
Filter 最先执行,然后是 Interceptor 的 preHandle,再进入 Controller 方法时外层 AOP 切面生效,方法执行完毕后 AOP 返回通知执行,接着 Interceptor 的 postHandle、afterCompletion,最后回到 Filter 的链路。
在全局验签过滤器里,你只需要关心 Filter 这一层。但在排查问题时,如果发现请求在 Interceptor 或 AOP 中被拦截了,要记住它肯定已经先经过了你的验签过滤器,所以问题定位的顺序应该是 Filter 层没有放行、Interceptor 配置错误、还是 AOP 切面逻辑误杀。别一上来就钻进 Interceptor 里找原因,先确认链路最前面的关卡是否真的通过了。
5. 复盘与后续演进建议
5.1 全局过滤能替代细粒度权限控制吗
经常有人把全局验签鉴权和细粒度权限控制混为一谈。实际上这两者解决的问题不同。全局过滤器解决的是“这个请求是否来自合法调用方、是否携带有效身份凭证”,它只能回答“你是谁”和“请求是否可信”。而一个用户登录后能访问哪些资源、能操作哪些数据,属于细粒度权限控制,需要结合 RBAC 权限模型、数据权限规则、接口权限注解去做,全局过滤器管不了这么细。
我实践中是这么分工的:全局过滤器负责入口验签、身份识别、以及路径级的粗粒度权限判断;Controller 层或 Service 层再通过注解和自定义权限校验器做精细控制。这样既有统一的安全边界,又不会让过滤器变成一个无所不包的巨型逻辑块。
5.2 给后来者实操的三条建议
第一条建议是不要一开始就追求最复杂的方案。先把最基础的全局过滤器跑通,用 HMAC 或 RSA 二选一,白名单写死在配置里,日志尽量详细,先保证链路是通的。第二步再引入 Redis nonce 去重、双 Token、动态白名单这些增强能力。一次铺太大,出了问题时因为变量太多,很难定位根因。
第二条建议是配置外置和监控先行。密钥、过期时间、白名单列表都应该放到配置中心,而不是硬编码在代码里。因为签名密钥一旦泄露,你需要在不重新发布代码的情况下紧急更换密钥。同时给过滤器加上计数器指标,比如每分钟验签成功数、失败数、Top 耗时接口等,你才知道这个安全屏障的运行状态。
第三条建议是必须写一个外部调用方的 Demo。我建议在项目里放一个简单的客户端示例,展示从生成密钥、构造签名、发送请求到处理响应的完整过程。这个 Demo 不仅是给对接方看的,也是给自己回归测试用的。每次改了签名规则,先跑一遍 Demo 确认服务端验签逻辑没有破坏,再放出去对接真实业务。
5.3 最后分享一个小技巧
调试这个全局过滤器时,有一个小技巧很省时间:在验签失败分支里不要把完整签名打印到 error 日志,因为调用方很可能传上来一串很长的 Base64 字符串,刷屏不说还意义不大。我习惯把失败原因分成四个维度记录:时间戳是否过期、nonce 是否重复、参数列表缺失哪些 Headers、签名比对是否失败。这样客户端反馈“验签不过”时,你能立刻根据日志判断卡在哪一步,而不是拿两个超长字符串去肉眼比对。
另外如果你用的是 Spring Boot 3.x,OncePerRequestFilter的包路径变成了jakarta.servlet,代码逻辑完全一致,但移植时记得改 import。我帮同事排查过一次,他把 2.x 的过滤器原样粘到 3.x 工程里,报了一系列 NoClassDefFoundError,就是因为这两个包名的差异。
全局过滤这个项目做下来,最大的感受是:安全的东西一定要前置收敛,不要散落到业务代码里。过滤器是这个思想的载体,但真正让过滤器发挥作用的,是清晰的路径顺序、严格的参数约定,以及上线后持续的监控和复盘。