If the framework integrates WebSocket with user information authentication

1. Basic knowledge of WebSocket

We usually use the HTTP/1.1 protocol       most often for front and backend requests . It has a flaw. Communication can only be initiated by the client. If we want to continuously obtain server information, we must continuously poll and make requests. Then if we need to change the server status To be able to proactively notify the client, you need to use WebSocket. WebSocket is a network transmission protocol. It is also located in the application layer of the OSI model and is built on the transport layer protocol TCP. The main feature is that full-duplex communication allows data to be transmitted in two directions at the same time. It is equivalent in capability to the combination of two simplex communication methods. For example, it refers to A→B and B→A at the same time. It is an instantaneous synchronized binary frame using Binary frame structure, syntax and semantics are completely incompatible with HTTP
        Compared with http/2, WebSocket focuses more on "real-time communication", while HTTP/2 focuses more on improving transmission efficiency, so the frame structure of the two is also very different. It does not define a stream like HTTP/2, so it does not exist. Features such as multiplexing and priority are full-duplex in themselves, and there is no need for the server push protocol name to introduce ws and wss to represent the plaintext and ciphertext websocket protocols respectively, and the default port is 80 or 443, which is almost the same as http.
    If the service simply pushes messages to the client and does not involve the client sending messages to the server, it can also be implemented using Spring WebFlux technology, which is directly established on the current http connection. In essence, it maintains a long http connection, which is suitable for simple In server data push scenarios, server push events are used to make it lighter and more convenient.
ps: Regarding the OSI seven-layer model, you can read my other article https://stronger.blog.csdn.net/article/details/127725957

2. If the framework integrates WebSocket

In the Issues on the gitee code repository of Ruoyi, this matter has been mentioned many times, for example
The author also made it clear that WebSocket will not be integrated into the framework and will be provided in the form of an extension plug-in. The portal will be based on the URL ; the code for downloading the author's network disk is roughly as follows
Configuration classWebSocketConfig
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * websocket 配置
 * 
 * @author ruoyi
 */
@Configuration
public class WebSocketConfig
{
    @Bean
    public ServerEndpointExporter serverEndpointExporter()
    {
        return new ServerEndpointExporter();
    }
}

Utility class SemaphoreUtils

import java.util.concurrent.Semaphore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * 信号量相关处理
 * 
 * @author ruoyi
 */
public class SemaphoreUtils{
    /**
     * SemaphoreUtils 日志控制器
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(SemaphoreUtils.class);

    /**
     * 获取信号量
     * 
     * @param semaphore
     * @return
     */
    public static boolean tryAcquire(Semaphore semaphore)
    {
        boolean flag = false;
        try
        {
            flag = semaphore.tryAcquire();
        }
        catch (Exception e)
        {
            LOGGER.error("获取信号量异常", e);
        }
        return flag;
    }

    /**
     * 释放信号量
     * 
     * @param semaphore
     */
    public static void release(Semaphore semaphore)
    {
        try
        {
            semaphore.release();
        }
        catch (Exception e)
        {
            LOGGER.error("释放信号量异常", e);
        }
    }
}

Server class WebSocketServer

import java.util.concurrent.Semaphore;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
import com.lxh.demo.util.SemaphoreUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

/**
 * websocket 消息处理
 * 
 * @author ruoyi
 */
@Component
@ServerEndpoint("/websocket/message")
public class WebSocketServer
{
    /**
     * WebSocketServer 日志控制器
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketServer.class);

    /**
     * 默认最多允许同时在线人数100
     */
    public static int socketMaxOnlineCount = 100;

    private static Semaphore socketSemaphore = new Semaphore(socketMaxOnlineCount);

