【WebSocket连接异常】前端使用WebSocket子协议传递token时,Java后端的正确打开方式!!!

news2024/12/26 12:26:13

文章目录

  • 1. 背景
  • 2. 代码实现和异常发现
  • 3. 解决异常
    • 3.1 从 URL入手
    • 3.2 从 WebSocket子协议的使用方式入手(真正原因)
  • 4. 总结(仍然存在的问题)

前言
本篇文章记录的是使用WebSocket进行双向通信时踩过的坑,希望能够帮助大家找到解决连接异常的正确方法。


1. 背景

本人在使用WebSocket实现“聊天室”的实时双向通信时(发消息、添加好友、处理好友请求等),一开始使用 cookie + session 的方式来管理用户的上下线情况,后来想引入 JWT,使用 token的方式来增强系统的可用性。这时我遇到了一个问题,大部分的接口都是使用 HTTP 协议的方式传输数据,因此我们可以将令牌放在 Header中用于身份校验;而 WebSocket进行双向通信时,前端无法直接在 header添加token。
经过网上查阅资料可知,有其他的方式可以在 HTTP升级为WebSocket时携带 token:(1)在 URL中追加 token(2)使用WebSocket的子协议传递 token。(通过抓包可以知道,token放在Header的 “Sec-WebSocket-Protocol” 中)

2. 代码实现和异常发现

考虑到 token直接暴露在 url 的安全性及优雅性等因素,我最终选择使用 WebSocket子协议来传递 token。以下是个人操作的过程及心路历程,若只想知道解决方法,可直接查看 3.2 从 WebSocket子协议的使用方式入手

前端代码如下:

var token = localStorage.getItem("token");
let websocket = new WebSocket("ws://" + location.host + "/WebSocketMessage", [token]);

对于后端来说,可以使用自定义拦截器来验证并处理token(存储token信息,以便后续在WebSocketSession中处理消息时使用),具体方法是自定义类继承 HandshakeInterceptor ,并重写它的两个方法。
建立连接前处理token的代码如下

@Component
public class SaveTokenInterceptor implements HandshakeInterceptor {
    // 握手前的操作,该方法返回 true 代表同意建立 WebSocket连接,false代表拒绝建立连接
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
    	// HTTP协议 未正式升级为 WebSocket时,可以对 HTTP 报文中的信息进行一定的处理
        // 1. 从 header中获取子协议的token,若token为空、过期或非法的,则拒绝建立 WebSocket连接
        String token = request.getHeaders().getFirst("Sec-WebSocket-Protocol");
        Claims claims = JwtUtil.parseToken(token);		// 解析令牌
        if (claims == null) return false;

        // 2. 将 token 中的信息放入到 attributes属性中,后续 WebSoketSession可通过方法获取 attributes,进而获取里面存放的信息
        int id = (int) claims.get(Constant.CLAIM_USERID);
        String username = (String) claims.get(Constant.CLAIM_USERNAME);
        attributes.put(Constant.USER_TOKEN_KEY, new User(id, username, ""));	
        return true;
    }


    // 握手完成后的操作
    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
		
    }
}

连接完成后,查看 WebSocketSession 是否能够正确拿到存储到 attributes 中的属性(通过第一个方法查看)

@Component
@Slf4j
public class TestWebSocket extends TextWebSocketHandler {
	// 这个方法会在 WebSocket建立成功后被自动调用
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("[WebSocketAPI] 连接成功!");
        // session.getAttributes() 得到一个 Map
        // 里面的元素为之前服务器Session存储的Attribute或放进去的其他自定义信息(上述处理token后存储的User对象)
        User user = (User) session.getAttributes().get(Constant.USER_TOKEN_KEY);
        log.info("[WebSocketAPI] afterConnectionEstablished, user: " + user);	// 验证是否将token信息放进去了
        
        if(user == null) {
            System.out.println("用户未登录!");
            return;
        }

