本节目标:把「谁允许连上来」做成 pipeline 结构,而不是散落在每个业务 handler 里的 if 判断。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport、netty-util模块)。
承接的问题
7.1 定下了帧格式,7.2 让连接能被发现是死是活,但 9093 仍然对所有人开放。一个 nc 127.0.0.1 9093 加上 16 字节头,就能构造 type=REQUEST 的帧,直接打到业务 handler。卷一的 HTTP 服务没有这个问题,因为每个请求自带语义;自定义二进制协议没有,连接建立不等于身份确认。
linkd-v2 的选择是「连接建立后先握手,再谈业务」:第一帧必须是 HELLO,payload 是 token 的 UTF-8 字节;服务端校验通过回 HELLO_ACK,之后才允许 REQUEST、RESPONSE、ERROR。这不是一个可选的检查,而是 pipeline 的结构——未认证的连接在物理上到不了业务 handler。
这一节讲清三件事:三态握手的状态与迁移、认证状态为什么绑在 Channel 上而不是 handler 上、以及「把认证放在业务 handler 里判断」为什么一定会漏。
核心机制一:三态握手与允许的帧
连接的生命周期从认证角度看只有三个状态:
| 状态 | 允许入站 | 允许出站 | 迁移条件 |
|---|---|---|---|
UNAUTHENTICATED | 只有 HELLO | HELLO_ACK、ERROR | token 正确 → AUTHENTICATED;错误或超时 → CLOSED |
AUTHENTICATED | 全部类型 | 全部类型 | 连接关闭 → CLOSED |
CLOSED | 无 | 无 | — |
HELLO 的 payload 就是 token 字节,长度 1 到 256;requestId 由客户端选,HELLO_ACK 原样回填,方便客户端把「握手成功」和发出去的请求对上。HELLO_ACK 的 payload 为空;失败时回 ERROR,payload 沿用 6.3 的约定:4 字节错误码加 UTF-8 文本。6.3 定义了 1–4 的编解码错误码,本节在同一个编号空间里加 5–7 表示认证阶段的错误,文本里不包含任何服务端配置。
为什么不在 HELLO 里放用户名和密码两个字段?v1 只需要一个凭据,字段越少,解析分支越少,能出错的地方也越少。为什么不是「第一帧就是带 token 的 REQUEST」?那样每个业务帧都要携带凭据,认证成本摊到每个请求上,日志里也更容易出现 token;握手只做一次,状态留在连接上。
一次完整的交互在帧层面是这样:
client server
|-- HELLO(type=1, requestId=1, token) ------->|
|<-- HELLO_ACK(type=2, requestId=1) ----------|
|-- REQUEST(type=5, requestId=2, payload) --->|
|<-- RESPONSE(type=6, requestId=2, payload) --|
HELLO_ACK 回填 HELLO 的 requestId,客户端可以确认「这条确认属于这次握手」,而不是把并发连接上的某个帧错认成握手结果。客户端侧的对应物是 8.1 的 ClientHandshakeHandler:channelActive 时发 HELLO,收到 HELLO_ACK 后把连接的 READY 属性置 true,重连逻辑据此判断连接可用。
状态迁移的实现不需要一个显式的枚举。UNAUTHENTICATED 和 AUTHENTICATED 用 Channel 上的一个布尔属性表示,CLOSED 就是 Channel.isActive() == false。三个状态、两个来源,够用且不会出现「枚举和 Channel 状态不一致」的问题。
核心机制二:AttributeKey 与连接绑定
认证结果存在 Channel 的属性上:
public static final AttributeKey<Boolean> AUTHENTICATED =
AttributeKey.valueOf("linkd.auth");
ctx.channel().attr(AUTHENTICATED).set(Boolean.TRUE);
为什么不用 AuthHandler 的成员字段?三个原因。第一,状态属于连接,不属于某个 handler 实例;pipeline 可以在运行中增删 handler(10 章的 WebSocket 升级、11 章的 HTTP/2 子通道都会重建 pipeline),状态不能跟着某个 handler 一起被移除。第二,后续 handler 需要查询认证结果,用 Channel 属性可以直接取,不用把 AuthHandler 的引用传来传去。第三,handler 可能是共享的,成员字段会被多条连接共用(4.3),属性天然是每连接一份。
4.2 的 HandshakeHandler 是把状态放在成员字段里的形状演示:它没有 token 校验,ERROR 用纯文本,HELLO_ACK 回 ready。本节是它的正式版本——状态移到 Channel 属性,ERROR 按 6.3 的 4 字节错误码加文本,HELLO_ACK 的空负载表示成功。读 4.2 时把两者当成「演示」和「契约」两层,不要照抄前者的 payload。
AttributeKey 写成 static final 常量,保证「同一个名字 = 同一个 key」。如果两个类各自 AttributeKey.valueOf("linkd.auth"),Netty 会返回同一个实例,看起来也能工作;但只要有一处拼成 "linkd.auth " 或 "linkd.authenticated",取到的就是 null,而这种错误不会抛异常,只会让认证永远不通过。常量把名字收在一个地方。
类型选 Boolean 而不是枚举,是因为当前只有「通过/未通过」两态。以后要区分角色,应该换一个新的 key 名(例如 "linkd.role")并放枚举,不要复用同名 key 换类型——旧的 handler 取出来会 ClassCastException。
核心机制三:token 比较与日志
token 在服务端以 byte[] 保存,构造 AuthHandler 时 clone() 一份。原因不是防并发,而是避免调用方之后改动或清零原数组,把 handler 里的凭据一起改掉;也避免把 token 变成 String 常驻字符串池。
比较用 MessageDigest.isEqual(expected, presented):
if (!MessageDigest.isEqual(expectedToken, presented)) {
reject(ctx, "bad token");
return;
}
JDK 25 的实现按 expected 的长度固定跑完循环,长度差先算进结果,不会在第一个不同字节处提前返回;长度不等也不会抛异常,只会返回 false(本机验证过 abc 对 abcd 返回 false)。它不承诺跨 JIT、跨平台的严格常量时间,所以真正的防线是三件事一起做:token 足够长且随机、失败即断开、对同一来源限制尝试次数。单靠一个比较函数挡不住在线爆破。
不要用 String.equals:它遇到第一个不同字符就返回,比较耗时和公共前缀长度相关;也不要用 == 比 byte[],那比的是引用,永远不相等。
日志的要求是「可排障、不可泄密」。可以打 remoteAddress、requestId、失败原因;不能打 payload,也不能把 token 拼进异常消息。反例:
// 反例:token 进了日志,日志系统就成了凭据仓库
LOG.log(Level.DEBUG, "hello payload={0}",
new String(frame.payload(), StandardCharsets.UTF_8));
同样的理由,ERROR 帧的 payload 只写 bad token,不回显期望值,也不回显客户端提交的值。错误原因要能区分「没按协议来」(expected HELLO)和「凭据不对」(bad token),但不能细分到「第几个字节不对」。
核心机制四:认证超时
只校验 token 还不够:一个连上来但永远不发 HELLO 的连接会一直占着 EventLoop 注册和缓冲区。所以握手要有截止时间,linkd-v2 定 10 秒:
ctx.executor().schedule(() -> {
if (ctx.channel().isActive() && !isAuthenticated(ctx)) {
reject(ctx, "auth timeout");
}
}, AUTH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
任务在 channelActive 里注册,跑在连接所属的 EventLoop 上,和 channelRead 同一个线程,所以读属性不需要同步。任务只跑一次,10 秒后检查一次就结束;不需要保存 ScheduledFuture 去 cancel——认证成功之后属性为 true,任务到点发现已认证就什么都不做。
用 EventLoop 的 schedule 而不是 new Thread 或独立的 ScheduledExecutorService:任务只读一个属性、写一个 ERROR 帧,是微秒级的;放到别的线程反而要处理跨线程写 Channel 的可见性和顺序。反例是「起一个线程 Thread.sleep(10_000) 再检查」——每连接一个平台线程,一万条连接就是一万个线程。
10 秒和 7.2 的 30 秒读空闲是两套机制:认证超时更短,未认证连接会先被它关掉;已认证连接才进入心跳的计时范围。日志上要能区分 auth timeout 和 no PONG ... closing,否则排障时分不清是握手没完成还是心跳丢了。
在 linkd-v2 里落地:AuthHandler 与 pipeline 顺序
完整的 AuthHandler:
package com.plume.linkd.v2.server;
import com.plume.linkd.v2.protocol.FrameType;
import com.plume.linkd.v2.protocol.LinkdFrame;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.AttributeKey;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.concurrent.TimeUnit;
public final class AuthHandler extends SimpleChannelInboundHandler<LinkdFrame> {
public static final AttributeKey<Boolean> AUTHENTICATED =
AttributeKey.valueOf("linkd.auth");
private static final int AUTH_TIMEOUT_SECONDS = 10;
private static final int MAX_TOKEN_BYTES = 256;
private static final byte[] EMPTY = new byte[0];
private final byte[] expectedToken;
public AuthHandler(byte[] expectedToken) {
this.expectedToken = expectedToken.clone();
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
ctx.executor().schedule(() -> {
if (ctx.channel().isActive() && !isAuthenticated(ctx)) {
reject(ctx, 6, "auth timeout");
}
}, AUTH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
ctx.fireChannelActive();
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, LinkdFrame frame) {
if (isAuthenticated(ctx)) {
ctx.fireChannelRead(frame);
return;
}
if (frame.type() != FrameType.HELLO) {
reject(ctx, 7, "expected HELLO");
return;
}
byte[] presented = frame.payload();
if (presented.length == 0 || presented.length > MAX_TOKEN_BYTES
|| !MessageDigest.isEqual(expectedToken, presented)) {
reject(ctx, 5, "bad token");
return;
}
ctx.channel().attr(AUTHENTICATED).set(Boolean.TRUE);
ctx.writeAndFlush(new LinkdFrame(FrameType.HELLO_ACK, 0, frame.requestId(), EMPTY));
}
private static boolean isAuthenticated(ChannelHandlerContext ctx) {
return Boolean.TRUE.equals(ctx.channel().attr(AUTHENTICATED).get());
}
private static void reject(ChannelHandlerContext ctx, int code, String reason) {
byte[] text = reason.getBytes(StandardCharsets.UTF_8);
ByteBuffer buf = ByteBuffer.allocate(4 + text.length);
buf.putInt(code).put(text);
ctx.writeAndFlush(new LinkdFrame(FrameType.ERROR, 0, 0, buf.array()))
.addListener(ChannelFutureListener.CLOSE);
}
}
几个关键点。channelRead0 在已认证时只做一次属性读取就转发,成本可以忽略;未认证时只接受 HELLO,其他类型一律 expected HELLO。reject 先写 ERROR 再挂 CLOSE listener,保证对端能收到原因再断开——只 close() 不写 ERROR,客户端只能看到连接被重置。HELLO 被消费,不转发给业务 handler,否则业务会收到一个不认识的类型。
SimpleChannelInboundHandler 默认自动释放入站消息,但 LinkdFrame 不是 ReferenceCounted,这个释放是空操作;真正需要释放的 ByteBuf 已经在解码器里转成 byte[] 了。
reject 里的 ERROR 用 requestId=0:认证失败是针对连接的,不对应某个请求;客户端收到后应该关闭连接并重新握手,而不是把它当成某个 CompletableFuture 的异常结果。
5、6、7 三个码都挂 CLOSE。6.3 的规则是「负载语义错误不关闭」,但那条规则针对已经认证的业务请求;认证是准入判断,失败之后继续复用连接,只会让攻击者在同一条连接上继续猜 token。客户端侧应该把未知错误码当成通用失败处理,这样新增 5–7 不需要 bump version。
认证成功后不要从 pipeline 移除 AuthHandler。移除看起来省了一次属性读取,但会让「pipeline 里有一个认证关口」这个不变量消失:后续如果再 addLast 一个 handler,它仍然排在原位置之后,看起来安全;可一旦有代码 replace 或重建 pipeline,就没有东西保证新链路还经过认证。保留它,每帧多一次属性读取,换的是结构上的确定性。
9093 的 pipeline 顺序:
ch.pipeline().addLast(new IdleStateHandler(30, 0, 0, TimeUnit.SECONDS));
ch.pipeline().addLast(new LinkdFrameDecoder());
ch.pipeline().addLast(new LinkdFrameEncoder());
ch.pipeline().addLast(new AuthHandler(token));
ch.pipeline().addLast(new HeartbeatHandler());
ch.pipeline().addLast(new LinkdFrameHandler(registry));
AuthHandler 必须在所有业务 handler 之前,也必须在解码器之后——它吃的是 LinkdFrame,不是字节。LinkdFrameEncoder 必须在 AuthHandler 之前(靠 head 方向),因为出站事件从 tail 往 head 走,AuthHandler 里的 ctx.writeAndFlush 只会经过它前面的出站处理器;编码器放在它后面,HELLO_ACK 和 ERROR 就不会被编码。
HeartbeatHandler 放在 AuthHandler 之后:未认证连接不参与 PING/PONG,10 秒认证超时先处理它们。如果顺序反过来,未认证连接也会收到 PING;一个还没完成握手的连接没有理由参与心跳,日志里也会多出一倍的 PING 记录。
本机用 EmbeddedChannel 跑四种情况,输出:
== auth ==
request before hello -> ERROR(code=7, expected HELLO) active=false
wrong token -> ERROR(code=5, bad token) active=false
hello -> HELLO_ACK() authed=true active=true
request after hello -> REQUEST requestId=8
silent 10s -> ERROR(code=6, auth timeout) active=false
四种情况分别对应:未认证发业务帧、token 错误、token 正确后业务帧透传、连上但不握手。每种失败都是「写 ERROR 再关闭」,对端能拿到原因。
为什么「认证放在业务 handler 里判断」容易漏
最常见的写法是每个业务 handler 开头复制一段:
// 反例:每个业务 handler 自己判断,漏一处就是越权
@Override
protected void channelRead0(ChannelHandlerContext ctx, LinkdFrame frame) {
if (ctx.channel().attr(AuthHandler.AUTHENTICATED).get() != Boolean.TRUE) {
LOG.log(Level.WARNING, "unauthenticated request");
return; // 只记日志,不回 ERROR,也不关闭
}
handle(frame);
}
这段代码有三个漏法。第一,新 handler 忘记复制:10 章的 WebSocketHub、11 章的 HTTP/2 子通道都是后来加的,每加一个就要记得加一遍,而「记得」不是机制。第二,复制了但 return 而不是关闭:攻击者可以在同一条连接上继续发帧、继续试 token,日志里只有一行 warning。第三,判断写在解析之后:前面的反序列化、查表、分配内存已经执行,越权的影响已经发生。
更根本的问题是保护方向。把认证放在业务 handler 里,默认是 fail-open:新增一个 handler,它默认不受保护,除非作者记得加判断。把认证放在 pipeline 前段,默认是 fail-closed:新增的 handler 只要排在 AuthHandler 之后,就自动只能看到已认证的帧;作者不需要知道认证存在。
集中式的代价是「业务 handler 必须相信前面的链路」。这个信任靠两件事维持:AuthHandler 不能被动态移除,以及任何新增的 pipeline 分支(WebSocket 升级、HTTP/2 子通道)都要把 AuthHandler 复制过去。前者用代码评审保证,后者在 10 章和 11 章各出现一次,届时会指出复制的位置。
常见错误与边界
错误一:用 == 比较 byte[]。 两个内容相同的数组引用不同,== 永远 false,所有认证都失败。用 Arrays.equals 能过功能测试,但语义上不如 MessageDigest.isEqual 明确;linkd-v2 统一用后者。
错误二:认证成功后又把 HELLO 转发下去。 业务 handler 收到 type=1 的帧,要么当成未知类型报错,要么在 switch 里漏掉。HELLO 是握手帧,AuthHandler 消费它,只回 HELLO_ACK。
错误三:失败只写 ERROR 不关闭。 连接保持打开,攻击者可以继续爆破 token;同时 RpcClient 可能把 ERROR 当成业务错误返回给调用方。ERROR 写完必须 ChannelFutureListener.CLOSE。
错误四:Boolean 属性拆箱。 写成 ctx.channel().attr(AUTHENTICATED).get() == false,属性为 null 时抛 NullPointerException;用 Boolean.TRUE.equals(...) 或 != Boolean.TRUE。
错误五:token 进日志或进 ERROR payload。 见核心机制三。还要注意异常栈:不要把 presented 放进 IllegalArgumentException 的消息里。
边界:单 token 不是身份系统。 v1 的服务端只有一个共享 token,它证明「你有准入凭据」,不证明「你是谁」。需要按客户端区分权限时,HELLO 要带 clientId,服务端按 clientId 查各自的 token;那是协议扩展,要按 7.1 的 version 规则走,不能偷偷改 payload 格式。
边界:认证不替代传输加密。 token 在明文 TCP 上传输,链路上的任何人都能抓到。9093 的定位是内网控制通道;跨公网必须走 TLS,TLS/ALPN 的边界在第 11 章说明,本节不假装 token 能解决窃听。
小结
- 握手是三态:未认证只接受
HELLO,认证后放行全部,失败或超时进入关闭;状态用Channel属性加isActive()表示。 AttributeKey<Boolean>绑在Channel上,pipeline 增删 handler 不会丢状态,后续 handler 可以直接查询。- token 用
MessageDigest.isEqual比较,失败原因只写bad token,日志和ERROR都不回显凭据。 - 认证超时用
ctx.executor().schedule,10 秒后检查一次属性;不要为每个连接起线程或 sleep。 AuthHandler放在解码器之后、所有业务 handler 之前;编码器必须在它前面,否则HELLO_ACK不会被编码。- 认证放在业务 handler 里是 fail-open,新增 handler 默认不受保护;放在 pipeline 前段才是 fail-closed。
协议、心跳、准入都齐了,下一章回到客户端:连接怎么建、断了怎么重连、重连期间请求怎么排队。8.1 从 Bootstrap 和 ReconnectHandler 开始。
阅读导航:上一节:7.2 IdleStateHandler 与心跳 · 下一节:8.1 客户端连接与重连 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。