微服务网关安全层设计:切面处理器详解(下)

摘要: 网关同时承担限流、参数校验、调用方认证、用户认证、请求解密、响应加密和日志记录时,真正困难的是顺序、短路和初始化时机。本文结合 MetaLite 的处理器链,提炼 9 个 Handler 的职责,并说明两阶段初始化为什么存在。

一、先看完整顺序

请求进入业务方法前,处理器按顺序执行:

节点限流
  → 参数校验
  → 调用方认证
  → 用户认证
  → 业务参数解密
  → 业务方法
  → 响应状态处理
  → 响应加密
  → 明文清理与日志收尾

顺序不是装饰:

  • 限流放前面,可以尽早拒绝过载请求;
  • 认证必须先于解密,否则无法获得调用方算法与密钥;
  • 日志需要看到必要上下文,但不能长期保存明文;
  • 明文清理必须发生在响应加密完成之后。

二、节点限流:先保护当前网关实例

NodeRateLimitHandler 使用进程内 RateLimiter

Resp<?> nodeRateLimit() {
    return rateLimiter.tryAcquire()
            ? Resp.success()
            : Resp.error(ErrorCode.RATE_LIMIT_NODE);
}

源码中的 RateLimiter.create(500) 只是初始速率,后续可以通过配置调整,不是不可降低的硬下限。

进程内限流保护的是单个节点。多节点总配额、调用方配额和用户配额仍需要其他计数方式。

三、参数类型决定认证深度

ApiReceiveParamHandler 负责通用字段检查;后续处理器再根据 Controller 参数类型判断是否需要用户认证:

ExternalLoginReq loginReq = findParam(ExternalLoginReq.class);
if (loginReq != null) {
    return userAuth(loginReq);
}
return Resp.success();
  • ExternalReq:只验证调用方;
  • ExternalLoginReq:继续验证用户 Token。

这种方式减少了额外注解,但也把安全级别绑定到参数类型。接口评审时必须检查方法签名是否选对。

四、调用方认证不是只查一个 appId

CallerAuthHandler 的主要链路包括:

  1. 查询调用方配置;
  2. 检查启用状态;
  3. 校验时间窗口;
  4. 校验来源 IP;
  5. 按固定字段顺序复算请求摘要;
  6. 执行调用方限流。
Resp<?> preHandle(ExternalReq req) {
    Caller caller = callerAuthService.getCaller(req.getAppId());
    checkStatus(caller);
    checkTimestamp(req);
    checkIp(req, caller);
    checkSign(req, caller);
    return callerRateLimit(req, caller);
}

需要明确:当前实现是共享秘密参与的自定义摘要校验,不是标准 HMAC,也没有 nonce。时间窗口只能限制旧请求,不能阻止窗口内的重复提交。

五、用户认证是额外一层,不替代调用方认证

开放接口可能只识别合作方,也可能同时要求终端用户身份。

ExternalReq       → 调用方认证
ExternalLoginReq  → 调用方认证 + 用户 Token

两层身份解决不同问题:调用方回答“哪个系统在调用”,用户 Token 回答“代表哪个用户操作”。不能用其中一层代替另一层。

六、为什么认证必须先于请求解密

BizParamDecryptHandler 需要调用方配置中的算法、模式和密钥。因此只有调用方身份确认后,才能选择 SM4、AES 及对应模式。

String plaintext = switch (caller.getEncryptAlgorithm()) {
    case "SM4-GCM" -> SM4.decryptGcm(req.getEncryptData(), secret);
    case "AES-GCM" -> AES.decryptGcm(req.getEncryptData(), secret);
    default -> throw new ServiceException(ErrorCode.ALGORITHM_NOT_SUPPORT);
};
req.setPlaintext(plaintext);

解密成功只说明密文和认证标签匹配,不代表请求具备业务幂等性,也不能替代 TLS。

七、响应阶段为什么需要三个动作

业务执行完成后,框架并不是简单把对象返回:

  1. ApiRespStatusHandler 整理响应状态;
  2. ApiRespEncryptHandler 按调用方配置加密响应数据;
  3. ApiClearRespDataHandler 在适当时机清理明文字段。
if (resp.getEncryptData() != null) {
    resp.setData(null);
}

清理明文的主要价值是减少明文继续进入响应序列化和后续日志的机会,而不是显著降低内存占用。

八、fail-fast 之后为什么还要执行日志处理器

任一前置 Handler 返回失败,链会停止,业务方法不会执行。

但如果失败路径完全跳过后续处理,网关只会留下一个错误响应,没有认证失败和限流拒绝记录。因此实现会保留必要的日志收尾:

for (AspectHandler handler : handlers) {
    Resp<?> result = handler.preHandle(context);
    if (!result.isSuccess()) {
        runRequiredLogHandlers(context, result);
        return result;
    }
}

日志处理仍要遵守最小化原则:摘要、密钥、Token 和完整明文不应默认写入日志。

九、为什么需要两阶段初始化

处理器链遇到的难点是 Spring 生命周期:部分 Bean 会在 @PostConstruct 中触发 AOP,而完整的 Handler 列表要等所有单例创建后才能确定。

MetaLite 分两步处理:

  1. ensureInitialized():初始化期间临时装载当前已有 Handler,保证链可用,但不设置最终标记;
  2. afterSingletonsInstantiated():全部单例完成后再次扫描、去重、排序,随后设置 initialized=true

可以把它理解为:第一次解决“不能空”,第二次解决“必须完整且有序”。

这种设计降低了初始化期空指针风险,也增加了生命周期复杂度。更稳妥的验证应覆盖:

  • @PostConstruct 中触发切面;
  • Handler 延迟注册;
  • 相同 Order 的稳定顺序;
  • 初始化失败后的重试与告警。

十、本篇结论

处理器链的价值不是把逻辑拆成 9 个类,而是建立可验证的执行契约:

  • 低成本检查优先;
  • 身份确认先于密钥使用;
  • 任一失败立即短路;
  • 失败路径仍保留必要日志;
  • 响应加密后及时清理明文;
  • 初始化期间可用,全部单例完成后再形成最终顺序。

讨论: 如果新增一个“防重复提交”Handler,它应该放在验签之前还是之后?答案取决于它需要哪些可信字段,以及希望在多早阶段拒绝请求。


框架简介:元界 MetaLite — 下一代企业级 Java 微服务技术底座

作者简介:基于 Spring 体系 15 年企业级开发经验,专注于通过企业级生产环境落地的工程思维和架构思想打造下一代 Java 微服务技术底座

完整文档与源码:Gitee 搜索 MetaLite(https://gitee.com/MetaLite)

更多推荐