    /**
     * 连接建立成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session) throws Exception{
        boolean semaphoreFlag = false;
        // 尝试获取信号量
        semaphoreFlag = SemaphoreUtils.tryAcquire(socketSemaphore);
        if (!semaphoreFlag)
        {
            // 未获取到信号量
            LOGGER.error("\n 当前在线人数超过限制数- {}", socketMaxOnlineCount);
            WebSocketUsers.sendMessageToUserByText(session, "当前在线人数超过限制数:" + socketMaxOnlineCount);
            session.close();
        }
        else
        {
            // 添加用户
            WebSocketUsers.put(session.getId(), session);
            LOGGER.info("\n 建立连接 - {}", session);
            LOGGER.info("\n 当前人数 - {}", WebSocketUsers.getUsers().size());
            WebSocketUsers.sendMessageToUserByText(session, "连接成功");
        }
    }

    /**
     * 连接关闭时处理
     */
    @OnClose
    public void onClose(Session session)
    {
        LOGGER.info("\n 关闭连接 - {}", session);
        // 移除用户
        WebSocketUsers.remove(session.getId());
        // 获取到信号量则需释放
        SemaphoreUtils.release(socketSemaphore);
    }

    /**
     * 抛出异常时处理
     */
    @OnError
    public void onError(Session session, Throwable exception) throws Exception
    {
        if (session.isOpen())
        {
            // 关闭连接
            session.close();
        }
        String sessionId = session.getId();
        LOGGER.info("\n 连接异常 - {}", sessionId);
        LOGGER.info("\n 异常信息 - {}", exception);
        // 移出用户
        WebSocketUsers.remove(sessionId);
        // 获取到信号量则需释放
        SemaphoreUtils.release(socketSemaphore);
    }

    /**
     * 服务器接收到客户端消息时调用的方法
     */
    @OnMessage
    public void onMessage(String message, Session session)
    {
        String msg = message.replace("你", "我").replace("吗", "");
        WebSocketUsers.sendMessageToUserByText(session, msg);
    }
}

WebSocketUsers tool class

import java.io.IOException;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import javax.websocket.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * websocket 客户端用户集
 * 
 * @author ruoyi
 */
public class WebSocketUsers
{
    /**
     * WebSocketUsers 日志控制器
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketUsers.class);

    /**
     * 用户集
     */
    private static Map<String, Session> USERS = new ConcurrentHashMap<String, Session>();

    /**
     * 存储用户
     *
     * @param key 唯一键
     * @param session 用户信息
     */
    public static void put(String key, Session session)
    {
        USERS.put(key, session);
    }

    /**
     * 移除用户
     *
     * @param session 用户信息
     *
     * @return 移除结果
     */
    public static boolean remove(Session session)
    {
        String key = null;
        boolean flag = USERS.containsValue(session);
        if (flag)
        {
            Set<Map.Entry<String, Session>> entries = USERS.entrySet();
            for (Map.Entry<String, Session> entry : entries)
            {
                Session value = entry.getValue();
                if (value.equals(session))
                {
                    key = entry.getKey();
                    break;
                }
            }
        }
        else
        {
            return true;
        }
        return remove(key);
    }

    /**
     * 移出用户
     *
     * @param key 键
     */
    public static boolean remove(String key)
    {
        LOGGER.info("\n 正在移出用户 - {}", key);
        Session remove = USERS.remove(key);
        if (remove != null)
        {
            boolean containsValue = USERS.containsValue(remove);
            LOGGER.info("\n 移出结果 - {}", containsValue ? "失败" : "成功");
            return containsValue;
        }
        else
        {
            return true;
        }
    }

    /**
     * 获取在线用户列表
     *
     * @return 返回用户集合
     */
    public static Map<String, Session> getUsers()
    {
        return USERS;
    }

    /**
     * 群发消息文本消息
     *
     * @param message 消息内容
     */
    public static void sendMessageToUsersByText(String message)
    {
        Collection<Session> values = USERS.values();
        for (Session value : values)
        {
            sendMessageToUserByText(value, message);
        }
    }

    /**
     * 发送文本消息
     *
     * @param session 缓存
     * @param message 消息内容
     */
    public static void sendMessageToUserByText(Session session, String message)
    {
        if (session != null)
        {
            try
            {
                session.getBasicRemote().sendText(message);
            }
            catch (IOException e)
            {
                LOGGER.error("\n[发送消息异常]", e);
            }
        }
        else
        {
            LOGGER.info("\n[你已离线]");
        }
    }
}

