认证时机:HTTP 握手阶段
首先了解,WebSocket 连接建立前,客户端会先发起一个 HTTP Upgrade 请求(握手)。此时连接仍是 HTTP 协议,可以携带 Cookie、Header 或 Query 参数。
关键点来了:认证可以在这一步搞定 !
一旦握手成功、协议升级成 WebSocket,后面就没法再用 HTTP 那套鉴权方式了。所以可以就得趁这个"窗口期",把身份验证拦下来处理。
本文正是利用这一点,完成拦截并验证握手请求。
核心逻辑解析
拦截 FullHttpRequest
Netty 是事件驱动的,所有进来的数据都走 channelRead。但我们只关心完整的 HTTP 请求(FullHttpRequest),别的比如字节流、WebSocket 帧啥的,直接往后传就行:
java
if (!(object instanceof FullHttpRequest req)) {
ctx.fireChannelRead(object);
return;
}
从 URI 中提取 Token
前端一般会把 Token 放在查询参数里,比如:
text
GET /ws?token=abc123xyz HTTP/1.1
我们用 Hutool 的 UrlBuilder 轻松解析出来,但如果没传或者传了个空的,那就直接拒绝。
java
String token = Optional.of(UrlBuilder.ofHttp(uri))
.map(UrlBuilder::getQuery)
.map(query -> query.get("token"))
.map(CharSequence::toString)
.filter(StrUtil::isNotBlank)
.orElse(null);
用 Sa-Token 验证 Token,并绑定用户
假设你已经配好了 Sa-Token,用户登录后调过 login 方法,那现在就可以反查:
java
String userId = (String) StpUtil.getLoginIdByToken(token);
如果 Token 无效或过期,Sa-Token 会抛出 NotLoginException。此时我们返回 401 Unauthorized,顺便关掉连接。
要是验证通过了,就把用户 ID 存到当前 Netty Channel 的属性里,这样后面处理消息的时候,随便哪个 Handler 都能拿,这就实现了"一次认证,全程可用"。
java
ctx.channel().attr(USER_ID_ATTR).set(userId);
java
String userId = ctx.channel().attr(WsAuthHandshakeHandler.USER_ID_ATTR).get();
清理 URI 查询参数
WebSocket 协议有个小讲究:Upgrade 请求的路径最好干净点,别带 query string。比如 /ws?token=xxx 应该变成 /ws。
为啥?因为后面的 WebSocketServerProtocolHandler 是靠路径匹配的,带参数可能匹配不上。
所以我们认证完就清理一下,确保后续 WebSocketServerProtocolHandler 能正确匹配路由
java
req.setUri(UrlBuilder.ofHttp(req.uri()).getPath().toString());
Apifox 测试 WebSocket
想看看效果?可以用 Apifox 模拟 WebSocket 连接。
获取有效 Token
先调用你系统中 Sa-Token 的登录接口,获得返回后的 Token 值,例如:
text
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.xxxx
新建 WebSocket 请求
在 Apifox 中点击新建请求 → 选择协议类型为 WebSocket,输入 WebSocket 地址:
text
ws://localhost:8934/im?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.xxxxx
测试场景一:不带 Token
在未携带 Token 的情况下尝试建立 WebSocket 连接,看是否能够正常连接:



测试场景二:带上有效 Token


