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

文章目录

  • 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 并不会被立即移除的情况,仍然能够进行消息发送(上一次操作停留在对话框界面)。

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

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

将gdip-yolo集成到yolov9模型项目中(支持预训练的yolov9模型)

1、yolov9模型概述 1.1 yolov9 YOLOv9意味着实时目标检测的重大进步&#xff0c;引入了可编程梯度信息&#xff08;PGI&#xff09;和通用高效层聚合网络&#xff08;GELAN&#xff09;等开创性技术。该模型在效率、准确性和适应性方面取得了显著改进&#xff0c;在MS COCO数…

「 安全工具介绍 」软件成分分析工具Black Duck,业界排名TOP 1的SCA工具

在现代的 DevOps 或 DevSecOps 环境中&#xff0c;SCA 激发了“左移”范式的采用。提早进行持续的 SCA 测试&#xff0c;使开发人员和安全团队能够在不影响安全性和质量的情况下提高生产力。前期在博文《「 网络安全常用术语解读 」软件成分分析SCA详解&#xff1a;从发展背景到…

Qt-饼图示范

1.效果图 2.代码如下 2.1 .h文件 #ifndef PIECHARTWIDGET_H #define PIECHARTWIDGET_H#include <QWidget> #include <QChartView> #include <QPieSeries>#include<QVBoxLayout> #include<QMessageBox> #include <QtCharts>struct PieDat…

FastAPI - uvicorn设置 logger 日志格式

