3.从零开始学习SpringBoot WebSocket身份鉴权,让你的项目更上一层楼!
上一篇博文介绍了如何利用STOMP和SpringBoot搭建一个能够实现相互通讯的聊天系统。通过该系统,我们了解了STOMP的基本使用方法以及一些基础概念。接下来,我们将在此基础上进行一些增强。由于聊天的本质是交流,因此我们需要知道是谁在与谁进行聊天,这就需要登录功能的支持。
接下来,我们将探讨如何为WebSocket通信添加身份验证功能。
I. 实例演示
1. 项目配置
首先搭建一个标准的SpringBoot项目工程,相关版本以及依赖如下
本项目借助SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发
核心依赖 spring-boot-starter-websocket, 其中模板渲染引擎thymeleaf主要是集成前端页面
<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
- 定义消息转发规则
- 定义拦截器(配置消息接收、返回的相关参数)
@Configuration
@EnableWebSocketMessageBroker
public class StompConfiguration implements WebSocketMessageBrokerConfigurer {
    /**
     * 这里定义的是客户端接收服务端消息的相关信息
     *
     * @param registry
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 消息代理指定了客户端订阅地址,前端订阅的就是这个路径, 接收后端发送的消息
        // 对应 index.js中的 stompClient.subscribe('/topic/hello'
        registry.enableSimpleBroker("/topic");
        // 表示配置一个或多个前缀,通过这些前缀过滤出需要被注解方法处理的消息。
        // 例如,前缀为 /app 的 destination 可以通过@MessageMapping注解的方法处理,
        // 而其他 destination (例如 /topic /queue)将被直接交给 broker 处理
        registry.setApplicationDestinationPrefixes("/app");
    }
    /**
     * 添加一个服务端点,来接收客户端的连接
     * 即客户端创建ws时,指定的地址, let socket = new WebSocket("ws://ws/chat/xxx");
     *
     * @param registry
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // Endpoint指定了客户端建立连接时的请求地址
        registry.addEndpoint("/ws/chat/{channel}")
                // 设置拦截器,从cookie中识别出登录用户
                .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连接的生命周期内使用。
@Slf4j
public class AuthHandshakeInterceptor extends HttpSessionHandshakeInterceptor {
    @Autowired
    private UserService userService;
    /**
     * 握手前,进行用户身份校验识别,  继续握手返回true, 中断握手返回false. 通过attributes参数设置登录的用户信息
     *
     * @param request
     * @param response
     * @param wsHandler
     * @param attributes: 即对应的是Message中的 simpSessionAttributes 请求头
     * @return
     * @throws Exception
     */
    @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搭建一个用户的登录入口,直接基于内存做一个最简单的用户登录管理
@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天
@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聊天实现
接下来我们开始写登录聊天的相关业务逻辑
后端实现
首先提供一个消息转发的后端接口
@Controller
public class ChatController {
    /**
     * 当接受到客户端发送的消息时, 发送的路径是: /app/hello (这个/app前缀是 StompConfiguration 中的配置的)
     * 将返回结果推送给所有订阅了 /topic/chat/channel 的消费者
     *
     * @param content
     * @return
     */
    @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实现如下
@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);
    }
}
最后再给出前端访问入口
@Controller
public class ChatController {
    @GetMapping(path = "/")
    public String index(Model model) {
        return "index";
    }
}
前端实现
前端的实现和上一篇博文的基本没有太大差别,无非是多了一个登录
<!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实现如下
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();
    // 第一步: 创建xhr对象
    let xhr = new XMLHttpRequest();
    // 第二步: 调用open函数 指定请求方式 与URL地址
    xhr.open('GET', 'http://localhost:8080/login?name=' + uname, false);
    // 第三步: 调用send函数 发起ajax请求
    xhr.send();
    // 其次建立ws链接
    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;
        // showGreeting("链接成功! 欢迎: " + $("#uname").val());
        console.log("订阅:", topic);
        stompClient.subscribe(topic, function (greeting) {
            // 表示这个长连接,订阅了 "/topic/hello" , 这样后端像这个路径转发消息时,我们就可以拿到对应的返回
            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(),
    };
    // 表示将消息转发到哪个目标,类似与http请求中的path路径,对应的是后端 @MessageMapping 修饰的方法
    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连接,通过订阅不同的主题来实现多群组聊天的功能。
敬请期待下篇博文!我是你们的好朋友一灰灰