跳到正文

认证、加密与端点安全 ​

一句话:应用认证把「内网即可信」换成零信任式的显式校验——身份、签名、暴露面各收一道口;换来的是可审计、可阻断,代价是密钥与证书的运维成本,以及每次调用多一次校验。

适用版本:Spring Boot 3.2.x / Spring Security 6.x / Spring Cloud Gateway 4.x。示例中的请求头 x-app-id、x-token、x-timestamp、x-nonce、x-signature 均为示例约定,落地时按团队规范统一命名。

为什么需要 ​

威胁模型先摆清楚:内网不等于可信。被攻陷的 Pod、被借用的跳板机、横向移动的攻击者都在内网,默认放行等于把边界交给运气。

没有这层设计时,常见四个口子:

  • 绕过网关直连服务:网关做了鉴权,服务却信任任何来源,攻击者拿到服务地址即可跳过入口。
  • Actuator 裸奔:env 能读配置、heapdump 能导内存、loggers 能改日志级别、threaddump 能看调用栈。
  • 明文密钥:密钥写进配置文件、镜像层或启动脚本,仓库和日志各留一份。
  • 重放:抓到一次合法请求,原样重发就能再次转账或下单。

核心机制 ​

三种凭证各管一段 ​

  • AppId + Token 回答「是谁」:调用方声明身份,服务端查映射表。简单,但 Token 泄露后拿到的人就是该应用。
  • 双向 TLS(mTLS) 回答「凭证是否可信」:双方在传输层出示证书,由 CA 链校验端点身份。它保护通道,不保护单次请求内容。
  • 签名 回答「请求是否被篡改或重放」:对规范化后的请求要素做 HMAC/RSA 签名,服务端重算比对。签名不加密内容,也不能替代 TLS。

生产常见组合是「mTLS 或 Token 定身份 + 签名防篡改 + TLS 防窃听」,不是三选一。

Token 生命周期 ​

申请 → 进程内缓存 → 过期前刷新 → 刷新失败降级。缓存带过期时间,不要每个请求都打认证服务;提前刷新取剩余寿命的 20% 左右(经验值)。多实例同时刷新会放大认证服务压力,加随机抖动或由边车统一取。刷新失败先继续用未过期 Token 并重试告警,已过期则拒绝。Token 不能写死在代码、镜像或 Dockerfile 里,只能来自 KMS、Vault 或注入的环境变量。

签名:先规范化,再谈安全 ​

参与签名的字段至少包括 method、path、排序后的 query、body 的 SHA-256、timestamp、nonce、appId。字段用固定分隔符拼接,query 按 key 排序并统一编码,body 先哈希避免大报文。漏签任何一项,攻击者就能改它。

服务端校验三件事:时间戳落在窗口内(常见 ±300 秒,经验值)、nonce 未见过(Redis SET NX + TTL 取两倍窗口)、签名一致(常量时间比较)。窗口用于容忍时钟偏移,不是越宽越好;节点必须跑 NTP,偏移超阈值要告警。

网关与服务两层 ​

网关做统一认证,服务仍要校验身份,否则绕过网关直连就完全裸奔。白名单只放健康检查和极少数公开接口,绝不能放管理接口;网关路由要显式排除 /actuator。两层校验看似重复,实则是「入口收敛」和「纵深防御」两件事。

Actuator 是最大的意外暴露面 ​

Spring Boot 3.x 默认只暴露 health。env、heapdump、threaddump、loggers 必须鉴权且只允许内网访问;管理端口与业务端口分离,网关不转发管理路径。开 * 之前先想清楚谁能在网络上到达这个端口。

配置、密钥与数据 ​

配置中心只存密文,主密钥放 KMS/Vault,启动时解密;轮换要新旧密钥并存并灰度。密钥不能进日志、异常堆栈和监控标签。服务间敏感流量走 TLS/mTLS;落库敏感字段加密、展示脱敏;审计日志记录谁在何时访问了什么。

失败时默认拒绝 ​

认证服务不可用时默认拒绝,不放行。刷新失败重试并告警;时钟漂移会让签名集体失败,监控要能区分「签名错误」和「时间戳过期」,否则排障方向会反。

日志脱敏 ​

先列出口,再谈规则:手机号、证件号、卡号、密码、Token、密钥在日志正文、异常堆栈、审计日志三个出口都要脱敏。异常堆栈最容易被忽略:把请求对象 toString() 拼进异常,等于绕过日志配置直接落盘。审计日志要保留「谁在何时访问了什么」,但对客体标识做掩码,不能因为叫审计就存全量明文。

