广州市网站建设_网站建设公司_小程序网站_seo优化
2025/12/21 11:32:19 网站建设 项目流程

认证时机:HTTP 握手阶段

首先了解,WebSocket 连接建立前,客户端会先发起一个 HTTP Upgrade 请求(握手)。此时连接仍是 HTTP 协议,可以携带 Cookie、Header 或 Query 参数。

关键点来了:认证可以在这一步搞定
一旦握手成功、协议升级成 WebSocket,后面就没法再用 HTTP 那套鉴权方式了。所以可以就得趁这个“窗口期”,把身份验证拦下来处理。

本文正是利用这一点,完成拦截并验证握手请求。

核心逻辑解析

拦截 FullHttpRequest

Netty 是事件驱动的,所有进来的数据都走 channelRead。但我们只关心完整的 HTTP 请求(FullHttpRequest),别的比如字节流、WebSocket 帧啥的,直接往后传就行:

if (!(object instanceof FullHttpRequest req)) {ctx.fireChannelRead(object);return;
}

从 URI 中提取 Token

前端一般会把 Token 放在查询参数里,比如:

GET /ws?token=abc123xyz HTTP/1.1

我们用 Hutool 的 UrlBuilder 轻松解析出来,但如果没传或者传了个空的,那就直接拒绝。

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 方法,那现在就可以反查:

String userId = (String) StpUtil.getLoginIdByToken(token);

如果 Token 无效或过期,Sa-Token 会抛出 NotLoginException。此时我们返回 401 Unauthorized,顺便关掉连接。

要是验证通过了,就把用户 ID 存到当前 Netty Channel 的属性里,这样后面处理消息的时候,随便哪个 Handler 都能拿,这就实现了“一次认证,全程可用”。

ctx.channel().attr(USER_ID_ATTR).set(userId);
String userId = ctx.channel().attr(WsAuthHandshakeHandler.USER_ID_ATTR).get();

清理 URI 查询参数

WebSocket 协议有个小讲究:Upgrade 请求的路径最好干净点,别带 query string。比如 /ws?token=xxx 应该变成 /ws

为啥?因为后面的 WebSocketServerProtocolHandler 是靠路径匹配的,带参数可能匹配不上。

所以我们认证完就清理一下,确保后续 WebSocketServerProtocolHandler 能正确匹配路由

req.setUri(UrlBuilder.ofHttp(req.uri()).getPath().toString());

Apifox 测试 WebSocket

想看看效果?可以用 Apifox 模拟 WebSocket 连接。

获取有效 Token

先调用你系统中 Sa-Token 的登录接口,获得返回后的 Token 值,例如:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.xxxx

新建 WebSocket 请求

在 Apifox 中点击新建请求 → 选择协议类型为 WebSocket,输入 WebSocket 地址:

ws://localhost:8934/im?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.xxxxx

测试场景一:不带 Token

在未携带 Token 的情况下尝试建立 WebSocket 连接,看是否能够正常连接:

Pasted image 20251221104621

Pasted image 20251221104641

Pasted image 20251221104659

测试场景二:带上有效 Token

Pasted image 20251221104737

Pasted image 20251221105032

完整代码

带注释,放心抄

WsAuthHandshakeHandler.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 会触发异常处理流程*/@Overridepublic 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,则返回 nullString 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 获取对应的登录用户 IDString 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 → /wsreq.setUri(UrlBuilder.ofHttp(req.uri()).getPath().toString());} catch (NotLoginException e) {// 如果 token 无效、过期或不存在,Sa-Token 会抛出 NotLoginExceptionlog.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");// 将响应写入并刷出到客户端,完成后关闭 Channelctx.writeAndFlush(response).addListener(future -> ctx.channel().close());}
}

Netty 服务端配置(关键部分)

ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class).childHandler(new ChannelInitializer<Channel>() {@Overrideprotected 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);

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询