SpringWebsocket 整合认证实战

1,490 阅读8分钟

SpringWebsocket 提供了支持 Websocket 协议的模块,允许客户端和服务端进行实时操作。

它支持 Stomp 支持和 SockJS。

SpringWebSocket 配置

依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!--与 Security 结合授权使用-->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-messaging</artifactId>
</dependency>

首先我们要取消掉SpringSecurity 拦截websocket

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    // 禁用CSRF和Session管理,可以根据项目需要启用或禁用
    httpSecurity
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(AbstractHttpConfigurer::disable)
            .headers().frameOptions().disable()
            .and()
            // 配置请求授权规则--- /websocket/** 允许所有用户访问
            .authorizeHttpRequests(author ->
                    author.antMatchers("/admin/code", "/admin/login", "/websocket/**").permitAll()

配置 WebSocket

@EnableWebSocketMessageBroker 首先启用 Websocket 消息代理,允许处理Websocket 消息。

我们再实现 WebSocketMessageBrokerConfigurer,用于配置消息代理的各个方面。

消息代理的目标、应用程序的前缀、跨域、认证等。

@Configuration
@EnableWebSocketMessageBroker
// @Order(Ordered.HIGHEST_PRECEDENCE + 999)
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

}

配置消息代理

通过重写 configureMessageBroker 进行配置消息代理。

通过 MessageBrokerRegistry 的 enableSimpleBroker 方法配置 /topic 的代理,表示客户端可以订阅 以/topic为前缀的目标,用于接收信息。

通过 MessageBrokerRegistry的setApplicationDestinationPrefixes方法设置应用程序的前缀,客户端发送信息将以/app 为前缀,才能被服务端进行处理。

通过 setUserDestinationPrefix 方法设置点对点消息地址

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
    //设置客户端接收点对点消息地址的前缀,默认为 /user
    config.setUserDestinationPrefix("/user");
    config.enableSimpleBroker("/topic");
    config.setApplicationDestinationPrefixes("/app");
}

配置心跳

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    // 创建线程池任务调度器
    ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
    // 设置线程池大小
    taskScheduler.setPoolSize(3);
    // 设置线程名称前缀
    taskScheduler.setThreadNamePrefix("wss-heartbeat-thread-");
    // 初始化任务调度器
    taskScheduler.initialize();

    // 配置心跳
    registry.enableSimpleBroker("/topic")
            // 设置心跳间隔(心跳发送和接收的间隔时间)
            .setHeartbeatValue(new long[]{10000, 10000})
            // 使用上面创建的任务调度器来处理心跳任务
            .setTaskScheduler(taskScheduler);

}

前端心跳需要和后端一致

// 创建 Stomp 客户端对象,连接到指定的 WebSocket 服务器
const client = Stomp.client('ws://localhost:4646/websocket');

// 配置心跳参数
client.heartbeat.outgoing = 10000; // 发送心跳间隔(毫秒)
client.heartbeat.incoming = 10000; // 接收心跳间隔(毫秒)

// 配置重新连接延迟(毫秒)
client.reconnectDelay = 5000;

注册 STOMP

通过重写 registerStompEndpoints 方法,进行注册端点,一种简单的消息协议。

通过 StompEndpointRegistry 的 addEndpoint方法,进行注册名为 websocket 端点。

通过 setAllowedOriginPatterns 设置允许跨域。* 表示允许所有。

通过 withSockJS 设置启动 SockJS 的支持,提供了对WebSocket API的抽象,使得在WebSocket不可用时,客户端仍然能够使用其他传输方式与服务器进行通信。

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/websocket")
            .setAllowedOriginPatterns("*")
            .withSockJS();
}

配置认证

我们创建 WebSocketAuthorizationSecurityConfig 类继承 AbstractSecurityWebSocketMessageBrokerConfigurer 它具有 WebSocket安全配置的基本功能。

通过重写 configureInbound 方法,表示所有的 websocket 请求,都需要进行入站,通过重写 sameOriginDisabled 返回true表示禁用同源策略。

通过 设置 messages.anyMessage().authenticated();,表示所有的请求都需要认证

@Configuration
public class WebSocketAuthorizationSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
    @Override
    protected void configureInbound(final MessageSecurityMetadataSourceRegistry messages) {
        // 需要认证
        messages.anyMessage().authenticated();
    }

    // 这里请自己按需求修改
    @Override
    protected boolean sameOriginDisabled() {
        return true;
    }
}

通过重写 configureClientInboundChannel 方法,表示入站拦截器,其中我们自定义一个拦截器,从中取出 Token 获取用户,并设置身份信息。

其中我们重写拦截器的 preSend 方法,表示在发送请求之前拦截。

