微服务安全实战——服务间认证、API 签名、Zero Trust 架构落地
微服务安全实战——服务间认证、API 签名、Zero Trust 架构落地
适读人群:需要解决微服务安全问题的 Java 后端开发者和架构师 | 阅读时长:约18分钟 | 核心价值:建立微服务安全体系,从服务间认证到 Zero Trust 的完整工程实践
那个没有认证的内部接口
前年,我负责一个项目的安全审计。系统是一套微服务架构,有 30 多个服务。
我做的第一件事,是访问每个服务的 IP + 端口,看看不经过 API 网关、直接打内部服务,会发生什么。
结果让我出了一身冷汗:90% 的服务都可以在没有任何认证的情况下直接访问,包括:
- 用户服务:可以直接查任意用户的信息
- 订单服务:可以直接修改任意订单的状态
- 支付服务:可以直接查看支付流水
开发团队的解释是:"这些服务部署在内网,外网访问不了。"
我问:那你们的 K8s 集群是完全隔离的吗?如果一个边缘服务(比如活动服务)被 RCE 了,攻击者可以从内网直接访问这些没有认证的服务吗?
沉默。
"内网安全"是一个幻觉,这就是 Zero Trust 安全模型诞生的根源:永不信任,始终验证(Never Trust, Always Verify)。
微服务面临的安全威胁
微服务架构引入了单体应用不存在的安全挑战:
- 服务间无认证:内部服务互相调用没有身份验证
- 过度授权:服务 A 可以调用服务 B 的所有接口,即使 A 只需要 B 的某一个接口
- 敏感信息暴露:日志、调试接口可能暴露敏感数据
- 横向移动:一个服务被攻破后,攻击者可以访问所有其他服务
- API 滥用:外部 API 没有限流和签名验证,可被滥用
方案一:服务间 mTLS 认证
mTLS(双向 TLS)是微服务间认证最强的方案。服务调用时,双方都验证对方的证书,确保只有持有合法证书的服务才能互相通信。
如果使用 Istio,mTLS 是内置功能(见 article-850);如果没有服务网格,可以手动配置。
Spring Boot 配置 mTLS
# 服务端配置
server:
ssl:
enabled: true
key-store: classpath:server.p12
key-store-password: ${SSL_KEYSTORE_PASSWORD}
key-store-type: PKCS12
key-alias: order-service
# 要求客户端提供证书(mTLS)
client-auth: need
trust-store: classpath:ca-truststore.p12
trust-store-password: ${SSL_TRUSTSTORE_PASSWORD}@Configuration
public class HttpClientConfig {
/**
* 配置 RestTemplate 使用 mTLS
*/
@Bean
public RestTemplate mtlsRestTemplate(
@Value("${client.keystore.path}") String keystorePath,
@Value("${client.keystore.password}") String keystorePassword,
@Value("${client.truststore.path}") String truststorePath,
@Value("${client.truststore.password}") String truststorePassword) throws Exception {
// 加载客户端证书
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(keystorePath)) {
keyStore.load(fis, keystorePassword.toCharArray());
}
// 加载信任的 CA 证书
KeyStore trustStore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(truststorePath)) {
trustStore.load(fis, truststorePassword.toCharArray());
}
SSLContext sslContext = SSLContexts.custom()
.loadKeyMaterial(keyStore, keystorePassword.toCharArray())
.loadTrustMaterial(trustStore, null)
.build();
CloseableHttpClient httpClient = HttpClients.custom()
.setSSLContext(sslContext)
.setSSLHostnameVerifier(SSLConnectionSocketFactory.getDefaultHostnameVerifier())
.build();
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory(httpClient);
return new RestTemplate(factory);
}
}方案二:JWT 服务间认证
比 mTLS 更简单的方案:服务 A 调用服务 B 时,携带一个内部 JWT Token,服务 B 验证 Token 的签名和声明。
内部 Token 生成
@Component
public class InternalTokenService {
@Value("${internal.jwt.secret}")
private String jwtSecret;
@Value("${spring.application.name}")
private String serviceName;
/**
* 生成内部服务调用 Token
* 携带:调用方服务名、被调用方服务名、过期时间
*/
public String generateInternalToken(String targetService) {
long now = System.currentTimeMillis();
return JWT.create()
.withIssuer(serviceName) // 调用方服务
.withAudience(targetService) // 被调用方服务
.withIssuedAt(new Date(now))
.withExpiresAt(new Date(now + 60 * 1000)) // Token 有效期 1 分钟
.withClaim("tokenType", "INTERNAL_SERVICE")
.sign(Algorithm.HMAC256(jwtSecret));
}
/**
* 验证内部 Token
*/
public DecodedJWT verifyInternalToken(String token, String expectedAudience) {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(jwtSecret))
.withAudience(expectedAudience)
.withClaim("tokenType", "INTERNAL_SERVICE")
.build();
return verifier.verify(token); // 验证失败抛 JWTVerificationException
}
}自动注入 Token 的 Feign 拦截器
@Component
public class InternalAuthRequestInterceptor implements RequestInterceptor {
@Autowired
private InternalTokenService tokenService;
// 从 Feign 配置中获取目标服务名
@Override
public void apply(RequestTemplate template) {
String targetService = extractTargetService(template.feignTarget().name());
String token = tokenService.generateInternalToken(targetService);
template.header("X-Internal-Token", token);
template.header("X-Source-Service", getServiceName());
}
private String extractTargetService(String feignName) {
// Feign Client 的 name 就是目标服务名
return feignName;
}
}服务端 Token 验证过滤器
@Component
public class InternalAuthFilter extends OncePerRequestFilter {
@Autowired
private InternalTokenService tokenService;
@Value("${spring.application.name}")
private String currentServiceName;
// 白名单:不需要内部认证的路径
private static final List<String> PUBLIC_PATHS = Arrays.asList(
"/actuator/health",
"/actuator/prometheus"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
// 白名单直接放行
if (PUBLIC_PATHS.stream().anyMatch(path::startsWith)) {
filterChain.doFilter(request, response);
return;
}
// 来自网关的外部请求,已经有用户 JWT,不需要内部 Token
if (request.getHeader("X-User-Id") != null) {
filterChain.doFilter(request, response);
return;
}
// 服务间调用,需要内部 Token
String internalToken = request.getHeader("X-Internal-Token");
if (internalToken == null) {
sendUnauthorized(response, "缺少内部认证 Token");
return;
}
try {
DecodedJWT jwt = tokenService.verifyInternalToken(internalToken, currentServiceName);
String caller = jwt.getIssuer();
// 将调用方信息写入 request attribute,供后续业务使用
request.setAttribute("callerService", caller);
filterChain.doFilter(request, response);
} catch (JWTVerificationException e) {
log.warn("内部 Token 验证失败:{}", e.getMessage());
sendUnauthorized(response, "内部 Token 无效");
}
}
private void sendUnauthorized(HttpServletResponse response, String message)
throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(
String.format("{\"code\":401,\"message\":\"%s\"}", message));
}
}方案三:API 签名防篡改
对外开放的 API(To B 接口、开放平台),需要 API 签名验证,防止:
- 请求被中间人篡改(参数被修改)
- 请求被重放(同一个请求被发送多次)
/**
* HMAC-SHA256 API 签名实现
*/
@Component
public class ApiSignatureService {
/**
* 生成签名
* 签名内容 = HTTP方法 + 路径 + 时间戳 + nonce + body 哈希
*/
public String sign(String method, String path, String timestamp,
String nonce, String body, String secretKey)
throws NoSuchAlgorithmException, InvalidKeyException {
// 对 body 做 MD5
String bodyHash = body != null
? DigestUtils.md5DigestAsHex(body.getBytes(StandardCharsets.UTF_8))
: "";
// 拼接待签名字符串
String stringToSign = method.toUpperCase() + "\n"
+ path + "\n"
+ timestamp + "\n"
+ nonce + "\n"
+ bodyHash;
// HMAC-SHA256 签名
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] signature = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signature);
}
/**
* 验证签名
*/
public boolean verify(HttpServletRequest request, String secretKey)
throws Exception {
// 从请求头获取签名信息
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String signature = request.getHeader("X-Signature");
if (timestamp == null || nonce == null || signature == null) {
return false;
}
// 防重放:时间戳必须在5分钟内
long requestTime = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - requestTime) > 5 * 60 * 1000) {
log.warn("请求时间戳过期,可能是重放攻击。timestamp={}", timestamp);
return false;
}
// 防重放:nonce 不能重复使用
String nonceKey = "api:nonce:" + nonce;
if (!redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", Duration.ofMinutes(10))) {
log.warn("Nonce 已使用,可能是重放攻击。nonce={}", nonce);
return false;
}
// 读取 body
String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
// 重新计算签名
String expectedSignature = sign(
request.getMethod(), request.getRequestURI(),
timestamp, nonce, body, secretKey);
// 时间恒定比较,防止时序攻击
return MessageDigest.isEqual(
signature.getBytes(), expectedSignature.getBytes());
}
}Zero Trust 架构落地步骤
Zero Trust 不是一个产品,而是一种安全架构原则。在微服务中落地需要分阶段:
第一阶段:身份化(2周)
- 为每个服务分配唯一身份(证书或 Token)
- 所有服务间调用携带身份信息
- 记录"谁调用了谁"的审计日志
第二阶段:最小权限(1个月)
- 定义每个服务能调用哪些接口(服务间 RBAC)
- 去掉过度授权的配置
第三阶段:持续验证(持续)
- 所有调用都验证身份,不因为"内网"就放行
- 定期审计权限配置
- 异常访问模式告警
三大踩坑实录
坑一:内部 Token 生命周期太短,频繁刷新影响性能
现象: 把内部 Token 有效期设为 1 分钟后,每次服务调用都要重新生成 Token(因为每次请求完就过期了),Token 生成的加密操作开销明显,P99 延迟增加了 20ms。
解法: 在服务内部缓存 Token,有效期快到时才刷新(比如有效期 5 分钟,剩 1 分钟时刷新):
@Component
public class InternalTokenCache {
private final Map<String, TokenEntry> cache = new ConcurrentHashMap<>();
public String getToken(String targetService) {
TokenEntry entry = cache.get(targetService);
if (entry == null || entry.isExpiredOrNearExpiry()) {
String newToken = tokenService.generateInternalToken(targetService);
cache.put(targetService, new TokenEntry(newToken, 5 * 60 * 1000));
return newToken;
}
return entry.getToken();
}
}坑二:API 签名的 body 读取问题
现象: 实现签名验证后,业务 Controller 读取 Request Body 时,总是获取到空内容,导致业务逻辑失败。
原因: HttpServletRequest.getInputStream() 只能读一次,签名验证过滤器读了 body 之后,流已经消费完了,Controller 再读就是空的。
解法: 使用 ContentCachingRequestWrapper 包装 request,支持多次读取 body:
@Override
protected void doFilterInternal(HttpServletRequest request, ...) {
// 包装 request,支持 body 重复读取
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
// 签名验证使用 wrappedRequest
signatureService.verify(wrappedRequest, secretKey);
// 传递给下游的也是 wrappedRequest
filterChain.doFilter(wrappedRequest, response);
}坑三:证书过期导致服务间调用全部失败
现象: 某天早上,所有服务间调用突然开始报 SSL 握手失败,经过排查是服务证书过期了。
原因: 手动管理证书,没有设置过期告警,证书到期当天才发现。
解法:
- 使用 cert-manager(K8s 插件)自动管理证书生命周期,自动续期
- 添加证书过期监控:提前 30 天告警
- 证书管理纳入基础设施即代码(IaC),不手动操作
写在最后
微服务安全不是一个可以"做完"的任务,而是一个持续迭代的过程。
回到文章开头的故事,那个项目最终用了 3 个月完成了第一阶段(身份化)——给所有服务间调用加上 JWT 内部 Token,记录了完整的调用审计日志。虽然还没到 Zero Trust 的完整状态,但已经把最大的安全风险降低了 80%。
安全建设的关键不是一步到位,而是每一步都要可落地、可验证。
