上一篇博文介绍了如何利用STOMP和SpringBoot搭建一个能够实现相互通讯的聊天系统。通过该系统,我们了解了STOMP的基本使用方法以及一些基础概念。接下来,我们将在此基础上进行一些增强。由于聊天的本质是交流,因此我们需要知道是谁在与谁进行聊天,这就需要登录功能的支持。
接下来,我们将探讨如何为WebSocket通信添加身份验证功能。
I. 实例演示
1. 项目配置
首先搭建一个标准的SpringBoot项目工程,相关版本以及依赖如下
本项目借助SpringBoot 2.2.1.RELEASE
+ maven 3.5.3
+ IDEA
进行开发
核心依赖 spring-boot-starter-websocket
, 其中模板渲染引擎thymeleaf
主要是集成前端页面
1 2 3 4 5 6 7 8 9 10 11
| <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies>
|
2. WebSocket配置
首先我们先看一下后端的配置,对于SpringBoot整合STOMP,主要通过实现配置类WebSocketMessageBrokerConfigurer
来定义相关的信息:
- 注册端点Endpoint
- 定义消息转发规则
- 定义拦截器(配置消息接收、返回的相关参数)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| @Configuration @EnableWebSocketMessageBroker public class StompConfiguration implements WebSocketMessageBrokerConfigurer {
@Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app"); }
@Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws/chat/{channel}") .addInterceptors(authHandshakeInterceptor()) .withSockJS(); }
@Bean public AuthHandshakeInterceptor authHandshakeInterceptor() { return new AuthHandshakeInterceptor(); }
}
|
有兴趣的小伙伴可以对比一下上面的Endpoint配置与之前整合STOMP的示例中的配置,两者之间存在两个主要差异:
addEndpoint("/ws/chat/{channel}")
这个端点并不是一个固定的值,最后一个{channel}
是一个变量。可以理解为聊天群,不同聊天群中的信息是相互隔离的,不会出现串频的情况。
addInterceptors(authHandshakeInterceptor())
这里设置了身份鉴权拦截器,也是本文的核心内容。在WebSocket连接建立之后,如何识别当前建立连接的用户呢?
3. 身份鉴权拦截器
与SpringMVC类似,WebSocket也支持拦截器。在握手之前,可以通过识别用户身份来实现辅助操作。例如,我们可以从cookie中获取用户信息,并将其写入消息的全局属性请求头。
实现方式主要是通过拦截器在握手过程中进行用户身份验证,并将用户信息存储在全局属性中,以便在整个WebSocket连接的生命周期内使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| @Slf4j public class AuthHandshakeInterceptor extends HttpSessionHandshakeInterceptor { @Autowired private UserService userService;
@Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { System.out.println("开始握手"); if (request instanceof ServletServerHttpRequest) { for (Cookie cookie : ((ServletServerHttpRequest) request).getServletRequest().getCookies()) { if ("l-login".equalsIgnoreCase(cookie.getName())) { String val = cookie.getValue(); String uname = userService.getUsername(val); log.info("获取登录用户: {}", uname); attributes.put("uname", uname); return true; } } return false; } return super.beforeHandshake(request, response, wsHandler, attributes); }
@Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception ex) { System.out.println("握手结束"); super.afterHandshake(request, response, wsHandler, ex); } }
|
上面的拦截器可以通过cookie来识别用户身份。当用户登录成功后,将用户名写入请求头uname中。这样,在后续的WebSocket通信过程中,就可以通过访问请求头uname来获取当前登录的用户信息
4. 用户登录
我们还是基于springmvc搭建一个用户的登录入口,直接基于内存做一个最简单的用户登录管理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| @Slf4j @Service public class UserService { private Map<String, String> userCache;
@PostConstruct public void init() { userCache = new HashMap<>(); }
public String login(String uname) { return userCache.computeIfAbsent(uname, s -> UUID.randomUUID().toString()); }
public String getUsername(String session) { for (Map.Entry<String, String> entry : userCache.entrySet()) { if (entry.getValue().equalsIgnoreCase(session)) { return entry.getKey(); } } return null; }
public String getUsernameByCookie() { ServletRequestAttributes requestAttr = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()); if (requestAttr != null) { HttpServletRequest request = requestAttr.getRequest(); if (request.getCookies() == null) { return null; }
Cookie ck = Arrays.stream(request.getCookies()).filter(s -> s.getName().equalsIgnoreCase("l-login")).findAny().orElse(null); if (ck != null) { return getUsername(ck.getValue()); } } return null; } }
|
新增一个用户登录的入口,用户登录成功之后,将session写入cookie,有效期30天
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @Controller public class ChatController { @Autowired private UserService userService;
private static final int COOKIE_AGE = 30 * 86400;
public static Cookie newCookie(String key, String session) { return newCookie(key, session, "/", COOKIE_AGE); }
public static Cookie newCookie(String key, String session, String path, int maxAge) { Cookie cookie = new Cookie(key, session); cookie.setPath(path); cookie.setMaxAge(maxAge); return cookie; }
@GetMapping(path = "login") @ResponseBody public String login(String name, HttpServletResponse response) { String sessionId = userService.login(name); response.addCookie(newCookie("l-login", sessionId)); return sessionId; } }
|
5. ws聊天实现
接下来我们开始写登录聊天的相关业务逻辑
后端实现
首先提供一个消息转发的后端接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Controller public class ChatController {
@MessageMapping("/hello/{channel}") public void sayHello(String content, @DestinationVariable("channel") String channel, SimpMessageHeaderAccessor headerAccessor) { String text = String.format("【%s】发送内容:%s", headerAccessor.getSessionAttributes().get("uname"), content); WsAnswerHelper.publish("/topic/chat/" + channel, text); } }
|
注意上面的实现,有几个关键信息
@MessageMapping("/hello/{channel}")
这里的{channel}
是一个传参形式,表示接收不同目标来源的消息;其取值通过DestinationVariable("channel") String channel
来获取
举个简单的例子:
- 客户端往
app/hello/globalChannel
发送的消息,会被后端转发给 /topic/chat/globalChannel
- 客户端往
app/hello/signleChannel
发送的消息,会被后端转发给 /topic/chat/signleChannel
headerAccessor.getSessionAttributes().get("uname")
从请求头中获取用户身份,没错,这里的uname就是在上面的拦截器 AuthHandshakeInterceptor
写入的
- 消息发送
写了一个简单的工具类,实现后端给客户端发送消息, WsAnswerHelper
实现如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Component public class WsAnswerHelper { @Autowired private SimpMessagingTemplate simpMessagingTemplate; private static WsAnswerHelper instance;
@PostConstruct public void init() { WsAnswerHelper.instance = this; }
public static void publish(String destination, Object msg) { instance.simpMessagingTemplate.convertAndSend(destination, msg); } }
|
最后再给出前端访问入口
1 2 3 4 5 6 7
| @Controller public class ChatController { @GetMapping(path = "/") public String index(Model model) { return "index"; } }
|
前端实现
前端的实现和上一篇博文的基本没有太大差别,无非是多了一个登录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| <!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/web/thymeleaf/layout"> <head> <title>Hello WebSocket</title> <link th:href="@{/main.css}" rel="stylesheet"> <link href="/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous"> <script src="/js/jquery.js"></script> <script src="/js/sockjs.min.js"></script> <script src="/js/stomp.min.js"></script> <script src="/index.js"></script> </head> <body>
<div id="main-content" class="container"> <div class="row"> <div class="col-md-6"> <form class="form-inline"> <div class="form-group"> <label for="connect">WebSocket 连接:</label> <input type="text" id="endpoint" class="form-control" value="globalChannel"> <input type="text" id="uname" class="form-control" value="一灰灰"> <button id="connect" class="btn btn-warning" type="submit">Connect</button> <button id="disconnect" class="btn btn-danger" type="submit" disabled="disabled">Disconnect </button> </div> </form> </div> <div class="col-md-6"> <form class="form-inline"> <div class="form-group"> <label for="name">send some message: </label> <input type="text" id="name" class="form-control" placeholder="message here..."> <button id="send" class="btn btn-dark" type="submit">Send</button> </div> </form> </div> </div> <div class="row"> <div class="col-md-12"> <table id="conversation" class="table table-striped"> <thead> <tr> <th>Greetings</th> </tr> </thead> <tbody id="greetings"> </tbody> </table> </div> </div> </div> </body> </html>
|
js实现如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| let stompClient = null;
function setConnected(connected) { $("#connect").prop("disabled", connected); $("#disconnect").prop("disabled", !connected); if (connected) { $("#conversation").show(); } else { $("#conversation").hide(); } $("#greetings").html(""); }
function connect() { const uname = $("#uname").val(); let xhr = new XMLHttpRequest(); xhr.open('GET', 'http://localhost:8080/login?name=' + uname, false); xhr.send();
const channel = $("#endpoint").val(); const socket = new SockJS('/ws/chat/' + channel); stompClient = Stomp.over(socket);
stompClient.connect({"uname": $("#uname").val()}, function (frame) { setConnected(true); console.log('Connected: ' + frame); let topic = '/topic/chat/' + channel; console.log("订阅:", topic); stompClient.subscribe(topic, function (greeting) { console.log("resp: ", greeting.body) showGreeting(greeting.body); }); }); socket.onclose = disconnect; }
function disconnect() { if (stompClient !== null) { stompClient.disconnect(); } setConnected(false); console.log("Disconnected"); }
function sendName() { const channel = $("#endpoint").val(); const headers = { 'u-name': $("#uname").val(), }; stompClient.send("/app/hello/" + channel, headers, JSON.stringify({'name': $("#name").val()})); }
function showGreeting(message) { $("#greetings").prepend("<tr><td>" + message + "</td></tr>"); }
$(function () { $("form").on('submit', function (e) { e.preventDefault(); }); $("#connect").click(function () { connect(); }); $("#disconnect").click(function () { disconnect(); }); $("#send").click(function () { sendName(); }); });
|
和之前的示例相比,区别在于建立连接之前,先调用了登录接口实现自动登录
6. 示例演示
接下来我们演示一下,用户登录之后,再进行聊天的表现形式