在哪一层做:脱敏放在日志框架的 appender/filter(如 Logback 的 PatternLayout 转换器或自定义 TurboFilter)或统一日志网关,而不是每个业务调用点手写 mask()。调用点脱敏一定会漏,新增代码也不会自动继承。正则适合兜底识别无固定格式的证件号、卡号;注解或结构化标签适合已知字段名(phone、idNo),取舍是覆盖率与可维护性:正则面广但易误伤,标签精确但依赖命名规范,常见做法是结构化字段优先、正则兜底。

性能与可追溯:避免对整条日志做全量正则回溯,尤其不要用嵌套量词匹配超长请求体;先按字段名命中,再对候选值做有限长度正则。脱敏不可逆,排障需要关联时保留哈希(如 SHA-256 前 8 位)或分段保留(前 3 后 4),哈希要带盐、不可反查。不要记录完整请求体;确需采样时只记字段名、长度和哈希。

签名在网关验一次,服务还要再验一次身份:前者挡住入口的伪造与重放,后者保证绕过网关的直连同样被拒。Token 与签名是两条独立链路——Token 解决是谁,签名解决这次请求是否完整、新鲜;只做其中一个,另一个攻击面仍然敞开。

实战场景 ​

一、红队没碰网关,直接调到了内部接口 ​

现象:年度渗透演练里,攻击者从一台被控制的边车容器出发,未经过网关,直接 curl http://10.32.7.19:8080/admin/user/list 拿到用户列表;随后又访问同实例的 9081/actuator/env,读出数据库口令明文。整套应用认证(Token、签名)一行代码没被绕过——因为它根本没被调用。

排查:认证发生在哪一层,决定它是否可被跳过。