Html page code

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>测试界面</title>
</head>

<body>

<div>
    <input type="text" style="width: 20%" value="ws://127.0.0.1/websocket/message" id="url">
	<button id="btn_join">连接</button>
	<button id="btn_exit">断开</button>
</div>
<br/>
<textarea id="message" cols="100" rows="9"></textarea> <button id="btn_send">发送消息</button>
<br/>
<br/>
<textarea id="text_content" readonly="readonly" cols="100" rows="9"></textarea>返回内容
<br/>
<br/>
<script th:src="@{/js/jquery.min.js}" ></script>
<script type="text/javascript">
    $(document).ready(function(){
        var ws = null;
        // 连接
        $('#btn_join').click(function() {
        	var url = $("#url").val();
            ws = new WebSocket(url);
            ws.onopen = function(event) {
                $('#text_content').append('已经打开连接!' + '\n');
            }
            ws.onmessage = function(event) {
                $('#text_content').append(event.data + '\n');
            }
            ws.onclose = function(event) {
                $('#text_content').append('已经关闭连接!' + '\n');
            }
        });
        // 发送消息
        $('#btn_send').click(function() {
            var message = $('#message').val();
            if (ws) {
                ws.send(message);
            } else {
                alert("未连接到服务器");
            }
        });
        //断开
        $('#btn_exit').click(function() {
            if (ws) {
                ws.close();
                ws = null;
            }
        });
    })
</script>
</body>
</html>

After successful operation, the page is as follows

Note that there is no user authentication at this time, so the path needs to be released. Because the framework uses SpringSecurity, find the file SecurityConfig.java and allow the path.

3. User authentication issues

Although we have completed the WebSocket communication between the browser (client) and Java (server) according to the above steps, we cannot limit which users can connect to our server to obtain data, and the server does not know which users it should send to. Message, before interacting with our framework, we pass the toke value through the browser to confirm the user's identity, so can our WebSocket do the same?

Unfortunately, ws connection cannot fully define the request header like http, which brings inconvenience to token authentication. We can generally complete user authentication in the following centralized way.

1. Carry  token the plain text in  url , such as ws://localhost:8080/weggo/websocket/message?Authorization=Bearer+token

2. It is implemented through the sub-protocol under websocket, and the Stomp protocol is implemented. The front-end uses the SocketJs framework to implement the corresponding customized request header. Implement the requirement to carry authorization=Bearer +token so that the connection can be established normally

3. Use the sub-protocol array to carry the token in protocols, var ws = new WebSocket(url, ["token"]);

In this way, the backend can read the Sec-WebSocket-Protocol attribute from the server in the onOpen event to obtain the token. For details, please refer to the official documentation of the WebScoket constructor .

var aWebSocket = new WebSocket(url [, protocols]);
url
要连接的URL;这应该是WebSocket服务器将响应的URL。
protocols 可选
一个协议字符串或者一个包含协议字符串的数组。这些字符串用于指定子协议,这样单个服务器可以实现多个WebSocket子协议
(例如,您可能希望一台服务器能够根据指定的协议(protocol)处理不同类型的交互)。如果不指定协议字符串,则假定为空字符串。

The protocols correspond to the Sec-WebSocket-Protocol attribute carried in the request header when initiating a ws connection. The server can obtain the value of this attribute for communication logic (i.e., communication sub-protocol). Of course, it is completely useless for token authentication. problem), the front-end staff carries sec-websocket-protocol=Bearer +token in the request header. The background intercepts the request before it reaches oauth2, and then adds Authorization=Bearer +token (the first letter of the key is capitalized) in the request header, and then in Add sec-websocket-protocol=Bearer +token to the response header (respone) (an error will be reported if not added)

Method 3 partial code example

//前端
var aWebSocket = new WebSocket(url ['用户token']);

