| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 |
- package com.zsElectric.boot.config;
- import cn.hutool.core.util.StrUtil;
- import com.zsElectric.boot.security.model.SysUserDetails;
- import com.zsElectric.boot.security.token.TokenManager;
- import com.zsElectric.boot.system.service.WebSocketService;
- import lombok.extern.slf4j.Slf4j;
- import org.jetbrains.annotations.NotNull;
- import org.springframework.context.annotation.Configuration;
- import org.springframework.context.annotation.Lazy;
- import org.springframework.http.HttpHeaders;
- import org.springframework.messaging.Message;
- import org.springframework.messaging.MessageChannel;
- import org.springframework.messaging.MessagingException;
- import org.springframework.messaging.simp.config.ChannelRegistration;
- import org.springframework.messaging.simp.config.MessageBrokerRegistry;
- import org.springframework.messaging.simp.stomp.StompCommand;
- import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
- import org.springframework.messaging.support.ChannelInterceptor;
- import org.springframework.messaging.support.MessageHeaderAccessor;
- import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
- import org.springframework.security.authentication.BadCredentialsException;
- import org.springframework.security.core.Authentication;
- import org.springframework.security.core.AuthenticationException;
- import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
- import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
- import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
- /**
- * WebSocket 配置类
- *
- * 核心功能:
- * - 配置 WebSocket 端点
- * - 配置消息代理
- * - 实现连接认证与授权
- * - 管理用户会话生命周期
- *
- * @author Ray.Hao
- * @since 3.0.0
- */
- @EnableWebSocketMessageBroker
- @Configuration
- @Slf4j
- public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
- private static final String WS_ENDPOINT = "/ws";
- private static final String APP_DESTINATION_PREFIX = "/app";
- private static final String USER_DESTINATION_PREFIX = "/user";
- private static final String[] BROKER_DESTINATIONS = {"/topic", "/queue"};
- private final TokenManager tokenManager;
- private final WebSocketService webSocketService;
- public WebSocketConfig(TokenManager tokenManager, @Lazy WebSocketService webSocketService) {
- this.tokenManager = tokenManager;
- this.webSocketService = webSocketService;
- log.info("✓ WebSocket 配置已加载");
- }
- /**
- * 注册 STOMP 端点
- *
- * 客户端通过该端点建立 WebSocket 连接
- */
- @Override
- public void registerStompEndpoints(StompEndpointRegistry registry) {
- registry
- .addEndpoint(WS_ENDPOINT)
- .setAllowedOriginPatterns("*"); // 允许跨域(生产环境建议配置具体域名)
- log.info("✓ STOMP 端点已注册: {}", WS_ENDPOINT);
- }
- /**
- * 配置消息代理
- *
- * - /app 前缀:客户端发送消息到服务端的前缀
- * - /topic 前缀:用于广播消息
- * - /queue 前缀:用于点对点消息
- * - /user 前缀:服务端发送给特定用户的消息前缀
- */
- @Override
- public void configureMessageBroker(MessageBrokerRegistry registry) {
- // 客户端发送消息的请求前缀
- registry.setApplicationDestinationPrefixes(APP_DESTINATION_PREFIX);
- // 启用简单消息代理,处理 /topic 和 /queue 前缀的消息
- registry.enableSimpleBroker(BROKER_DESTINATIONS);
- // 服务端通知客户端的前缀
- registry.setUserDestinationPrefix(USER_DESTINATION_PREFIX);
- log.info("✓ 消息代理已配置: app={}, broker={}, user={}",
- APP_DESTINATION_PREFIX, BROKER_DESTINATIONS, USER_DESTINATION_PREFIX);
- }
- /**
- * 配置客户端入站通道拦截器
- *
- * 核心功能:
- * 1. 连接建立时:解析 JWT Token 并绑定用户身份
- * 2. 连接关闭时:触发用户下线通知
- * 3. 安全防护:拦截无效连接请求
- */
- @Override
- public void configureClientInboundChannel(ChannelRegistration registration) {
- registration.interceptors(new ChannelInterceptor() {
- @Override
- public Message<?> preSend(@NotNull Message<?> message, @NotNull MessageChannel channel) {
- StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
- // 防御性检查:确保 accessor 不为空
- if (accessor == null) {
- log.warn("⚠ 收到异常消息:无法获取 StompHeaderAccessor");
- return ChannelInterceptor.super.preSend(message, channel);
- }
- StompCommand command = accessor.getCommand();
- if (command == null) {
- return ChannelInterceptor.super.preSend(message, channel);
- }
- try {
- switch (command) {
- case CONNECT:
- handleConnect(accessor);
- break;
- case DISCONNECT:
- handleDisconnect(accessor);
- break;
- case SUBSCRIBE:
- handleSubscribe(accessor);
- break;
- default:
- // 其他命令不需要特殊处理
- break;
- }
- } catch (AuthenticationException ex) {
- // 认证失败时强制关闭连接
- log.error("❌ 连接认证失败: {}", ex.getMessage());
- throw ex;
- } catch (Exception ex) {
- // 捕获其他未知异常
- log.error("❌ WebSocket 消息处理异常", ex);
- throw new MessagingException("消息处理失败: " + ex.getMessage());
- }
- return ChannelInterceptor.super.preSend(message, channel);
- }
- });
- log.info("✓ 客户端入站通道拦截器已配置");
- }
- /**
- * 处理客户端连接请求
- *
- * 安全校验流程:
- * 1. 提取 Authorization 头
- * 2. 验证 Bearer Token 格式
- * 3. 解析并验证 JWT 有效性
- * 4. 绑定用户身份到当前会话
- * 5. 记录用户上线状态
- */
- private void handleConnect(StompHeaderAccessor accessor) {
- String authorization = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION);
- // 安全检查:确保 Authorization 头存在且格式正确
- if (StrUtil.isBlank(authorization)) {
- log.warn("⚠ 非法连接请求:缺少 Authorization 头");
- throw new AuthenticationCredentialsNotFoundException("缺少 Authorization 头");
- }
- if (!authorization.startsWith("Bearer ")) {
- log.warn("⚠ 非法连接请求:Authorization 头格式错误");
- throw new BadCredentialsException("Authorization 头格式错误");
- }
- // 提取 JWT Token(移除 "Bearer " 前缀)
- String token = authorization.substring(7);
- if (StrUtil.isBlank(token)) {
- log.warn("⚠ 非法连接请求:Token 为空");
- throw new BadCredentialsException("Token 为空");
- }
- // 解析并验证 Token
- Authentication authentication;
- try {
- authentication = tokenManager.parseToken(token);
- } catch (Exception ex) {
- log.error("❌ Token 解析失败", ex);
- throw new BadCredentialsException("Token 无效: " + ex.getMessage());
- }
- // 验证解析结果
- if (authentication == null || !authentication.isAuthenticated()) {
- log.warn("⚠ Token 解析失败:认证对象无效");
- throw new BadCredentialsException("Token 解析失败");
- }
- // 获取用户详细信息
- Object principal = authentication.getPrincipal();
- if (!(principal instanceof SysUserDetails)) {
- log.error("❌ 无效的用户凭证类型: {}", principal.getClass().getName());
- throw new BadCredentialsException("用户凭证类型错误");
- }
- SysUserDetails userDetails = (SysUserDetails) principal;
- String username = userDetails.getUsername();
- if (StrUtil.isBlank(username)) {
- log.warn("⚠ 用户名为空");
- throw new BadCredentialsException("用户名为空");
- }
- // 绑定用户身份到当前会话(重要:用于 @SendToUser 等注解)
- accessor.setUser(authentication);
- // 获取会话 ID
- String sessionId = accessor.getSessionId();
- if (sessionId == null) {
- log.warn("⚠ 会话 ID 为空,使用临时 ID");
- sessionId = "temp-" + System.nanoTime();
- }
- // 记录用户上线状态
- try {
- webSocketService.userConnected(username, sessionId);
- log.info("✓ WebSocket 连接建立成功: 用户[{}], 会话[{}]", username, sessionId);
- } catch (Exception ex) {
- log.error("❌ 记录用户上线状态失败: 用户[{}], 会话[{}]", username, sessionId, ex);
- // 不抛出异常,允许连接继续
- }
- }
- /**
- * 处理客户端断开连接事件
- *
- * 注意:
- * - 只有成功建立过认证的连接才会触发下线事件
- * - 防止未认证成功的连接产生脏数据
- */
- private void handleDisconnect(StompHeaderAccessor accessor) {
- Authentication authentication = (Authentication) accessor.getUser();
- // 防御性检查:只处理已认证的连接
- if (authentication == null || !authentication.isAuthenticated()) {
- log.debug("未认证的连接断开,跳过处理");
- return;
- }
- Object principal = authentication.getPrincipal();
- if (!(principal instanceof SysUserDetails)) {
- log.warn("⚠ 断开连接时用户凭证类型异常");
- return;
- }
- SysUserDetails userDetails = (SysUserDetails) principal;
- String username = userDetails.getUsername();
- if (StrUtil.isNotBlank(username)) {
- try {
- webSocketService.userDisconnected(username);
- log.info("✓ WebSocket 连接断开: 用户[{}]", username);
- } catch (Exception ex) {
- log.error("❌ 记录用户下线状态失败: 用户[{}]", username, ex);
- }
- }
- }
- /**
- * 处理客户端订阅事件(可选)
- *
- * 用于记录订阅信息或实施订阅级别的权限控制
- */
- private void handleSubscribe(StompHeaderAccessor accessor) {
- Authentication authentication = (Authentication) accessor.getUser();
- if (authentication != null && authentication.isAuthenticated()) {
- String destination = accessor.getDestination();
- String username = authentication.getName();
- log.debug("用户[{}]订阅主题: {}", username, destination);
- // TODO: 这里可以实现订阅级别的权限控制
- // 例如:检查用户是否有权限订阅某个主题
- }
- }
- }
|