面的示意图也可以看出,在相同channel之间的用户可以相互通信。聊天信息前面都会带上发送这个消息的用户名。这样可以方便用户识别和区分来自不同用户的聊天信息。
7. 小结
本文通过实例演示了WebSocket的身份鉴权,其底层依然是借助Cookie来实现用户身份识别。与常规的Cookie鉴权不同之处在于,在WebSocket连接的生命周期内,通过HttpSessionHandshakeInterceptor拦截器来解析用户身份,并将相关信息写入到请求头中,以供其他地方进行使用。
本文的主要目的是为大家演示如何实现WebSocket的身份识别验证,整体的功能相对较少。以下是一些可能的应用场景和实现方式:
- 当一个用户加入聊天室时,系统可以通过广播一个通知来告知其他用户。具体实现方式可以是,在用户加入聊天室时,服务器将该用户的身份信息发送给所有已连接的客户端,客户端收到通知后可以在界面上显示相应的提示信息。
- 当一个用户离开聊天室时,系统同样可以通过广播一个通知来告知其他用户。具体实现方式可以是,在用户离开聊天室时,服务器将该用户的身份信息发送给所有已连接的客户端,客户端收到通知后可以在界面上移除相应的提示信息。
- 如现在一个订阅对应一个websocket连接,那么是否可以一个ws连接,通过订阅不同的topic,来实现多群组聊天的功能呢?
下篇博文将探讨如何实现以下功能:
- 当一个用户加入聊天时,系统广播一个通知。
- 当用户离开聊天时,系统广播一个通知。
- 使用一个WebSocket连接,通过订阅不同的主题来实现多群组聊天的功能。
敬请期待下篇博文!我是你们的好朋友一灰灰
II. 不能错过的源码和相关知识点
0. 项目
系列博文:
1. 微信公众号: 一灰灰Blog
尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激
下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛
一灰灰blog
Related Issues not found
Please contact @liuyueyi to initialize the comment