定位层取证据判定
服务的鉴权来源服务是否只依赖网关注入的头,而不自行校验“有头就放行”等于把身份交给任意来源
网络可达性在集群内另一 Pod 上 curl 目标服务端口与管理端口可达即没有 default deny
管理端口暴露curl /actuator 列表、management.endpoints.web.exposure.include 实际值出现 env/heapdump 即为高危
路由转发规则网关路由是否显式排除 /actuator/**未排除则管理接口经入口可达
白名单范围放行清单里是否有 /**、/admin/** 或健康检查以外的路径白名单扩张是最常见的退化路径

本例三处同时命中:服务信任网关注入的身份头、NetworkPolicy 缺失、管理端口与业务端口共用且暴露 env。

对症做法:

java
// 1. 服务侧二次校验身份:网关认证是入口收敛,服务自检是纵深防御
@Component
class InternalAuthFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest r, HttpServletResponse s, FilterChain c)
            throws ServletException, IOException {
        // 校验 mTLS 对端证书或内部 Token,缺失/无效直接 401,不看“是不是内网”
        if (!verifier.verify(r)) { s.sendError(401, "unauthenticated origin"); return; }
        c.doFilter(r, s);
    }
}
yaml
# 2. 管理端口隔离 + 默认只暴露 health
management:
  server:
    port: 9081
    address: 127.0.0.1          # 只允许本机抓取;跨机抓取改内网地址并加网络策略
  endpoints:
    web:
      exposure:
        include: health          # 不要写 *
java
// 3. 网关侧不转发管理路径:高优先级 GlobalFilter 直接拒绝,避免误配路由
public class BlockManagementPathFilter implements GlobalFilter, Ordered {
    public int getOrder() { return -200; }                      // 早于路由转发
    public Mono<Void> filter(ServerWebExchange ex, GatewayFilterChain chain) {
        if (ex.getRequest().getPath().value().startsWith("/actuator")) {
            ex.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
            return ex.getResponse().setComplete();              // 不进入任何后端路由
        }
        return chain.filter(ex);
    }
}
yaml
# 4. 网络默认拒绝:只放通“调用方 → 服务业务端口”和“监控 → 管理端口”
kind: NetworkPolicy
spec:
  podSelector: { matchLabels: { app: user-service } }
  policyTypes: [Ingress]
  ingress:
    - from: [{ podSelector: { matchLabels: { app: api-gateway } } }]
      ports: [{ port: 8080 }]
    - from: [{ namespaceSelector: { matchLabels: { name: monitoring } } }]
      ports: [{ port: 9081 }]

复盘要点:判据是“绕过入口能否成功”:每次演练都要重复那一发 curl,从非网关 Pod 访问业务端口与管理端口,预期 401/连接被拒。把“服务自检存在 + NetworkPolicy 覆盖 + 管理端口绑本机”三项写进新服务上线门禁;端点清单要定期拉取核对,见 企业级可观测性与故障定位。

二、一台机器 NTP 失联,签名集体 401 ​

现象:凌晨开始某调用方 30% 的请求返回 401,且比例随实例数增长;白天正常、夜间集中。值班第一反应是“密钥被改了”,回滚密钥版本无效;平台组准备排查签名算法时,发现失败全部来自同一个 Pod。

排查:401 有三种成因,混在一个指标里就会把排障方向带偏。

失败原因判定证据特征
时间戳过期日志里 `now - ts
签名不匹配canonical 串逐字段对比(method/path/query/bodyHash)与发布相关,跨实例均匀分布
重放被拦nonce 冲突计数(409 而非 401)与重试或客户端队列积压相关
缺头请求头完整性检查命中数多为客户端版本或代理 strip
密钥缺失/版本错按 appId 与密钥版本维度统计失败率只影响单一调用方

本例根因是该节点时钟漂移 +7 分钟:时间戳窗口设 ±300 秒,落在窗口外直接拒绝。所有业务逻辑都正常,失败原因却显示成“认证失败”。

对症做法:

java
// 1. 失败原因分开计数,绝不合并成一个 “auth_failed”
metrics.counter("auth.reject", "reason", "timestamp_expired").increment();
metrics.counter("auth.reject", "reason", "bad_signature").increment();
metrics.counter("auth.reject", "reason", "replay").increment();
metrics.counter("auth.reject", "reason", "missing_header").increment();
// 维度按 appId + 实例,不带高基数的 nonce/签名值
yaml
# 2. 时钟成为被监控项:偏移超阈值直接告警,而不是等认证失败率上升
- alert: NodeClockSkew
  expr: abs(node_clock_offset_seconds) > 2
  for: 2m
java
// 3. 窗口大小要按“最大可接受重放风险”定,不要为绕过时钟问题无限放宽
//    ±300s(经验值)配合 nonce TTL = 两倍窗口;放宽窗口等于延长重放可用期
if (Math.abs(now - ts) > WINDOW) reject("timestamp_expired", diff);

窗口与 nonce TTL 是一对参数:窗口放宽而 TTL 不变,就会出现“时间戳合法但 nonce 已过期”或反向的语义漏洞。

复盘要点:监控要能回答“这是密钥问题还是时钟问题”。判据是四类失败原因各自的曲线,任何一类突增都有独立 runbook;同时把“认证失败率按实例维度”做成默认视图,本次故障在有 30% 失败时就能定位到单个 Pod,而不是先怀疑密钥。跨语言调用要先统一传绝对 epoch 毫秒与 NTP 要求,见 全链路超时与自适应限流。

三、异常堆栈把卡号写进日志:脱敏漏在第二个出口 ​

现象:合规抽查发现生产日志里存在完整卡号与身份证号。业务代码里所有 log 都调用了 MaskUtil.mask(),看不出问题;追到具体条目,来源是一次支付异常——throw new PayFailedException("refund failed for " + request),request.toString() 把整个 DTO(含卡号明文)拼进了异常 message,堆栈落盘时绕过了日志格式里的脱敏规则。

排查:出口有三个方面,只配了一个就会漏。

出口是否受日志 pattern 控制常见漏点
日志正文是(appender 的正则/转换器生效)新增字段命名不规范,正则匹配不到
异常 message 与堆栈部分不受控:toString() 拼进 message 后按整行输出DTO 未重写 toString,Lombok @Data 全字段输出
追踪标签与指标是(但常被忽略)span tag 记录请求参数、orderId 之类高基数值
审计日志独立存储,独立规则“审计”名义下存全量明文

对症做法:

java
// 1. 敏感 DTO 显式控制字符串形态,默认不输出字段值
@Override public String toString() {
    return "PayReq{orderId=" + orderId + ", card=" + MaskUtil.maskCard(cardNo)
         + ", len=" + cardNo.length() + "}";       // 长度与掩码,不保留原文
}

// 2. 异常只带请求 ID,细节进受控日志;错误响应不回显内部信息
throw new PayFailedException("refund failed, reqId=" + req.requestId(), cause);

// 3. 脱敏统一在框架层做,不靠调用点:Logback 转换器 + 日志网关兜底
//    已知字段名走结构化标签,未知格式走有限长度正则兜底
public class MaskingLayout extends PatternLayoutBase<ILoggingEvent> { /* 命中字段名后再正则 */ }
text
4. 关联排查需求用不可逆摘要:SHA-256 加盐取前 8 位,或前 3 后 4 分段保留
   正则避免对整条长报文做嵌套匹配,先按字段名命中再对候选值限长处理

审计日志要保留“谁在何时访问了什么”,但对客体标识做掩码;名字叫审计不代表可以存全量明文。