//后端
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
     //这里就是我们所提交的token
     String submitedToken=session.getHandshakeHeaders().get("sec-websocket-protocol").get(0);

     //根据token取得登录用户信息(业务逻辑根据你自己的来处理)
}

In addition, if you need to obtain the token before the first handshake, you only need to obtain it in the header.

@Override
public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Map<String, Object> map) throws Exception {
        System.out.println("准备握手");
        String submitedToken = serverHttpRequest.getHeaders().get("sec-websocket-protocol")
        return true;
}

Because my project is about the interaction between the mobile APP and the server, I later chose the simplest implementation solution.

The first thing to solve is to obtain the token information of the URL in the interceptor. The original framework only obtains it from the head, so it needs to be slightly modified.

Find the getToken method in the TokenService.java file and change it to the following, so that you can get the token in the url without affecting the original Http request.

 private String getToken(HttpServletRequest request)
    {
        String token = Optional.ofNullable(request.getHeader(header)).orElse(request.getParameter(header));
        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
        {
            token = token.replace(Constants.TOKEN_PREFIX, "");
        }
        return token;
    }

Next, we need to transform our WebSocket class. In order to facilitate reading, the WebSocketUsers class is removed and the class variable webSocketSet is added to store the client object.

import com.alibaba.fastjson2.JSON;
import com.tongchuang.common.utils.SecurityUtils;
import com.tongchuang.web.mqtt.domain.DeviceInfo;
import io.netty.util.HashedWheelTimer;
import io.netty.util.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;

/**
 * websocket 消息处理
 *
 * @author stronger
 */
@Component
@ServerEndpoint("/websocket/message")
public class WebSocketServer {
    /*========================声明类变量,意在所有实例共享=================================================*/
    /**
     * WebSocketServer 日志控制器
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketServer.class);

    /**
     * 默认最多允许同时在线人数100
     */
    public static int socketMaxOnlineCount = 100;

    private static Semaphore socketSemaphore = new Semaphore(socketMaxOnlineCount);

    HashedWheelTimer timer = new HashedWheelTimer(1, TimeUnit.SECONDS, 8);
    /**
     * concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
     */
    private static final CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<>();
    /**
     * 连接数
     */
    private static final AtomicInteger count = new AtomicInteger();

    /*========================声明实例变量,意在每个实例独享=======================================================*/
    /**
     * 与某个客户端的连接会话,需要通过它来给客户端发送数据
     */
    private Session session;
    /**
     * 用户id
     */
    private String sid = "";

    /**
     * 连接建立成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session) throws Exception {
        // 尝试获取信号量
        boolean semaphoreFlag = SemaphoreUtils.tryAcquire(socketSemaphore);
        if (!semaphoreFlag) {
            // 未获取到信号量
            LOGGER.error("\n 当前在线人数超过限制数- {}", socketMaxOnlineCount);
            // 给当前Session 登录用户发送消息
            sendMessageToUserByText(session, "当前在线人数超过限制数:" + socketMaxOnlineCount);
            session.close();
        } else {
            // 返回此会话的经过身份验证的用户,如果此会话没有经过身份验证的用户,则返回null
            Authentication authentication = (Authentication) session.getUserPrincipal();
            SecurityUtils.setAuthentication(authentication);
            String username = SecurityUtils.getUsername();
            this.session = session;
            //如果存在就先删除一个,防止重复推送消息
            for (WebSocketServer webSocket : webSocketSet) {
                if (webSocket.sid.equals(username)) {
                    webSocketSet.remove(webSocket);
                    count.getAndDecrement();
                }
            }
            count.getAndIncrement();
            webSocketSet.add(this);
            this.sid = username;
            LOGGER.info("\n 当前人数 - {}", count);
            sendMessageToUserByText(session, "连接成功");
        }
    }

    /**
     * 连接关闭时处理
     */
    @OnClose
    public void onClose(Session session) {
        LOGGER.info("\n 关闭连接 - {}", session);
        // 移除用户
        webSocketSet.remove(session);
        // 获取到信号量则需释放
        SemaphoreUtils.release(socketSemaphore);
    }

