主题
认证、加密与端点安全
一句话:应用认证把「内网即可信」换成零信任式的显式校验——身份、签名、暴露面各收一道口;换来的是可审计、可阻断,代价是密钥与证书的运维成本,以及每次调用多一次校验。
适用版本: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: 2mjava
// 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;重放同一组头 → 409Actuator 最小暴露:
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。