复盘要点:脱敏覆盖率要能被验证:在测试日志与异常样本上跑一次扫描(手机号、证件号、卡号、token、密钥片段五种模式),命中即为不通过,把这条挂进 CI。规则落点也要统一——出口层(appender/日志网关)做兜底,字段层(结构化标签)做精确,调用点手写 mask() 一定会漏,新代码也不会自动继承。密钥与 Token 同样不能进日志、异常与监控标签,见 配置中心。

最小上手 ​

Servlet 过滤器骨架(≤30 行,省略 body 缓存、密钥加载与异常处理):

java
// 示例约定:x-app-id / x-timestamp / x-nonce / x-signature,密钥来自 KMS
public class AppAuthFilter extends OncePerRequestFilter {
    private final SecretStore secrets;   // 按 appId 取密钥,绝不写死
    private final NonceStore nonces;     // Redis SET NX,TTL 取两倍时间窗
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        String appId = req.getHeader("x-app-id"), ts = req.getHeader("x-timestamp");
        String nonce = req.getHeader("x-nonce"), sign = req.getHeader("x-signature");
        if (appId == null || ts == null || nonce == null || sign == null
                || Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(ts)) > 300) {
            res.sendError(401, "missing or expired"); return;
        }
        String canonical = String.join("\n", req.getMethod(), req.getRequestURI(),
                canonicalQuery(req.getQueryString()), sha256(readBody(req)), ts, nonce, appId);
        if (!constantTimeEquals(hmacSha256(secrets.get(appId), canonical), sign)) {
            res.sendError(403, "bad signature"); return;
        }
        if (!nonces.putIfAbsent(appId + ":" + nonce, 600)) {
            res.sendError(409, "replay"); return;
        }
        chain.doFilter(req, res);
    }
}
// 省略:canonicalQuery、sha256、readBody、hmacSha256 与异常处理

验证方式与预期:

bash
TS=$(date +%s); NONCE=$(openssl rand -hex 8)
# 用同一 canonical 串与测试密钥算出 SIGN
curl -i http://127.0.0.1:8080/api/orders \
  -H "x-app-id: demo-app" -H "x-timestamp: $TS" \
  -H "x-nonce: $NONCE" -H "x-signature: $SIGN"
# 预期 200;把 TS 改成 10 分钟前 → 401;重放同一组头 → 409

Actuator 最小暴露:

yaml
management:
  server:
    port: 9081              # 管理端口与业务端口分离
  endpoints:
    web:
      exposure:
        include: health     # Spring Boot 3.x 默认仅 health,不要写 "*"

生产动作 ​

  • 轮换与灰度:签名密钥 90 天、Token 30–90 天、证书按 CA 策略(均为经验值)。步骤是「发布新密钥 → 双密钥验签 → 切换签发 → 观察 → 下线旧密钥」,旧密钥至少保留「两倍时间窗 + 最长 Token 有效期」,避免在途请求失败。
  • 监控三类信号:认证失败率(按 appId 和 URI 维度)、签名失败原因(过期/签名错/重放/缺头)、重放拦截数。失败率突增先查时钟与密钥版本,再查调用方发布。
  • 端点审计:定期拉取 management.endpoints.web.exposure.include 与网关路由,确认没有管理路径被转发;新服务上线前检查管理端口是否只绑内网。
  • 脱敏检查:日志、异常响应、追踪标签里不得出现 Token、密钥、完整身份证号;错误信息只给请求 ID,不回显密钥或内部地址。

边界 ​

  • 自建认证有持续成本:登录态、密钥、审计、漏洞响应都要人维护;团队规模小、需求标准时,成熟 IdP 或网关插件通常更划算。
  • mTLS 的复杂度在证书运维:签发、轮换、吊销、信任链更新,没有自动化就不要手工铺;服务网格能分担一部分,但引入新的运维面。
  • 签名不能替代 TLS:签名保完整性和来源,不保机密性;没有 TLS,Token 和报文仍然可被旁观。反过来,只有 TLS 也不能阻止应用层重放。
  • 按敏感度和攻击面取舍:公开只读接口不必上全套 mTLS;能读配置、导内存的管理端点则应按最高等级保护。安全措施要成对看待收益与运维代价。入口设计见服务网关,密钥存放见配置中心。

讲清楚 ​

内网不可信:攻击者能绕过网关直连服务、打 Actuator。分三层:AppId 加 Token 或 mTLS 解决「是谁」,签名解决请求是否被改、是否重放,TLS 解决传输是否被窃听。Token 要缓存并提前刷新,失败默认拒绝;签名要把 method、path、query、body 哈希、时间戳、nonce 规范化,再用时间窗和 nonce 去重。网关认证不能代替服务自检,Actuator 默认只开 health。代价是密钥与证书的运维成本,应按敏感度和攻击面取舍,小系统不必全套 mTLS。

把一门技术讲透到既懂也会用