    /**
     * 抛出异常时处理
     */
    @OnError
    public void onError(Session session, Throwable exception) throws Exception {
        if (session.isOpen()) {
            // 关闭连接
            session.close();
        }
        String sessionId = session.getId();
        LOGGER.info("\n 连接异常 - {}", sessionId);
        LOGGER.info("\n 异常信息 - {}", exception);
        // 移出用户
        webSocketSet.remove(session);
        // 获取到信号量则需释放
        SemaphoreUtils.release(socketSemaphore);
    }

    /**
     * 服务器接收到客户端消息时调用的方法
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        Authentication authentication = (Authentication) session.getUserPrincipal();
        LOGGER.info("收到来自" + sid + "的信息:" + message);
        // 实时更新
        this.refresh(sid, authentication);
        sendMessageToUserByText(session, "我收到了你的新消息哦");
    }

    /**
     * 刷新定时任务,发送信息
     */
    private void refresh(String userId, Authentication authentication) {
        this.start(5000L, task -> {
            // 判断用户是否在线,不在线则不用处理,因为在内部无法关闭该定时任务,所以通过返回值在外部进行判断。
            if (WebSocketServer.isConn(userId)) {
                // 因为这里是长链接,不会和普通网页一样,每次发送http 请求可以走拦截器【doFilterInternal】续约,所以需要手动续约
                SecurityUtils.setAuthentication(authentication);
                // 从数据库或者缓存中获取信息,构建自定义的Bean
                DeviceInfo deviceInfo = DeviceInfo.builder().Macaddress("de5a735951ee").Imei("351517175516665")
                        .Battery("99").Charge("0").Latitude("116.402649").Latitude("39.914859").Altitude("80")
                        .Method(SecurityUtils.getUsername()).build();
                // TODO判断数据是否有更新
                // 发送最新数据给前端
                WebSocketServer.sendInfo("JSON", deviceInfo, userId);
                // 设置返回值,判断是否需要继续执行
                return true;
            }
            return false;
        });
    }

    private void start(long delay, Function<Timeout, Boolean> function) {
        timer.newTimeout(t -> {
            // 获取返回值,判断是否执行
            Boolean result = function.apply(t);
            if (result) {
                timer.newTimeout(t.task(), delay, TimeUnit.MILLISECONDS);
            }
        }, delay, TimeUnit.MILLISECONDS);
    }

    /**
     * 判断是否有链接
     *
     * @return
     */
    public static boolean isConn(String sid) {
        for (WebSocketServer item : webSocketSet) {
            if (item.sid.equals(sid)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 群发自定义消息
     * 或者指定用户发送消息
     */
    public static void sendInfo(String type, Object data, @PathParam("sid") String sid) {
        // 遍历WebSocketServer对象集合,如果符合条件就推送
        for (WebSocketServer item : webSocketSet) {
            try {
                //这里可以设定只推送给这个sid的,为null则全部推送
                if (sid == null) {
                    item.sendMessage(type, data);
                } else if (item.sid.equals(sid)) {
                    item.sendMessage(type, data);
                }
            } catch (IOException ignored) {
            }
        }
    }

    /**
     * 实现服务器主动推送
     */
    private void sendMessage(String type, Object data) throws IOException {
        Map<String, Object> result = new HashMap<>();
        result.put("type", type);
        result.put("data", data);
        this.session.getAsyncRemote().sendText(JSON.toJSONString(result));
    }

    /**
     * 实现服务器主动推送-根据session
     */
    public static void sendMessageToUserByText(Session session, String message) {
        if (session != null) {
            try {
                session.getBasicRemote().sendText(message);
            } catch (IOException e) {
                LOGGER.error("\n[发送消息异常]", e);
            }
        } else {
            LOGGER.info("\n[你已离线]");
        }
    }
}

Guess you like

Origin blog.csdn.net/neusoft2016/article/details/132919507