完整代码
带注释,放心抄
WsAuthHandshakeHandler.java
java
package io.jiangbyte.app.handler;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.net.url.UrlBuilder;
import cn.hutool.core.util.StrUtil;
import io.jiangbyte.app.constant.ChannelAttrKey;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.*;
import io.netty.util.AttributeKey;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* @author Charlie
* @version v1.0
* @date 19/12/2025
* @description WebSocket 握手认证处理器
* 在 WebSocket 协议升级前(即 HTTP 握手阶段),验证客户端携带的 token 是否合法
* 并将认证通过的用户 ID 绑定到当前 Channel 上,供后续 WebSocket 通信使用
*/
@Slf4j
public class WsAuthHandshakeHandler extends ChannelInboundHandlerAdapter {
// 定义一个 AttributeKey,用于在 Netty Channel 上存储用户 ID
// 后续的 Handler 或业务逻辑可以通过 ctx.channel().attr(USER_ID_ATTR).get() 获取用户身份
private static final AttributeKey<String> USER_ID_ATTR = AttributeKey.valueOf("USER_ID");
/**
* 重写 ChannelInboundHandlerAdapter 的 channelRead 方法
* 处理入站的 HTTP 请求(WebSocket 握手请求)
*
* @param ctx Channel 上下文,用于操作 Channel(如写回响应、关闭连接等)
* @param object 入站的数据对象
* @throws Exception 抛出异常时 Netty 会触发异常处理流程
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object object) throws Exception {
// 判断接收到的对象是否是 FullHttpRequest(完整的 HTTP 请求)
// 如果不是,则直接传递给下一个 Handler 处理
if (!(object instanceof FullHttpRequest req)) {
ctx.fireChannelRead(object); // 继续向后传递事件
return;
}
// 获取请求的完整 URI(包含路径和查询参数,如 /ws?token=abc123)
String uri = req.uri();
// 获取客户端的远程地址(IP:Port),若无法获取则默认为 "unknown"
String remoteAddr = Optional.ofNullable(ctx.channel().remoteAddress()).map(Object::toString).orElse("unknown");
// 从 URI 中提取 token 参数:
// 1. 使用 Hutool 的 UrlBuilder 解析 HTTP URL
// 2. 获取查询参数(query string)
// 3. 从中取出 "token" 参数值
// 4. 转为字符串并过滤掉空白值
// 5. 若不存在有效 token,则返回 null
String token = Optional.of(UrlBuilder.ofHttp(uri))
.map(UrlBuilder::getQuery) // 获取 Query 对象
.map(query -> query.get("token")) // 获取 token 参数值(可能为 null)
.map(CharSequence::toString) // 转为 String
.filter(StrUtil::isNotBlank) // 过滤掉 null 或空白字符串
.orElse(null);
// 如果 token 为空或空白,记录警告日志并返回 401 未授权响应
if (StrUtil.isBlank(token)) {
log.warn("ws_auth_fail reason='missing_token' remote_addr={} uri={}", mask(remoteAddr), mask(uri));
unauthorized(ctx);
return;
}
try {
// 使用 Sa-Token 根据 token 获取对应的登录用户 ID
String userId = (String) StpUtil.getLoginIdByToken(token);
log.info("ws_auth_success user_id={} token_prefix={} remote_addr={} uri={}", userId, mask(token), mask(remoteAddr), mask(uri));
// 将用户 ID 绑定到当前 Channel 的属性中,供后续 Handler 使用
ctx.channel().attr(USER_ID_ATTR).set(userId);
// 清除 URI 中的查询参数(只保留路径),因为 WebSocket 协议升级时不需要 query string
// eg:/ws?token=abc → /ws
req.setUri(UrlBuilder.ofHttp(req.uri()).getPath().toString());
} catch (NotLoginException e) {
// 如果 token 无效、过期或不存在,Sa-Token 会抛出 NotLoginException
log.warn("ws_auth_fail reason='invalid_or_expired_token' remote_addr={} uri={}", mask(remoteAddr), mask(uri));
unauthorized(ctx);
return;
}
// 认证通过,继续将请求传递给下一个 Handler(通常是 WebSocketServerProtocolHandler)
ctx.fireChannelRead(object);
}
/**
* 对 token 进行脱敏处理,防止敏感信息泄露到日志中
* 规则:保留前6个字符,其余用 "***" 替代
*
* @param token 原始 token 字符串
* @return 脱敏后的字符串,如 "abc123***"
*/
private String mask(String token) {
// 如果 token 为 null 或长度 ≤6,直接返回(null 返回 "null" 字符串,避免 NPE)
if (token == null || token.length() <= 6) {
return StrUtil.isBlank(token) ? "null" : token;
}
// 截取前6位 + "***"
return token.substring(0, 6) + "***";
}
/**
* 向客户端发送 HTTP 401 Unauthorized 响应,并关闭连接
*
* @param ctx Channel 上下文
*/
private void unauthorized(ChannelHandlerContext ctx) {
// 创建一个 HTTP/1.1 401 响应,body 为空
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.UNAUTHORIZED);
// 设置响应头:Content-Length 为 0,Connection 为 close(立即关闭连接)
response.headers()
.setInt(HttpHeaderNames.CONTENT_LENGTH, 0)
.set(HttpHeaderNames.CONNECTION, "close");
// 将响应写入并刷出到客户端,完成后关闭 Channel
ctx.writeAndFlush(response).addListener(future -> ctx.channel().close());
}
}
Netty 服务端配置(关键部分)
java
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast(new IdleStateHandler(60, 0, 0));
p.addLast(new HttpServerCodec());
p.addLast(new ChunkedWriteHandler());
p.addLast(new HttpObjectAggregator(65535));
// 认证拦截器放这儿!
p.addLast(new WsAuthHandshakeHandler());
// WebSocket 协议升级处理器:
// - 路径为 "/im"
// - 子协议为 null(不指定)
// - 允许发送 Ping/Pong 心跳帧(true)
p.addLast(new WebSocketServerProtocolHandler(props.getWs().getPath(), null, true));
// ...
}
})
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true);