Netty + Sa-Token 实现 WebSocket 握手认证

认证时机: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);
相关推荐
多云的夏天2 小时前
SpringBoot3+Vue3基础框架(1)-springboot+对接数据库表登录
数据库·spring boot·后端
计算机毕设VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue旅游信息推荐系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·课程设计·旅游
CC.GG2 小时前
【C++】红黑树
java·开发语言·c++
学IT的周星星2 小时前
java常见面试题
java·开发语言
shoubepatien2 小时前
JAVA -- 12
java·后端·intellij-idea
Mr.朱鹏3 小时前
大模型入门学习路径(Java开发者版)上
java·开发语言·spring boot·spring·大模型·llm·transformer
古月฿3 小时前
大学生素质测评系统设计与实现
java·vue.js·redis·mysql·spring·毕业设计
一雨方知深秋3 小时前
程序流程控制
java·for循环·while循环·if分支·switch分支·dowhile循环·嵌套循环
木木一直在哭泣3 小时前
Spring 里的过滤器(Filter)和拦截器(Interceptor)到底啥区别?
后端