【WebSocket毗连非常】前端使用WebSocket子协议传递token时,Java后端的正 ...

打印 上一主题 下一主题

主题 867|帖子 867|积分 2601

前言
本篇文章记载的是使用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子协议的使用方式入手。
前端代码如下:
  1. var token = localStorage.getItem("token");
  2. let websocket = new WebSocket("ws://" + location.host + "/WebSocketMessage", [token]);
复制代码
对于后端来说,可以使用自定义拦截器来验证并处理token(存储token信息,以便后续在WebSocketSession中处理消息时使用),详细方法是自定义类继续 HandshakeInterceptor ,并重写它的两个方法。
建立毗连前处理token的代码如下
  1. @Component
  2. public class SaveTokenInterceptor implements HandshakeInterceptor {
  3.     // 握手前的操作,该方法返回 true 代表同意建立 WebSocket连接,false代表拒绝建立连接
  4.     @Override
  5.     public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
  6.             // HTTP协议 未正式升级为 WebSocket时,可以对 HTTP 报文中的信息进行一定的处理
  7.         // 1. 从 header中获取子协议的token,若token为空、过期或非法的,则拒绝建立 WebSocket连接
  8.         String token = request.getHeaders().getFirst("Sec-WebSocket-Protocol");
  9.         Claims claims = JwtUtil.parseToken(token);                // 解析令牌
  10.         if (claims == null) return false;
  11.         // 2. 将 token 中的信息放入到 attributes属性中,后续 WebSoketSession可通过方法获取 attributes,进而获取里面存放的信息
  12.         int id = (int) claims.get(Constant.CLAIM_USERID);
  13.         String username = (String) claims.get(Constant.CLAIM_USERNAME);
  14.         attributes.put(Constant.USER_TOKEN_KEY, new User(id, username, ""));       
  15.         return true;
  16.     }
  17.     // 握手完成后的操作
  18.     @Override
  19.     public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
  20.                
  21.     }
  22. }
复制代码
毗连完成后,检察 WebSocketSession 是否能够正确拿到存储到 attributes 中的属性(通过第一个方法检察)
  1. @Component
  2. @Slf4j
  3. public class TestWebSocket extends TextWebSocketHandler {
  4.         // 这个方法会在 WebSocket建立成功后被自动调用
  5.     @Override
  6.     public void afterConnectionEstablished(WebSocketSession session) throws Exception {
  7.         System.out.println("[WebSocketAPI] 连接成功!");
  8.         // session.getAttributes() 得到一个 Map
  9.         // 里面的元素为之前服务器Session存储的Attribute或放进去的其他自定义信息(上述处理token后存储的User对象)
  10.         User user = (User) session.getAttributes().get(Constant.USER_TOKEN_KEY);
  11.         log.info("[WebSocketAPI] afterConnectionEstablished, user: " + user);        // 验证是否将token信息放进去了
  12.         
  13.         if(user == null) {
  14.             System.out.println("用户未登录!");
  15.             return;
  16.         }
  17.         // 往 hash表 中存储对应客户端的WebSocket对象
  18.         onlineUserManager.online(user.getId(), session);
  19.     }
  20.     @Override
  21.     protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
  22.         // 这个方法是在 websocket 收到消息后被自动调用
  23.         System.out.println("[WebSocketAPI] 收到消息! " + message.toString());
  24.     }
  25.     // 这个方法是在连接出现异常时被自动调用
  26.     @Override
  27.     public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
  28.         System.out.println("[WebSocketAPI] 连接异常! " + exception.getMessage());
  29.         User user = (User) session.getAttributes().get(Constant.USER_TOKEN_KEY);
  30.         if(user == null) {
  31.             return;
  32.         }
  33.         onlineUserManager.offline(user.getId(), session);
  34.     }
  35.     // 这个方法是在连接正常关闭后被自动调用
  36.     @Override
  37.     public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
  38.         System.out.println("[WebSocketAPI] 关闭! " + status.toString());
  39.         User user = (User) session.getAttributes().get(Constant.USER_TOKEN_KEY);
  40.         if(user == null) {
  41.             return;
  42.         }
  43.         onlineUserManager.offline(user.getId(), session);
  44.     }
  45. }
复制代码
通过抓包及后端控制台日记观察上述过程:


可以发现:WebSocketSession 已经能够正确拿到 token里的信息,但是控制台也出现了WebSocket毗连非常token校验失败两个非常征象。(通过欣赏器控制台也可发现毗连非常)


3. 解决非常

3.1 从 URL入手

起首,token校验失败原因比力简朴,一般是在拦截器拦截 HTTP哀求时发生,于是我通过抓包进行分析,但是令人感到奇怪的是全部 HTTP 哀求均正常携带了 token,为什么会出现令牌解析不乐成的环境呢?
经过一番思考,我决定在拦截器拦截哀求时,通过 request获取全部经过拦截器的 HTTP哀求的 URL,通过打印每个 HTTP 哀求的URL及Header携带的 token 分析是否是前端 WebSocket 使用了子协议而导致被拦截器拦截,从而导致的非常。
  1. @Component
  2. public class LoginInterceptor implements HandlerInterceptor {
  3.     @Override
  4.     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  5.         String requestURI = request.getRequestURI();
  6.         // 在方法执行前进行拦截,此处判断哪些方法可以被执行
  7.         // 从 header中的token 判断用户是否登录
  8.         String token = request.getHeader(Constant.USER_TOKEN_HEADER);
  9.         System.out.println(token);
  10.         System.out.println(requestURI);
  11.         if (JwtUtil.parseToken(token) == null) {
  12.             response.setStatus(401);
  13.             return false;
  14.         }
  15.         return true;
  16.     }
  17. }