        // 往 hash表 中存储对应客户端的WebSocket对象
        onlineUserManager.online(user.getId(), session);
    }


    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 这个方法是在 websocket 收到消息后被自动调用
        System.out.println("[WebSocketAPI] 收到消息! " + message.toString());
    }

    // 这个方法是在连接出现异常时被自动调用
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("[WebSocketAPI] 连接异常! " + exception.getMessage());
        User user = (User) session.getAttributes().get(Constant.USER_TOKEN_KEY);
        if(user == null) {
            return;
        }
        onlineUserManager.offline(user.getId(), session);
    }


    // 这个方法是在连接正常关闭后被自动调用
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("[WebSocketAPI] 关闭! " + status.toString());
        User user = (User) session.getAttributes().get(Constant.USER_TOKEN_KEY);
        if(user == null) {
            return;
        }
        onlineUserManager.offline(user.getId(), session);
    }
}

通过抓包及后端控制台日志观察上述过程:
在这里插入图片描述
在这里插入图片描述
可以发现:WebSocketSession 已经能够正确拿到 token里的信息,但是控制台也出现了WebSocket连接异常token校验失败两个异常现象。(通过浏览器控制台也可发现连接异常)

在这里插入图片描述


3. 解决异常

3.1 从 URL入手

首先,token校验失败原因比较简单,一般是在拦截器拦截 HTTP请求时发生,于是我通过抓包进行分析,但是令人感到奇怪的是所有 HTTP 请求均正常携带了 token,为什么会出现令牌解析不成功的情况呢?

经过一番思考,我决定在拦截器拦截请求时,通过 request获取所有经过拦截器的 HTTP请求的 URL,通过打印每个 HTTP 请求的URL及Header携带的 token 分析是否是前端 WebSocket 使用了子协议而导致被拦截器拦截,从而导致的异常。

@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();

        // 在方法执行前进行拦截,此处判断哪些方法可以被执行
        // 从 header中的token 判断用户是否登录
        String token = request.getHeader(Constant.USER_TOKEN_HEADER);
        System.out.println(token);
        System.out.println(requestURI);
        if (JwtUtil.parseToken(token) == null) {
            response.setStatus(401);
            return false;
        }
        return true;
    }
}

在这里插入图片描述

可以发现:上面的 HTTP 请求都符合预期,出现异常是使用 token 验证用户身份时,由浏览器默认发起的 favicon/ico(GET请求)并不会像其他 HTTP 请求一样,在其 Header 上携带 token,因此出现了令牌校验失败的情况。

通过上述偶然出现的异常也可以发现该程序上的一个问题,若一个 HTTP 的 Header 没有携带 token(即 token == null)就不需要进行令牌解析了,直接拦截即可。

因此只需将上述拦截器的拦截规则多做一个判断即可解决令牌解析的异常。(代码如下)

@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();

        // 在方法执行前进行拦截,此处判断哪些方法可以被执行
        // 从 header中的token 判断用户是否登录
        String token = request.getHeader(Constant.USER_TOKEN_HEADER);
        if (token == null || JwtUtil.parseToken(token) == null) {
            response.setStatus(401);
            return false;
        }
        return true;
    }
}

3.2 从 WebSocket子协议的使用方式入手(真正原因)

由于抓包并不能找到问题出现的原因,因此我查阅了 WebSocket 子协议的相关使用方式发现:如果前端使用了子协议携带了 token,在 WebSocket连接完成后,返回的响应报文应该携带相同的子协议内容。

因此我立马通过抓包查看了响应报文:
在这里插入图片描述

可以发现,响应报文确实没有携带对应的 Header,为了验证 WebSocket连接异常的原因导致及上述说法的正确性,我对代码作出了如下修改:

@Component
public class SaveTokenInterceptor implements HandshakeInterceptor {
    // 握手前的操作
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        // 1. 从 header中获取子协议的token,若token为空、过期或非法的,则拒绝建立 WebSocket连接
        String token = request.getHeaders().getFirst("Sec-WebSocket-Protocol");
        System.out.println("[SaveTokenInterceptor] beforeHandshake方法,token: " + token);
        Claims claims = JwtUtil.parseToken(token);
        if (claims == null) return false;

