分布式微服务系统架构第120集:专业WebSocket鉴权

加群联系作者vx:xiaoda0423

仓库地址:webvueblog.github.io/JavaPlusDoc...

1024bat.cn/

能力 说明
支持 Header 传 token 不光从 URL,也能从 WebSocket 请求头拿 token
握手失败自定义返回码 拒绝连接时能带上明确的 HTTP 错误码,比如 401 未授权
支持主动踢人下线 比如token过期、恶意连接,可以主动断开
统一异常处理 保证连接失败时有标准返回,不是莫名其妙报错

🛠️ 进阶版完整代码


(1)改造 BaseEndpointConfigure --- 高级版

typescript 复制代码
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
import javax.servlet.http.HttpSession;
import java.util.List;
import java.util.Map;

public class BaseEndpointConfigure extends ServerEndpointConfig.Configurator {

    @Override
    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
        String token = extractToken(request);

        if (!validateToken(token)) {
            // 拒绝握手,抛一个特殊异常
            throw new WebSocketAuthenticationException("Token无效,拒绝连接");
        }

        // 保存到用户属性,后续可以从Session里拿
        sec.getUserProperties().put("token", token);
    }

    private String extractToken(HandshakeRequest request) {
        // 1. 优先从 Header 中取
        Map<String, List<String>> headers = request.getHeaders();
        List<String> tokenList = headers.get("Sec-WebSocket-Protocol");
        if (tokenList != null && !tokenList.isEmpty()) {
            return tokenList.get(0);
        }

        // 2. 如果Header没有,从 URL query 参数取
        String query = request.getQueryString();
        if (query != null && query.startsWith("token=")) {
            return query.substring(6);
        }
        return null;
    }

    private boolean validateToken(String token) {
        // TODO: 这里可以接你的认证中心、JWT解析、Redis等逻辑
        return token != null && token.length() > 5;
    }
}

(2)定义一个「握手鉴权异常」WebSocketAuthenticationException

scala 复制代码
public class WebSocketAuthenticationException extends RuntimeException {
    public WebSocketAuthenticationException(String message) {
        super(message);
    }
}

(3)在 WebSocketConfig添加统一异常处理

serverEndpointExporter() 方法外包装一层 try-catch。

typescript 复制代码
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

    @Bean
    public BaseEndpointConfigure newConfigure() {
        return new BaseEndpointConfigure();
    }
}

(4)改造 @ServerEndpoint --- 带token、踢人

typescript 复制代码
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;

@ServerEndpoint(value = "/ws/echo", configurator = BaseEndpointConfigure.class)
public class EchoWebSocket {

    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        String token = (String) config.getUserProperties().get("token");
        System.out.println("WebSocket连接建立,token=" + token);

        // 你可以在这里根据业务逻辑判断
        // 比如token过期了,踢掉
        if ("expiredToken".equals(token)) {
            try {
                session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "Token已过期,断开连接"));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        try {
            session.getBasicRemote().sendText("服务端返回:" + message);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @OnClose
    public void onClose(Session session) {
        System.out.println("WebSocket连接关闭");
    }

    @OnError
    public void onError(Session session, Throwable error) {
        System.err.println("WebSocket发生错误:" + error.getMessage());
    }
}

🧠 核心变化总结

项目 变化点
token提取 支持 Header 优先,URL 兜底
校验逻辑 灵活扩展,可以接认证中心、数据库
握手失败 自定义异常,友好提示
连接后控制 支持业务主动踢人
统一处理 保证握手和运行阶段都能优雅处理异常

📈 提升体验Tips(可选项)

  • 如果想让客户端更友好收到拒绝原因,可以自定义 WebSocket握手拦截器 → 返回HTTP状态码,比如401
  • 可以加上心跳检测机制,比如客户端定时发送ping消息,服务端回复pong
  • 可以加SessionManager,统一管理所有在线Session(方便批量踢人/广播消息)
  • 连接时支持 token
  • 握手失败给明确的拒绝理由
  • 连接成功后可以踢人
  • 全程异常不留死角
  • 可无缝对接后端认证体系

🎯 总体目标