有两个参数,Message<?> 参数表示 WebSocket 消息,MessageChannel 参数表示消息发送的通道。

StompHeaderAccessor 类 用于访问STOMP消息的头部信息,比如命令(CONNECT、SEND、SUBSCRIBE等)和头部信息。

我们就从这个类中获取我们存入的 Token 令牌。

我们检查是否是连接信息,连接信息包含 accessor.getCommand() 的数据,如果为null就不是连接,直接放行即可。

如果是,进行认证, 通过 getFirstNativeHeader 方法获取我们指定的 Token 名,一般为 Authorization

最后重点是,我们创建 UsernamePasswordAuthenticationToken 将用户信息放进去,这样可以在 Security 中获取当前用户。 accessor.setUser(user) 一定要把 User 对象存入啊。后期直接通过 Security 中的作用域获取,最后返回 Message 就好啦。

不要忘记把创建的拦截器加入哦, registration.interceptors(interceptor);

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
    ChannelInterceptor interceptor = new ChannelInterceptor() {
        @Override
        public Message<?> preSend(Message<?> message, MessageChannel channel) {
            // 获取消息头访问器,用于处理STOMP消息的头信息
            StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

            // 检查访问器和命令是否为空
            if (accessor == null || accessor.getCommand() == null) {
                return null; // 返回空表示继续处理下一个拦截器
            }

            // 处理CONNECT命令,用于用户认证
            if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                // 从消息头中获取Authorization头(包含用户的身份认证token)
                String token = accessor.getFirstNativeHeader("Authorization");
                
                // 如果token为空,表示没有身份认证信息,返回空继续处理下一个拦截器
                if (token == null) {
                    return null;
                }
                
                // 剥离token前缀,获取原始token
                token = token.replace(Constants.TOKEN_PREFIX, "");
                
                // 通过token服务获取LoginUser对象,用于创建用户Principal
                LoginUser loginUser = tokenService.getLoginUser(token);

                // 设置认证类
                final UsernamePasswordAuthenticationToken user = new UsernamePasswordAuthenticationToken(
                            loginUser,
                            null,
                            loginUser.getAuthorities()
                    );
                
                // 将用户Principal设置到访问器中,以便后续处理使用
                accessor.setUser(user);
            }

            // 返回处理后的消息,继续后续处理
            return message;
        }
    };

    // 将拦截器注册到WebSocket消息处理的Channel
    registration.interceptors(interceptor);
}

后期在控制器获取用户对象的方法。

@MessageMapping 和 requestMapping 一样,放在类上,表示一级路经,放在方法上,表示二级路径。

用于表示接收时的路径。

@SendTo 是返回到当前Message路径,是所有订阅用户 @SendToUser 是返回到当前Message路径,是当前用户,或者对方指定向自己发。

simpMessagingTemplate 是SpringWebsocket 提供的,用于主动推送信息给客户端。

simpMessagingTemplate.convertAndSend 表示向所有在线用户发送信息,参数1是订阅地址,参数2是数据。

@RestController
@MessageMapping("/cover/channel")
public class ChannelSocketController extends BaseController {

    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;

    @MessageMapping("/deptChannel")
    @SendTo("/topic/deptChannel")
    public AjaxResult getDeptByIdSocket(ManholeCover manholeCover) {
        simpMessagingTemplate.convertAndSend("/topic/deptChannel", success);
        return success;
    }
}

前端 stomp 连接

创建 Stomp 客户端,设置ws地址,并链接,订阅user消息

安装

@stomp/stompjs
pnpm i sockjs-client
import SockJS from 'sockjs-client/dist/sockjs.min.js'
const sockJs = new SockJS('http://localhost:4646/websocket');
const client = Stomp.over(sockJs)
client.connect({"Authorization": 'Bearer ' + getToken()}, () => {

    // 加载地图信息,是后端的MessageMapping路径
    client.send('/app/cover/channel/deptChannel', {"Authorization": 'Bearer ' + getToken()}, JSON.stringify({}));

    // 订阅消息,是后端的 @SendTo 路径,如果前缀加 user表示接收自己的,或者对方指定向自己发。
    client.subscribe('/topic/deptChannel', (message) => {
        console.log('Received222222:', message.body);
    })
})

请 CV

前端 Stompjs 连接

import { Stomp } from "@stomp/stompjs" // 引入 Stomp.js
import { getToken } from "@/utils/auth.js" // 导入获取 token 的函数

// 定义仓库和客户端对象
let client = null