        // 2. 将 id 和 username 存入WebSocket的 attributes中
        int id = (int) claims.get(Constant.CLAIM_USERID);
        String username = (String) claims.get(Constant.CLAIM_USERNAME);
        attributes.put(Constant.USER_TOKEN_KEY, new User(id, username, ""));
        return true;
    }


    // 握手完成后的操作
    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
        // 获取 Servlet 的 HttpServletRequest 和 HttpServletResponse 对象
        // httpRequest 可以获取 HTTP协议升级前 请求报文的信息,如 header中的键值对等
        // httpResponse 可以设置 HTTP响应 的相关信息,如状态码、ContentType、header信息等
        HttpServletRequest httpRequest = ((ServletServerHttpRequest) request).getServletRequest();
        HttpServletResponse httpResponse = ((ServletServerHttpResponse) response).getServletResponse();
        if (httpRequest.getHeader("Sec-WebSocket-Protocol") != null) {
            httpResponse.addHeader("Sec-WebSocket-Protocol", httpRequest.getHeader("Sec-WebSocket-Protocol"));
        }
    }
}

上述代码即在 WebSocket 连接完成后,针对响应增加了一个子协议的 header。

注意:无法直接通过 afterHandshake 方法参数的 ServerHttpResponse 修改响应内容,因为该接口并没有提供修改响应的方法。由于ServerHttpResponse是一个接口,通过源码我们可以发现:
ServletServerHttpResponse类 实现了该接口,且在Spring中 ServletServerHttpResponse 对 Servlet的 HttpServletResponse 类进行了封装,因此我们可以将 方法参数中的 response 强转为底层的实现类ServletServerHttpResponse,再通过 ServletServerHttpResponse 类中的方法获取封装的 HttpServletResponse 类,然后就可以使用该类设置响应报文的内容。
在这里插入图片描述
在这里插入图片描述

对代码作出上述修改后,运行程序的结果如下:
在这里插入图片描述
在这里插入图片描述

4. 总结(仍然存在的问题)

通过上述修改后,已经能够使用 token 验证用户身份,管理用户上下线情况,但仍然存在问题:

  1. 在使用 cookie-session 验证用户登录状态和上下线状态时,服务器重启重启会导致存储在内存的 session 消失,因此用户后续的任何请求都可能触发拦截器的拦截操作,需重新进行登录才能正常进行后续的操作。

而对于使用 token 来代替 cookie-session,虽然触发 HTTP 请求的操作能够做到 “用户无感知”,即服务器因某种原因重启后,用户不用二次登录依然可以完成操作;但对于使用 WebSocket 进行实时通信的消息转发、好友请求转发等功能来说,该程序使用 ConcurrentHashMap 来存储 WebSocketSession,服务器一旦重启,哈希表保存的登录信息就没了,这部分功能也因此直接“失效”了。

要想解决这个问题,可能需要引入 Redis 这样的中间件或使用其他的机制来实现 WebSocket 重连,以保证用户的使用体验。

  1. 当令牌达到过期时间,而用户没有触发发送 HTTP 请求的操作,而是进行发送消息这种操作,那么上述存储用户信息的方式则是错误的,因为这种做法虽然可以让接口代码只有小幅度修改,但会出现用户令牌虽然过期了但 ConcurrentHashMap 存储的 WebSocketSession 并不会被立即移除的情况,仍然能够进行消息发送(上一次操作停留在对话框界面)。

以上就是本篇文章的全部内容了,如果这篇文章对你有些许帮助,你的点赞、收藏和评论就是对我最大的支持。
另外,文章可能存在许多不足之处,也希望你可以给我一点小小的建议,我会努力检查并改进。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1605673.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

链表(C语言)

