《Java 服务端通信工程》10.2 Token 传播与校验

10.1 解决了链路加密,本节解决调用者身份:用 Metadata 传播 Bearer token,在 ServerInterceptor 里校验结构、HMAC 签名与过期时间,给出可编译实现,并划清教学代码与生产 JWT 库的边界和日志脱敏规则。

本节目标:把「谁在调用 9093」变成拦截器里可验证的对象:能正确读写 authorization metadata,能解释 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、roleauthorization 原值、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 授权模型与租户隔离

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收