  • 每个登录用户,只能有一个有效WebSocket连接
  • 新连接建立时,踢掉老连接(比如账号在别的地方登录)
  • 有一个统一Session管理器 ,支持:
    • 根据 userId 查找Session
    • 踢人(主动断开连接)
    • 广播消息(选配)
  • 接入现有的 token鉴权流程,自然兼容!

🏗️ 正式搭建


(1)新增统一管理类:WebSocketSessionManager

typescript 复制代码
import javax.websocket.Session;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

public class WebSocketSessionManager {

    // userId -> Session
    private static final ConcurrentHashMap<String, Session> userSessionMap = new ConcurrentHashMap<>();

    /**
     * 添加新的连接
     */
    public static void addSession(String userId, Session session) {
        // 如果有旧连接,先踢掉
        Session oldSession = userSessionMap.put(userId, session);
        if (oldSession != null && oldSession.isOpen()) {
            try {
                oldSession.close(); // 踢掉老连接
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 移除连接
     */
    public static void removeSession(String userId) {
        userSessionMap.remove(userId);
    }

    /**
     * 通过userId获取Session
     */
    public static Session getSession(String userId) {
        return userSessionMap.get(userId);
    }

    /**
     * 主动发送消息
     */
    public static void sendMessage(String userId, String message) {
        Session session = getSession(userId);
        if (session != null && session.isOpen()) {
            try {
                session.getBasicRemote().sendText(message);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 踢掉某个用户
     */
    public static void kickOutUser(String userId, String reason) {
        Session session = getSession(userId);
        if (session != null && session.isOpen()) {
            try {
                session.close(new javax.websocket.CloseReason(
                        javax.websocket.CloseReason.CloseCodes.NORMAL_CLOSURE, reason
                ));
            } catch (IOException e) {
                e.printStackTrace();
            }
            removeSession(userId);
        }
    }
}

(2)在 @ServerEndpoint 里接入管理器

比如在 EchoWebSocket 里用:

typescript 复制代码
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;

@ServerEndpoint(value = "/ws/echo", configurator = BaseEndpointConfigure.class)
public class EchoWebSocket {

    private String userId;

    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        String token = (String) config.getUserProperties().get("token");
        
        // 假设这里解析出来userId(一般token里面带的)
        this.userId = parseUserIdFromToken(token);

        System.out.println("WebSocket连接建立,userId=" + userId);

        // 加入Session管理器
        WebSocketSessionManager.addSession(userId, session);
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        try {
            session.getBasicRemote().sendText("服务端返回:" + message);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @OnClose
    public void onClose(Session session) {
        System.out.println("WebSocket连接关闭,userId=" + userId);
        WebSocketSessionManager.removeSession(userId);
    }

    @OnError
    public void onError(Session session, Throwable error) {
        System.err.println("WebSocket发生错误:" + error.getMessage());
    }

    private String parseUserIdFromToken(String token) {
        // TODO: 实际可以解析JWT或者查Redis拿到userId
        return token; // 这里简单演示,假设token本身就是userId
    }
}

✅ 这样每次有新连接:

  • 自动把老的连接踢掉
  • 新的连接注册进管理器
  • 关闭时/出错时也自动清理Session

(3)其他地方想踢人怎么办?

比如后台管理系统发现用户异常,需要踢人:

arduino 复制代码
WebSocketSessionManager.kickOutUser("userId123", "你的账号在别处登录,已被强制下线");

一行代码,干掉!

📈 整体流程图

rust 复制代码
客户端连接 -> 解析token -> 解析userId -> WebSocketSessionManager管理session
                 ↓
        同userId已有session?
                ↓是
        踢掉老session,注册新session
  • userSessionMap 也可以换成 RedissonRMap,支持分布式节点同步踢人
  • 可以在服务端定时心跳检测,把死掉的连接清理掉
  • 可以扩展消息总线,做到一条命令踢掉多个服务节点的连接
能力 说明
在线用户统一管理
支持单点登录强制踢掉旧连接
异常情况自动清理Session
跨服务节点可以扩展Redisson同步

📦 WebSocket心跳机制 + 服务端检测离线清理 + 自动重连 + 分布式节点同步踢人

支持单机 & 多节点分布式同步

🛠️ 1. 整体设计目标

功能模块 说明
心跳机制 客户端定时发心跳(比如5秒),服务器更新最后活跃时间
离线清理 服务器定时扫描,超时未心跳的连接强制踢掉
自动重连 客户端断开后自动发起重连
分布式同步踢人 多节点部署,用 Redis 发布/订阅消息,所有节点同步踢人

🏗️ 2. 服务端改造步骤


(1)WebSocketSessionManager 加强版

不仅保存 Session,还记录最后活跃时间戳

java 复制代码
import javax.websocket.Session;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

public class WebSocketSessionManager {

    static class SessionInfo {
        Session session;
        volatile long lastActiveTime;

        SessionInfo(Session session) {
            this.session = session;
            this.lastActiveTime = System.currentTimeMillis();
        }
    }

    // userId -> SessionInfo
    private static final ConcurrentHashMap<String, SessionInfo> userSessionMap = new ConcurrentHashMap<>();

    public static void addSession(String userId, Session session) {
        SessionInfo oldInfo = userSessionMap.put(userId, new SessionInfo(session));
        if (oldInfo != null && oldInfo.session.isOpen()) {
            try {
                oldInfo.session.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public static void removeSession(String userId) {
        userSessionMap.remove(userId);
    }

    public static Session getSession(String userId) {
        SessionInfo info = userSessionMap.get(userId);
        return info != null ? info.session : null;
    }

    public static void updateActiveTime(String userId) {
        SessionInfo info = userSessionMap.get(userId);
        if (info != null) {
            info.lastActiveTime = System.currentTimeMillis();
        }
    }

    public static void kickOutUser(String userId, String reason) {
        Session session = getSession(userId);
        if (session != null && session.isOpen()) {
            try {
                session.close(new javax.websocket.CloseReason(
                        javax.websocket.CloseReason.CloseCodes.NORMAL_CLOSURE, reason
                ));
            } catch (IOException e) {
                e.printStackTrace();
            }
            removeSession(userId);
        }
    }

    // 定时扫描超时的Session
    public static void clearInactiveSessions(long timeoutMillis) {
        long now = System.currentTimeMillis();
        userSessionMap.forEach((userId, info) -> {
            if (now - info.lastActiveTime > timeoutMillis) {
                System.out.println("清理超时连接 userId=" + userId);
                kickOutUser(userId, "连接超时无心跳,已被踢出");
            }
        });
    }
}

(2)WebSocket接口调整(EchoWebSocket示例)

接收心跳消息,刷新活跃时间!

typescript 复制代码
@ServerEndpoint(value = "/ws/echo", configurator = BaseEndpointConfigure.class)
public class EchoWebSocket {

    private String userId;

    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        String token = (String) config.getUserProperties().get("token");
        this.userId = parseUserIdFromToken(token);

        WebSocketSessionManager.addSession(userId, session);
        System.out.println("WebSocket连接建立:" + userId);
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        if ("heartbeat".equals(message)) {
            WebSocketSessionManager.updateActiveTime(userId);
            return;
        }

        try {
            session.getBasicRemote().sendText("服务端回应:" + message);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @OnClose
    public void onClose(Session session) {
        WebSocketSessionManager.removeSession(userId);
        System.out.println("WebSocket连接关闭:" + userId);
    }

    @OnError
    public void onError(Session session, Throwable error) {
        System.err.println("WebSocket错误:" + error.getMessage());
    }

    private String parseUserIdFromToken(String token) {
        return token;
    }
}

(3)加一个定时任务清理死连接

可以用 Spring Task 简单搞定:

java 复制代码
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class WebSocketHeartbeatChecker {

    // 每30秒检查一次
    @Scheduled(fixedDelay = 30000)
    public void clearInactiveSessions() {
        long timeout = 60_000; // 1分钟无心跳,认为超时
        WebSocketSessionManager.clearInactiveSessions(timeout);
    }
}

(4)分布式节点同步踢人(Redisson Pub/Sub)

新增监听Redis消息,同步踢人:

发布踢人:

typescript 复制代码
@Autowired
private RedissonClient redissonClient;

public void kickUserGlobally(String userId) {
    // 发送广播消息到 Redis
    redissonClient.getTopic("websocket:kickout").publish(userId);
    // 本地也踢一次(防止延迟)
    WebSocketSessionManager.kickOutUser(userId, "系统通知:账号被踢下线");
}

监听 Redis 消息:

kotlin 复制代码
import org.redisson.api.listener.MessageListener;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

@Component
public class KickoutSubscriber {

    @Resource
    private RedissonClient redissonClient;

    @PostConstruct
    public void subscribeKickout() {
        redissonClient.getTopic("websocket:kickout").addListener(String.class, (channel, userId) -> {
            System.out.println("收到踢人消息,踢掉 userId=" + userId);
            WebSocketSessionManager.kickOutUser(userId, "分布式踢人同步");
        });
    }
}

🏗️ 整体目标

功能模块 说明
单节点推送 给指定用户推送消息(单发)
广播推送 给所有在线用户推送消息(群发)
分布式同步 多节点部署时,广播推送同步到所有节点(用 Redis 发布订阅)
高可用容错 断网/重连/节点故障下自动修复连接

这是一个 Spring Boot 中专门用于支持 WebSocket 的配置类。

它主要做两件事:

主要内容 说明
注册 WebSocket 端点 通过 ServerEndpointExporter 自动扫描并注册使用了 @ServerEndpoint 注解的类
自定义配置注入 通过 BaseEndpointConfigure 提供 WebSocket 握手过程中的扩展(比如自定义鉴权、拦截器、参数绑定)

1. ServerEndpointExporter 作用

java 复制代码
@Bean
public ServerEndpointExporter serverEndpointExporter() 
  • 开启 WebSocket 支持
    ServerEndpointExporter 是 Spring Boot + WebSocket 集成中必需的。
    它会去扫描应用中所有用 @ServerEndpoint 注解标记的类,并把它们注册为 WebSocket 的服务端点(Endpoint)。
  • 没有它会怎样?
    ➔ WebSocket 服务启动不起来,@ServerEndpoint 不生效,客户端也连接不上。

✅ 加了它之后,像这种 @ServerEndpoint 标注的类才能工作:

kotlin 复制代码
@ServerEndpoint("/ws/notify")
public class NotificationWebSocket {
    ...
}

2. BaseEndpointConfigure 作用

java 复制代码
@Bean
public BaseEndpointConfigure newConfigure()
  • 这是你自定义的一个扩展点 (比如继承 ServerEndpointConfig.Configurator)。

  • 可以在这里 控制 WebSocket 建立连接前后的逻辑,比如:

    • 自定义 session 属性
    • 握手时进行用户鉴权(比如通过 token 获取 userId)
    • 注入 Spring Bean 进 WebSocket 类中(解决 @ServerEndpoint 里不能用 @Autowired 问题)

🔔 通常 BaseEndpointConfigure 里会 override 这些方法

java 复制代码
@Override
public <T> T getEndpointInstance(Class<T> endpointClass) throws InstantiationException {
    return ApplicationContextHolder.getBean(endpointClass); // 让 WebSocket 支持注入 Spring Bean
}

@Override
public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) {
    // 在握手阶段,提取 Token 鉴权,传递用户信息到 WebSocket
}

🎯 什么时候需要写这个 WebSocketConfig?

场景 是否需要
Spring Boot 原生 WebSocket 项目 ✅ 必须要有
项目中有 @ServerEndpoint 注解的 WebSocket类 ✅ 必须要有
需要自定义握手逻辑,比如认证、授权 ✅ 必须要有
使用第三方容器,比如外置Tomcat ❌ 可以不用(但建议也加上 BaseEndpointConfigure)

这段 WebSocketConfig 就是:

让你的 WebSocket 服务端点能在 Spring Boot 项目中自动生效,并且支持自定义鉴权和依赖注入。

相关推荐
Java中文社群9 分钟前
聊聊SpringAI流式输出的底层实现?
java·人工智能·后端
雷渊10 分钟前
如何设计一个订单号生成服务?
后端
心走12 分钟前
八股文中TCP三次握手怎么具象理解?
前端·面试
雷渊15 分钟前
设计秒杀系统需要考虑哪些因素?
后端
无妄无望23 分钟前
Git,本地上传项目到github
git·github
小华同学ai24 分钟前
90.9K star!Open WebUI一键部署AI聊天界面,这个开源项目让大模型交互更简单!
github
super凹凸曼25 分钟前
分享一个把你的API快速升级为MCP规范的方案,可在线体验
java·后端·开源
离线请留言26 分钟前
本地密码管理器-Vaultwarden
后端
IT杨秀才28 分钟前
LangChain框架入门系列(3):数据连接
人工智能·后端·langchain
IT杨秀才28 分钟前
LangChain框架入门系列(2):Hello LangChain
人工智能·后端·langchain