前言&#xff1a;前面几篇文章我们详细介绍了顺序表&#xff0c;以及基于顺序表来实现的通讯录。今天我们连介绍一下链表的下一个结构链表。那么链表和顺序表究竟有什么区别呢&#xff1f;他们两个的优缺点分别是什么。今天这篇文章就带大家了解一下链表。 目录 一.链表的概念…

新质生产力走红背后,华为云的基本盘和自我修养

文 | 智能相对论 作者 | 沈浪 今年全国两会期间走红的“新质生产力”正成为中国产业转型升级的关键方向。政府工作报告更是把“大力推进现代化产业体系建设&#xff0c;加快发展新质生产力”放在今年政府工作任务的重要位置。 何为新质生产力&#xff1f;简单来说&#xff0…

C++奇迹之旅:探索C++拷贝构造函数

文章目录 &#x1f4dd;拷贝构造函数&#x1f320; 概念&#x1f309;特征 &#x1f320;浅拷贝(值拷贝)&#x1f309;深拷贝 &#x1f320;拷贝构造函数典型调用场景&#x1f320;应用时效率的思考&#x1f6a9;总结 &#x1f4dd;拷贝构造函数 &#x1f320; 概念 在现实生…

web轮播图

思路&#xff1a; 例如&#xff1a;有5张轮播的图片&#xff0c;每张图片的宽度为1024px、高度为512px.那么轮播的窗口大小就应该为一张图片的尺寸&#xff0c;即为&#xff1a;1024512。之后将这5张图片0px水平相接组成一张宽度为&#xff1a;5120px,高度依然为&#xff1a;5…

SpringBoot - Logback 打印第三方 Jar 日志解决方案

问题描述 最近碰到一个很苦恼的问题&#xff0c;就是第三方的 Jar 在自己项目里日志可以正常输出&#xff0c;但是一旦被引用到其他项目里&#xff0c;就日志死活打不出来…… 解决方案 这是原来的配置 - logback.xml <?xml version"1.0" encoding"UTF-8…

5G-A有何能耐?5G-A三载波聚合技术介绍

2024年被称作5G-A元年。5G-A作为5G下一阶段的演进技术&#xff0c;到底有何能耐呢&#xff1f; 三载波聚合&#xff08;3CC&#xff09;被认为是首个大规模商用的5G-A技术&#xff0c;将带来手机网速的大幅提升。 █ 什么是3CC 3CC&#xff0c;全称叫3 Component Carriers…

python聊天室

python聊天室 文章目录 python聊天室chat_serverchat_client使用方式1.局域网聊天2.公网聊天 下面是一个简单的示例&#xff0c;包含了chat_client.py和chat_server.py的代码。 chat_server chat_server.py监听指定的端口&#xff0c;并接收来自客户端的消息&#xff0c;并将消…

一个 .net 8 + Azure 登录 + Ant Design Blazor 的基本后台框架

一个 .net 8 Azure 登录 Ant Design Blazor 的基本后台框架 主界面使用了 Ant Design Blazor 项目模板搭建 后台技术是 .net 8 Blazor run at server 模式 登录方式使用 Azure 实现了菜单导航和路由 此外实现了读取和修改本地Json文件的功能&#xff0c;不是必须的&#x…

【Python】OPC UA模拟服务器实现

目录 服务器模拟1. 环境准备2. 服务器设置3. 服务器初始化4. 节点操作5. 读取CSV文件6. 运行服务器 查看服务器客户端总结 在工业自动化和物联网&#xff08;IoT&#xff09;领域&#xff0c;OPC UA&#xff08;开放平台通信统一架构&#xff09;已经成为一种广泛采用的数据交换…

Leo赠书活动-24期 【三大层次学习企业架构框架TOGAF】文末送书

✅作者简介&#xff1a;大家好&#xff0c;我是Leo&#xff0c;热爱Java后端开发者&#xff0c;一个想要与大家共同进步的男人&#x1f609;&#x1f609; &#x1f34e;个人主页&#xff1a;Leo的博客 &#x1f49e;当前专栏&#xff1a; 赠书活动专栏 ✨特色专栏&#xff1a;…

