本节目标:把「谁在调用 9093」变成拦截器里可验证的对象:能正确读写
authorizationmetadata,能解释 JWT 三段结构并在不引入 JWT 库的前提下做 HMAC 校验,能处理过期与时钟偏移,也知道哪些东西绝对不能进日志。
适用版本:JDK 25、gRPC-Java 1.76.0;示例中的 HMAC 校验是教学实现,不是生产 JWT 库。
承接的问题
10.1 让 9093 只接受持有客户端证书的连接。证书回答的是「这是不是网关这个进程」,回答不了「这次请求代表哪个用户、哪个租户」。同一条 mTLS 连接会承载成千上万个用户请求,如果服务端只认证书,任何能连上来的调用方都能读所有租户的数据。
身份要从请求里来。gRPC 的载体是 Metadata,键名 authorization,值形如 Bearer <token>。顺序是:客户端拦截器把 token 放进 metadata,服务端拦截器取出并校验,通过后把 Principal 放进 gRPC Context,任何失败都映射成 UNAUTHENTICATED 且错误信息不带 token。
这里有一个必须先划清的边界:本节手写的是 JWT 的结构解析与 HMAC 校验,不是 JWT 库。它只支持 HS256,不做 kid 轮换、不做 aud/iss 校验、不处理 crit 头、不支持 RSA/ECDSA。生产环境请用成熟的 JOSE 实现;本节的价值是让你看清每个失败点在哪里,而不是鼓励自己造一个认证库。
核心机制一:Metadata 的键与传播位置
Metadata 是 HTTP/2 header 的 gRPC 映射,规则很硬:
- 键名必须小写。
Metadata.Key.of("Authorization", ...)会在运行时抛异常;HTTP/2 要求 header 名小写。 - 文本值用
Metadata.ASCII_STRING_MARSHALLER;二进制值(如证书摘要)用Metadata.BINARY_BYTE_MARSHALLER,键名必须以-bin结尾。 - 一个键可以有多个值。取
headers.get(key)只拿第一个,getAll(key)拿全部;认证场景应当拒绝多值,避免代理拼接造成歧义。 - token 只放 header,不放 URL、不放 protobuf 字段、不放日志字段。URL 会进访问日志和浏览器历史,protobuf 字段会被业务代码随手打印。
客户端侧用一个只做一件事的拦截器:
package com.plume.linkd.v3.client;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.ForwardingClientCall;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
import java.util.function.Supplier;
public final class BearerClientInterceptor implements ClientInterceptor {
private final Supplier<String> tokenSource;
public BearerClientInterceptor(Supplier<String> tokenSource) {
this.tokenSource = tokenSource;
}
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method,
CallOptions options,
Channel next) {
ClientCall<ReqT, RespT> call = next.newCall(method, options);
return new ForwardingClientCall.SimpleForwardingClientCall<>(call) {
@Override
public void start(Listener<RespT> listener, Metadata headers) {
headers.put(AuthInterceptor.AUTHORIZATION,
"Bearer " + tokenSource.get());
super.start(listener, headers);
}
};
}
}
tokenSource 用 Supplier 而不是固定字符串,是为了让 token 到期后能换新的。注意不要在 interceptCall 里取 token:真正发请求是在 start,两者之间可能隔了很久;把取值放在 start 里,拿到的才是当下有效的 token。
核心机制二:JWT 结构与签名校验
JWT 是 header.payload.signature 三段,前两段是 base64url 编码的 JSON,第三段是对 header.payload 的签名。以 HS256 为例,签名是 HMAC-SHA256,密钥双方共享。
一个最小的校验器只做四件事:拆三段、验算法、验签名、取 claims。下面这个类刻意写得直白,方便逐行对照失败模式:
package com.plume.linkd.v3.server;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class TokenVerifier {
private static final Pattern ALG =
Pattern.compile("\"alg\"\\s*:\\s*\"([A-Za-z0-9-]+)\"");
private static final Pattern EXP =
Pattern.compile("\"exp\"\\s*:\\s*(\\d+)");
private static final Pattern SUB =
Pattern.compile("\"sub\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern TENANT =
Pattern.compile("\"tenant\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern ROLE =
Pattern.compile("\"role\"\\s*:\\s*\"([A-Z_]+)\"");
private static final Pattern JTI =
Pattern.compile("\"jti\"\\s*:\\s*\"([^\"]+)\"");
private static final Duration CLOCK_SKEW = Duration.ofSeconds(60);
private static final int MAX_TOKEN_LENGTH = 8192;
private final byte[] secret;
private final Clock clock;
public TokenVerifier(byte[] secret, Clock clock) {
this.secret = secret.clone();
this.clock = clock;
}
public Principal verify(String authorization) {
if (authorization == null || authorization.isBlank()) {
throw unauthenticated("missing authorization");
}
if (!authorization.startsWith("Bearer ")) {
throw unauthenticated("unsupported authorization scheme");
}
String token = authorization.substring("Bearer ".length()).trim();
if (token.isEmpty() || token.length() > MAX_TOKEN_LENGTH) {
throw unauthenticated("bad token length");
}
String[] parts = token.split("\\.", -1);
if (parts.length != 3) {
throw unauthenticated("malformed token");
}
String header = decode(parts[0]);
if (!"HS256".equals(claim(header, ALG, "alg"))) {
throw unauthenticated("unsupported alg");
}
byte[] signingInput = (parts[0] + "." + parts[1])
.getBytes(StandardCharsets.US_ASCII);
byte[] expected = hmac(signingInput);
byte[] actual;
try {
actual = Base64.getUrlDecoder().decode(parts[2]);
} catch (IllegalArgumentException e) {
throw unauthenticated("bad signature encoding");
}
if (!MessageDigest.isEqual(expected, actual)) {
throw unauthenticated("bad signature");
}
String payload = decode(parts[1]);
long exp = longClaim(payload, EXP, "exp");
Instant now = clock.instant();
if (now.isAfter(Instant.ofEpochSecond(exp).plus(CLOCK_SKEW))) {
throw unauthenticated("token expired");
}
return new Principal(
claim(payload, SUB, "sub"),
claim(payload, TENANT, "tenant"),
claim(payload, ROLE, "role"),
claim(payload, JTI, "jti"),
Instant.ofEpochSecond(exp));
}
private byte[] hmac(byte[] input) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return mac.doFinal(input);
} catch (Exception e) {
throw new IllegalStateException("HmacSHA256 unavailable", e);
}
}
private static String decode(String part) {
try {
return new String(Base64.getUrlDecoder().decode(part),
StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
throw unauthenticated("bad base64url");
}
}
private static String claim(String json, Pattern pattern, String name) {
Matcher matcher = pattern.matcher(json);
if (!matcher.find()) {
throw unauthenticated("missing claim " + name);
}
return matcher.group(1);
}
private static long longClaim(String json, Pattern pattern, String name) {
try {
return Long.parseLong(claim(json, pattern, name));
} catch (NumberFormatException e) {
throw unauthenticated("bad numeric claim " + name);
}
}
private static io.grpc.StatusRuntimeException unauthenticated(String message) {
return io.grpc.Status.UNAUTHENTICATED
.withDescription(message)
.asRuntimeException();
}
}
Principal 是一个只读记录:record Principal(String subject, String tenantId, String role, String tokenId, Instant expiresAt)。它的 toString() 只拼接 subject、tenant、role 和 expiresAt,不带 token,也不带完整 jti,避免对象被顺手打进日志。
这段实现的能力边界必须写清楚:
| 能力 | 本节实现 | 生产要求 |
|---|---|---|
| 算法 | 仅 HS256 | 按信任域选 RS256/ES256/EdDSA |
| 密钥 | 单个共享密钥 | kid 轮换 + JWKS |
| 受众 | 不校验 | 必须校验 aud 包含本服务 |
| 签发方 | 不校验 | 必须校验 iss 白名单 |
| JSON 解析 | 正则取字段 | 严格 JSON 解析,拒绝重复键 |
| 时钟 | 可注入 Clock | 同样需要,便于测试 |
crit 头 | 不处理 | 遇到未知 crit 必须拒绝 |
正则取 claims 的做法对嵌套对象和转义字符串都不稳,只适合演示。即便如此,它已经覆盖了真实故障里最常见的几类:算法被改成 none、签名被截断、exp 过期、base64url 填充错误。生产替换成 JOSE 库时,上面这些失败点仍然要一一映射到 UNAUTHENTICATED。
核心机制三:过期、时钟偏移与重放
exp 是「过期时刻」,不是「剩余时长」。判断用 now > exp + skew,而不是 now > exp。CLOCK_SKEW = 60 s 的意思是:允许签发方比本机慢一分钟。链路两端都做同样宽松的判断,会放大重放窗口;只在接收端放宽,签发端严格,是更常见的取舍。nbf(not before)用同一个 skew 反向判断,本节实现为篇幅只保留 exp。
重放不靠 exp 解决。token 在有效期内被截获,仍然可以重放。真正的防线是:
- 短 TTL:内部 token 控制在 5 分钟以内,网关按需刷新。
jti去重:对写操作,在幂等层记录jti(或jti + 请求指纹),窗口覆盖 TTL 加 skew。3.3 的幂等键解决的是「重复提交」,jti去重解决的是「同一个 token 被换着请求重放」,两者不能互相替代。- 受众绑定:token 的
aud必须包含linkd,否则网关签给别的服务的 token 也能拿来调 9093。
核心机制四:把身份放进 Context
校验通过后,服务方法要能读到 Principal。gRPC 的 Context 是线程局部的,但服务方法的回调可能在不同线程上执行,所以不能只在 interceptCall 里 attach 一下就完事。做法是把 Context 包在 ServerCall.Listener 的每个回调上:
package com.plume.linkd.v3.server;
import io.grpc.Context;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
public final class AuthInterceptor implements ServerInterceptor {
public static final Context.Key<Principal> PRINCIPAL =
Context.key("linkd.principal");
public static final Metadata.Key<String> AUTHORIZATION =
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
private final TokenVerifier verifier;
public AuthInterceptor(TokenVerifier verifier) {
this.verifier = verifier;
}
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
if (headers.getAll(AUTHORIZATION).size() > 1) {
throw io.grpc.Status.UNAUTHENTICATED
.withDescription("duplicate authorization")
.asRuntimeException();
}
Principal principal = verifier.verify(headers.get(AUTHORIZATION));
Context context = Context.current().withValue(PRINCIPAL, principal);
ServerCall.Listener<ReqT> delegate =
context.call(() -> next.startCall(call, headers));
return new ContextualListener<>(context, delegate);
}
}
ContextualListener 只做一件事:把每个回调放进对应的 Context:
package com.plume.linkd.v3.server;
import io.grpc.Context;
import io.grpc.ServerCall;
public final class ContextualListener<ReqT> extends ServerCall.Listener<ReqT> {
private final Context context;
private final ServerCall.Listener<ReqT> delegate;
public ContextualListener(Context context, ServerCall.Listener<ReqT> delegate) {
this.context = context;
this.delegate = delegate;
}
@Override
public void onReady() {
context.run(delegate::onReady);
}
@Override
public void onMessage(ReqT message) {
context.run(() -> delegate.onMessage(message));
}
@Override
public void onHalfClose() {
context.run(delegate::onHalfClose);
}
@Override
public void onCancel() {
context.run(delegate::onCancel);
}
@Override
public void onComplete() {
context.run(delegate::onComplete);
}
}
服务方法里直接读 AuthInterceptor.PRINCIPAL.get()。如果为 null,说明这个方法绕过了拦截器,应当按 INTERNAL 处理,而不是默认放行。注册顺序上,认证拦截器要放在指标拦截器之前,这样「未认证」也会被计数,但不会进入业务方法。
在 linkd-v3 里落地
网关签发的内部 token 只带四个业务字段:sub、tenant、role、jti,TTL 300 秒,aud=linkd。role 是粗粒度角色(READER、LIBRARIAN、ADMIN),细粒度授权留给 10.3。
- 网关到 9093:
BearerClientInterceptor(() -> tokenCache.current()),token 到期前 30 秒刷新。 - 9093 的 server:
NettyServerBuilder...intercept(new AuthInterceptor(verifier))。 - 9093 到 libraryd:不要转发用户 token。libraryd 是内部服务,用独立的服务凭据;租户信息通过 10.3 的
x-tenant-id与内部签名传递,避免用户 token 在内部扩散。
日志红线只有一句:token 不落日志,任何形态都不行。具体规则:
| 可以记录 | 禁止记录 |
|---|---|
sub、tenant、role | authorization 原值、Bearer 后面的任何片段 |
jti 的哈希前 8 位 | 完整 jti(可被用于关联重放) |
| 校验失败的原因分类 | 解析后的完整 claims JSON |
Principal.toString() 已经去掉了 token;异常信息里也只放「bad signature」「token expired」这类分类,不放 token 片段。日志里出现 Bearer 前缀本身就是一次事故,应当在日志管道里加一条脱敏规则兜底。
常见错误与边界
错误一:在 interceptCall 里读 token,而不是在 start 里。 客户端拦截器构造时取 token,重试或长连接复用时会拿到过期值。取值放在 start,并且每次调用都取。
错误二:用 headers.get 而不检查多值。 代理把两个 authorization 拼在一起时,不同实现取第一个或最后一个,攻击者可以利用这种歧义。认证场景直接拒绝多值。
错误三:把 UNAUTHENTICATED 当成可重试错误。 签名错误、token 过期重试多少次都一样。只有「token 刚被刷新」这一种情况值得重试一次,而且要在客户端明确判断,不要交给通用的重试拦截器。
错误四:在错误信息里回显 token,或用 String.equals 比较签名。 "invalid token: " + token 会把凭据写进日志、追踪和错误响应;逐字节比较会泄露前缀匹配长度。错误信息只描述分类,签名比较用 MessageDigest.isEqual。
边界一:HMAC 只适合同一信任域。 网关和 linkd 共享密钥,可以用 HS256;跨团队、跨信任域时必须用非对称签名,让验证方只拿公钥。
边界二:Clock 必须可注入。 过期测试不能靠 Thread.sleep,那会让 CI 变慢且不稳定。构造器传入 Clock.fixed(...),测试就能精确覆盖 exp - 1s、exp、exp + 59s、exp + 61s 四个点。
边界三:token 校验通过不等于租户匹配。 token 里的 tenant 与请求头 x-tenant-id 是否一致,是 10.3 的职责;10.2 只回答「这个 token 是真的、没过期」。同时记住 mTLS 证书与 token 是两层:证书标识进程,token 标识用户,不要互相替代。
小结
- 身份通过
authorization: Bearer <token>传播,键名小写、单值、只走 metadata;JWT 校验的最小步骤是拆三段、验alg、验 HMAC 签名、验exp,本节实现只支持 HS256,是教学代码,不是生产 JWT 库。 - 过期判断要加时钟偏移;重放靠短 TTL、
jti去重和aud绑定,不靠exp。 Principal通过Context传给服务方法,ContextualListener保证每个回调都在正确的上下文里执行。- token 永远不进日志、指标标签、追踪属性和错误信息;只记录
sub、tenant、role与失败分类。 - 9093 到 libraryd 不转发用户 token,内部服务用自己的凭据;租户边界在下一节显式校验。
身份确认之后,下一个问题是「这个身份能对哪些资源做什么」。10.3 把 x-tenant-id 的必填校验、租户归属、RBAC 与 ABAC 的简化模型接进来,并处理跨租户 ID 枚举这个容易被忽略的泄漏面。
阅读导航:上一节 10.1 TLS 与 mTLS | 下一节 10.3 授权模型与租户隔离
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。