// 连接客户端函数
const connectClient = () => {
    // 创建 Stomp 客户端对象,连接到指定的 WebSocket 服务器
    client = Stomp.client('ws://localhost:4646/websocket')

    // 配置心跳参数
    client.heartbeat.outgoing = 10000 // 发送心跳间隔(毫秒)
    client.heartbeat.incoming = 10000 // 接收心跳间隔(毫秒)
    client.reconnectDelay = 5000 // 设置重新连接延迟(毫秒)

    // 设置调试函数,用于捕获连接关闭事件
    client.debug = (message) => {
        // 开启日志打印
        console.log(message)
        // 如果消息以 'Connection closed' 开头,则表示连接已关闭
        if (message.startsWith('Connection closed')) {
            // 打印日志并执行相应逻辑
            console.log('连接已关闭')
        }
    }

    // 连接客户端到服务器,携带 token
    client.connect({ Authorization: 'Bearer ' + getToken() }, () => {
        // 订阅数据频道
        client.subscribe('/topic/deptChannel', (message) => {
            
        })
    })
    // 打印连接成功日志
    console.log('连接成功')
}

// 导出获取客户端对象的函数
export const getClient = () => client;

// 导出连接客户端函数
export const ConnectClient = () => connectClient();

配置安全认证

/**
 * WebSocket权限配置类,用于配置WebSocket消息的安全性。
 */
@Configuration
public class WebSocketAuthorizationSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    /**
     * 配置入站消息的安全性,使所有入站消息都需要经过身份验证。
     */
    @Override
    protected void configureInbound(final MessageSecurityMetadataSourceRegistry messages) {
        // 添加自己的映射,要求所有入站消息都需要经过身份验证
        messages.anyMessage().authenticated();
    }

    /**
     * 配置是否禁用同源策略。根据实际需求返回是否禁用同源策略。
     */
    @Override
    protected boolean sameOriginDisabled() {
        // 这里请自己按需求修改,返回是否禁用同源策略
        return true;
    }
}

后端配置 stompjs 协议

import static com.common.socket.StompUtils.ONLINE_USER;

/**
 * WebSocket 配置类
 */
@Configuration
@EnableWebSocketMessageBroker
// 指定优先级,保证在Security配置之前生效
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Autowired
    private WebTokenService webTokenService;

    /**
     * 注册 Stomp 端点
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/websocket")
                // 设置允许跨域的域名
                .setAllowedOriginPatterns("*")
//                .withSockJS() // 如果需要使用 SockJS,可以添加该方法
        ;
    }

    /**
     * 配置消息代理
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 设置客户端接收点对点消息地址的前缀,默认为 /user
        registry.setUserDestinationPrefix("/user");
        registry.setApplicationDestinationPrefixes("/app"); // 定义应用程序的前缀,客户端发送消息的目标前缀

        // 设置定时器
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(3);
        taskScheduler.setThreadNamePrefix("wss-heartbeat-thread-");
        taskScheduler.initialize();

        // 配置心跳
        registry.enableSimpleBroker("/topic")
                .setHeartbeatValue(new long[]{10000, 10000}) // 设置心跳间隔
                .setTaskScheduler(taskScheduler); // 使用指定的任务调度器处理心跳任务
    }

    /**
     * 配置客户端入站通道
     */
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        // 设置通道拦截器
        ChannelInterceptor interceptor = new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                // 获取 Stomp 头部访问器
                StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                if (accessor == null || accessor.getCommand() == null) return null;
                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    // 处理连接请求
                    String token = accessor.getFirstNativeHeader("Authorization");
                    if (token == null) return null;
                    // 解析 token,获取登录用户信息
                    token = token.replace(Constants.TOKEN_PREFIX, "");
                    LoginUser loginUser = webTokenService.getLoginUser(token);

                    // 将登录用户添加到在线用户列表
                    ONLINE_USER.put(loginUser.getUsername(), loginUser);
                    
                    // 配置 Security 认证
                    final UsernamePasswordAuthenticationToken user = new UsernamePasswordAuthenticationToken(
                            loginUser,
                            null,
                            loginUser.getAuthorities()
                    );
                    // 设置 Stomp 消息头部的用户信息
                    accessor.setUser(user);
                }
                return message;
            }
        };
        registration.interceptors(interceptor); // 添加拦截器
    }
}

后端工具类,获取在线用户

public class StompUtils {

    /*在线用户*/
    public final static HashMap<String, LoginUser> ONLINE_USER = new HashMap<>();

    /*根据用户Id,获取在线用户名*/
    public static String getUserName(Long userId) {
        return ONLINE_USER.entrySet().stream().filter(entry -> entry.getValue().getUserId().equals(userId))
                .map(Map.Entry::getKey).findFirst().orElse(null);
    }

}