复制代码

可以发现:上面的 HTTP 哀求都符合预期,出现非常是使用 token 验证用户身份时,由欣赏器默认发起的 favicon/ico(GET哀求)并不会像其他 HTTP 哀求一样,在其 Header 上携带 token,因此出现了令牌校验失败的环境。
通过上述偶然出现的非常也可以发现该步调上的一个题目,若一个 HTTP 的 Header 没有携带 token(即 token == null)就不必要进行令牌解析了,直接拦截即可。
因此只需将上述拦截器的拦截规则多做一个判断即可解决令牌解析的非常。(代码如下)
  1. @Component
  2. public class LoginInterceptor implements HandlerInterceptor {
  3.     @Override
  4.     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  5.         String requestURI = request.getRequestURI();
  6.         // 在方法执行前进行拦截,此处判断哪些方法可以被执行
  7.         // 从 header中的token 判断用户是否登录
  8.         String token = request.getHeader(Constant.USER_TOKEN_HEADER);
  9.         if (token == null || JwtUtil.parseToken(token) == null) {
  10.             response.setStatus(401);
  11.             return false;
  12.         }
  13.         return true;
  14.     }
  15. }
复制代码

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

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

可以发现,响应报文确实没有携带对应的 Header,为了验证 WebSocket毗连非常的原因导致及上述说法的正确性,我对代码作出了如下修改:
  1. @Component
  2. public class SaveTokenInterceptor implements HandshakeInterceptor {
  3.     // 握手前的操作
  4.     @Override
  5.     public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
  6.         // 1. 从 header中获取子协议的token,若token为空、过期或非法的,则拒绝建立 WebSocket连接
  7.         String token = request.getHeaders().getFirst("Sec-WebSocket-Protocol");
  8.         System.out.println("[SaveTokenInterceptor] beforeHandshake方法,token: " + token);
  9.         Claims claims = JwtUtil.parseToken(token);
  10.         if (claims == null) return false;
  11.         // 2. 将 id 和 username 存入WebSocket的 attributes中
  12.         int id = (int) claims.get(Constant.CLAIM_USERID);
  13.         String username = (String) claims.get(Constant.CLAIM_USERNAME);
  14.         attributes.put(Constant.USER_TOKEN_KEY, new User(id, username, ""));
  15.         return true;
  16.     }
  17.     // 握手完成后的操作
  18.     @Override
  19.     public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
  20.         // 获取 Servlet 的 HttpServletRequest 和 HttpServletResponse 对象
  21.         // httpRequest 可以获取 HTTP协议升级前 请求报文的信息,如 header中的键值对等
  22.         // httpResponse 可以设置 HTTP响应 的相关信息,如状态码、ContentType、header信息等
  23.         HttpServletRequest httpRequest = ((ServletServerHttpRequest) request).getServletRequest();
  24.         HttpServletResponse httpResponse = ((ServletServerHttpResponse) response).getServletResponse();
  25.         if (httpRequest.getHeader("Sec-WebSocket-Protocol") != null) {
  26.             httpResponse.addHeader("Sec-WebSocket-Protocol", httpRequest.getHeader("Sec-WebSocket-Protocol"));
  27.         }
  28.     }
  29. }
复制代码
上述代码即在 WebSocket 毗连完成后,针对响应增加了一个子协议的 header。
注意:无法直接通过 afterHandshake 方法参数的 ServerHttpResponse 修改响应内容,由于该接口并没有提供修改响应的方法。由于ServerHttpResponse是一个接口,通过源码我们可以发现:
ServletServerHttpResponse类 实现了该接口,且在Spring中 ServletServerHttpResponse 对 Servlet的 HttpServletResponse 类进行了封装,因此我们可以将 方法参数中的 response 强转为底层的实现类ServletServerHttpResponse,再通过 ServletServerHttpResponse 类中的方法获取封装的 HttpServletResponse 类,然后就可以使用该类设置响应报文的内容。


对代码作出上述修改后,运行步调的效果如下:


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

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

  • 在使用 cookie-session 验证用户登录状态和上下线状态时,服务器重启重启会导致存储在内存的 session 消失,因此用户后续的任何哀求都大概触发拦截器的拦截操作,需重新进行登录才气正常进行后续的操作。
而对于使用 token 来取代 cookie-session,虽然触发 HTTP 哀求的操作能够做到 “用户无感知”,即服务器因某种原因重启后,用户不消二次登录依然可以完成操作;但对于使用 WebSocket 进行及时通讯的消息转发、好友哀求转发等功能来说,该步调使用 ConcurrentHashMap 来存储 WebSocketSession,服务器一旦重启,哈希表保存的登录信息就没了,这部门功能也因此直接“失效”了。
要想解决这个题目,大概必要引入 Redis 如许的中间件或使用其他的机制来实现 WebSocket 重连,以保证用户的使用体验。

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

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

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

罪恶克星

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表