鸿蒙开发岗突增!它和前端开发到底有哪些区别和联系?

2024年1 月 18 日&#xff0c;鸿蒙 Next 预览版面向开发者正式开放申请。至此&#xff0c;鸿蒙原生应用版图已成型&#xff0c;这个中国自主研发的操作系统&#xff0c;正式走上了独立之路。 有许多的公司都陆续地加入了鸿蒙原生应用开发的队列&#xff0c;从年初宣布的200个应…

网络基础-基于TCP协议的Socket通讯

一、Socket通讯基于TCP协议流程图 UDP 的 Socket 编程相对简单些不在介绍。 二、 服务端程序启动 服务端程序要先跑起来&#xff0c;然后等待客户端的连接和数据。 服务端程序首先调用 socket() 函数&#xff0c;创建网络协议为 IPv4&#xff0c;以及传输协议为 TCP 的…

基于数据库现有表导出为设计文档

1.查询 SELECTCOLUMN_NAME 字段名,COLUMN_COMMENT 字段描述,COLUMN_TYPE 字段类型,false as 是否为主键 FROMINFORMATION_SCHEMA.COLUMNS wheretable_NAME region -- 表名2.查询结果 3.导出为excel

求交错且分母为阶乘的和(java)

import java.util.*; public class APP1{public static void main(String[] args){double sum0.0;int n0;int flag1;int fm1;Scanner reader new Scanner(System.in);System.out.println("请输入n的值&#xff1a;");nreader.nextInt();for(int i0;i<n;i){fm*i; …

【笔试训练】day5

今天的题&#xff0c;最后一题忘公式了&#xff0c;卡了一会推出来了 1、游游的you 思路&#xff1a; 看清题目意思就行&#xff0c;这里的相邻两个o可以重复算&#xff0c;也就是说&#xff0c;“ooo”算2分。 先算you的得分&#xff0c;再算oo 对了&#xff0c;不开long lo…

图神经网络实战——利用节点回归预测网络流量

图神经网络实战——利用节点回归预测网络流量 0. 前言1. 数据集分析2. 实现 GCN 模型执行节点回归3. 模型测试相关链接 0. 前言 在机器学习中&#xff0c;回归指的是对连续值的预测。通常与分类形成鲜明对比&#xff0c;分类的目标是找到正确的类别(即离散值&#xff0c;而非连…

C++_智能指针

文章目录 前言一、智能指针原理二、库支持的智能指针类型1.std::auto_ptr2.std::unique_ptr3.std::shared_ptr4.std::weak_ptr 三、删除器总结 前言 智能指针是一种采用RAII思想来保护申请内存不被泄露的方式来管理我们申请的内存&#xff0c;对于RAII&#xff0c;我们之前也已…

这是刚发布的人形机器人?不,分明是《午夜凶铃》现实版

波士顿动力公司大名鼎鼎的人形机器人Atlas&#xff0c;你一定见识过吧。 Atlas可以像人一样行走、奔跑和攀爬 | 波士顿动力公司 这款用液压系统打造的机器人产品&#xff0c;经过十多年的调试升级&#xff0c;才终于拥有了人类一般灵活的身手。在波士顿动力公司历年来放出的视频…

OpenHarmony UI开发-ohos-svg

简介 ohos-svg是一个SVG图片的解析器和渲染器&#xff0c;解析SVG图片并渲染到页面上。它支持大部分 SVG 1.1 规范&#xff0c;包括基本形状、路径、文本、样式和渐变,它能够渲染大多数标准的 SVG 图像。ohos-svg的优点是性能好、内存占用低。 效果展示 SVG图片解析并绘制: …

第七周学习笔记DAY.4-方法重写与多态

学完本次课程后&#xff0c;你能够&#xff1a; 实现方法重写 深入理解继承相关概念 了解Object类 会使用重写实现多态机制 会使用instanceof运算符 会使用向上转型 会使用向下转型 什么是方法重写 方法的重写或方法的覆盖&#xff08;overriding&#xff09; 1.子类根据…