怎么将日志打印到文件 在main.py加入log_config“./uvicorn_config.json” import uvicornif __name__ "__main__":uvicorn.run("app:app", host"0.0.0.0", port8000, log_config"./uvicorn_config.json")uvicorn_config.json {&qu…

“互联网+”创意创业大赛活动方案

大赛历时6个月&#xff0c;总体分两个赛程&#xff1a;一是策划创意阶段。评审的是方案。二是组织实施阶段。通过阶段一立项的项目由公司协助实施&#xff0c;最终评审的是项目落实情况。学生可两个赛程单独参加&#xff0c;也可连续参加。 具体流程及时间安排如下&#xff1a;…

ansible-tower连接git实现简单执行playbook

前提&#xff1a;安装好ansible-tower和git&#xff0c;其中git存放ansible得剧本 其中git中得内容为&#xff1a; --- - name: yjxtesthosts: yinremote_user: rootgather_facts: noroles:- testroles/test/tasks/main.yml #文件内容 --- #- name: Perform Test Task # tas…

单链表-通讯录

目录 单链表实现 通讯录代码实现 初始化 初始化函数 添加 删除 展示 查找 修改 销毁 代码展示 main.c text.c text.h list.c list.h 和前面的通讯录实现差不多这次就是实现一个以单链表为底层的通讯录 单链表实现 数据结构&#xff1a;单链表-CSDN博客 通讯…

OpenHarmony多媒体-video_trimmer

简介 videotrimmer是在OpenHarmony环境下&#xff0c;提供视频剪辑能力的三方库。 效果展示&#xff1a; 安装教程 ohpm install ohos/videotrimmerOpenHarmony ohpm环境配置等更多内容&#xff0c;请参考 如何安装OpenHarmony ohpm包 。 使用说明 目前支持MP4格式。 视频…

docker部署的nginx配置ssl证书https

申请ssl证书&#xff0c;已腾讯的免费证书为例 2.上传证书到linux服务器 2.1 映射ssql目录 首先确保容器命令已映射宿主机目录&#xff0c;不一定是ssl&#xff0c;也可以是其他路径。 2.2 上传文件到指定路径 以我映射的ssl路径为例&#xff0c;我上传到宿主机的 /usr/local…

【GEE实践应用】使用MODIS NDVI数据集绘制研究区域每日NDVI序列曲线

// 设置研究区域 var geometry table;// 选择MODIS NDVI 数据集 var modisNDVI ee.ImageCollection(MODIS/006/MOD13A2).filterBounds(geometry).filterDate(2000-01-01, 2023-12-31);// 计算每天的平均 NDVI var dailyMeanNDVI modisNDVI.map(function(image) {var date e…

(最详细)关于List和Set的区别与应用

关于List与Set的区别 List和Set都继承自Collection接口&#xff1b; List接口的实现类有三个&#xff1a;LinkedList、ArrayList、Vector。Set接口的实现类有两个&#xff1a;HashSet(底层由HashMap实现)、LinkedHashSet。 在List中&#xff0c;List.add()是基于数组的形式来添…

OpenHarmony网络组件-Mars

项目简介 Mars 是一个跨平台的网络组件&#xff0c;包括主要用于网络请求中的长连接&#xff0c;短连接&#xff0c;是基于 socket 层的解决方案&#xff0c;在网络调优方面有更好的可控性&#xff0c;暂不支持HTTP协议。 Mars 极大的方便了开发者的开发效率。 效果演示 编译…

简述Kafka的高可靠性

什么叫可靠性&#xff1f; 大家都知道&#xff0c;系统架构有三高&#xff1a;「高性能、高并发和高可用」&#xff0c;三者的重要性不言而喻。 对于任意系统&#xff0c;想要同时满足三高都是一件非常困难的事情&#xff0c;大型业务系统或者传统中间件都会搭建复杂的架构来…

万字长文带你APK反编译重签名aabapks转换

Android反编译 反编译&#xff08;Decompilation&#xff09;是将已编译的程序&#xff08;比如二进制代码&#xff09;转换回更高级别的编程语言代码的过程。这通常用于理解程序的工作原理&#xff0c;进行软件审计&#xff0c;恢复丢失的源代码&#xff0c;或者进行教学研究…

提升数据质量的三大要素:清洗prompt、数据溯源、数据增强(含Reviewer2和PeerRead)​

前言 我带队的整个大模型项目团队超过40人了&#xff0c;分六个项目组 每个项目组都是全职带兼职&#xff0c;且都会每周确定任务/目标/计划然后各项目组各自做任务拆解&#xff0c;有时同组内任务多时 则2-4人一组 方便并行和讨论&#xff0c;每周文档记录当周工作内容&…

Leetcode 4.18

Leetcode 1.无重复字符的最长子串2.最长回文子串3.整数反转4.字符串转换整数 (atoi)5.正则表达式匹配 1.无重复字符的最长子串 无重复字符的最长子串 滑动窗口&#xff0c;先让右指针右移&#xff0c;如果发现这个子串有元素和右指针当前元素重复。 则&#xff1a; 左指针右移…

HackmyVM-----Boxing靶机

文章目录 正常打靶流程1.获取靶机IP地址2.获取靶机端口服务3.访问网页4.添加域名WindowsLinux 5.访问域名6.nc反弹shell 7.结束 正常打靶流程 1.获取靶机IP地址 ┌──(root㉿kali)-[/home/kali] └─# arp-scan -l Interface: eth0, type: EN10MB, MAC: 00:0c:29:10:3c:9b, …

Stable Diffusion XL优化终极指南

如何在自己的显卡上获得SDXL的最佳质量和性能&#xff0c;以及如何选择适当的优化方法和工具&#xff0c;这一让GenAI用户倍感困惑的问题&#xff0c;业内一直没有一份清晰而详尽的评测报告可供参考。直到全栈开发者Flix San出手。 在本文中&#xff0c;Flix介绍了相关SDXL优化…

H264标准协议基础3

参考博文 上一篇H264标准协议基础2 1.解码视频帧的poc计算 2.残差4x4 矩阵中的trailingones & numcoeff 2.1查表 trailingones 表达出尾部one(1,-1)系数的个数,按照zigzag扫描出(1,-1)个数,trailingones的最大为3; numcoeff 表达非零值系数的个数,最多为16个…

uniapp开发 如何获取IP地址?

一定要看到最后&#xff01;&#xff01;&#xff01; 一、需求 使用uniapp开发小程序时&#xff0c;需要调取【记录日活动统计】的接口&#xff0c;而这个接口需要传递一个ip给后台&#xff0c; 那么前端如何获取ip呢&#xff1f;下面代码里可以实现 二、代码实现 1.在项…