You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

JWT过期时关闭Spring WebSocket连接的最优实现方案

问题

我正在实现Spring WebSocket端点,希望仅对已认证用户开放,当前应用采用纯JWT认证(无OAuth)。目前通过将初始HTTP请求端点置于Spring Security之后,已实现仅允许认证用户建立WebSocket连接,但存在JWT过期后用户仍保持连接的问题。我曾尝试使用OutBoundChannelInterceptor从WebSocket会话属性中验证JWT,验证通过则发送消息,否则抛出异常,但该拦截器无法捕获消息发送事件,且仅在用户触发操作时才会关闭连接。请问解决此问题的最优方案是什么?

最优解决方案

核心思路是主动检测会话中JWT的有效性,结合定时检查与即时验证,在JWT过期时主动断开连接,同时收紧WebSocket权限控制。具体实现如下:

1. 完善握手拦截器,存储JWT与过期时间

修改WebSocketHandshakeInterceptor,在握手阶段直接验证JWT有效性,并将过期时间存入会话属性,避免后续重复解析:

public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
    private static final String TOKEN_COOKIE_NAME = "authToken";
    private static final String TOKEN_EXPIRY_ATTR = "tokenExpiry";

    @Autowired
    private TokenHandler tokenHandler;

    @Override
    public boolean beforeHandshake(ServerHttpRequest request,
                                   ServerHttpResponse response,
                                   WebSocketHandler wsHandler,
                                   Map<String, Object> attributes) {
        HttpServletRequest httpRequest = ((ServletServerHttpRequest) request).getServletRequest();
        Cookie[] cookies = httpRequest.getCookies();
        String token = getAuthTokenFromCookie(cookies);

        if (token == null) {
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return false;
        }

        try {
            tokenHandler.parseUserFromToken(token);
            Date expiryDate = tokenHandler.getExpiryDateFromToken(token);
            attributes.put(TOKEN_COOKIE_NAME, token);
            attributes.put(TOKEN_EXPIRY_ATTR, expiryDate.getTime());
            return true;
        } catch (Exception e) {
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            return false;
        }
    }

    private String getAuthTokenFromCookie(Cookie[] cookies) {
        if (cookies == null) return null;
        for (Cookie cookie : cookies) {
            if (TOKEN_COOKIE_NAME.equals(cookie.getName())) {
                return cookie.getValue();
            }
        }
        return null;
    }

    @Override
    public void afterHandshake(ServerHttpRequest serverHttpRequest,
                               ServerHttpResponse serverHttpResponse,
                               WebSocketHandler webSocketHandler,
                               Exception e) {}
}

2. 实现会话管理器,定时检测并断开过期连接

创建WebSocket会话管理器,定时遍历活跃会话,检查JWT是否过期,主动断开失效连接:

@Component
public class WebSocketSessionManager {
    private static final String TOKEN_EXPIRY_ATTR = "tokenExpiry";
    private final SimpMessagingTemplate messagingTemplate;
    private final Set<SimpSession> activeSessions = Collections.synchronizedSet(new HashSet<>());

    @Autowired
    public WebSocketSessionManager(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
        // 每30秒检查一次过期会话
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(this::checkExpiredSessions, 0, 30, TimeUnit.SECONDS);
    }

    public void addSession(SimpSession session) {
        activeSessions.add(session);
    }

    public void removeSession(SimpSession session) {
        activeSessions.remove(session);
    }

    private void checkExpiredSessions() {
        long currentTime = System.currentTimeMillis();
        Iterator<SimpSession> iterator = activeSessions.iterator();
        while (iterator.hasNext()) {
            SimpSession session = iterator.next();
            Object expiryAttr = session.getAttributes().get(TOKEN_EXPIRY_ATTR);
            if (expiryAttr != null && currentTime > (long) expiryAttr) {
                messagingTemplate.sendToUser(session.getId(), "/queue/expired", "Token expired");
                session.disconnect();
                iterator.remove();
            }
        }
    }
}

在WebSocketConfig中注册会话事件监听器,维护活跃会话列表:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Autowired
    private WebSocketSessionManager sessionManager;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/api/ws-message")
                .setAllowedOrigins("http://localhost:3000")
                .addInterceptors(new WebSocketHandshakeInterceptor())
                .withSockJS();
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new AuthChannelInterceptor());
    }

    @EventListener
    public void handleSessionConnected(SessionConnectedEvent event) {
        sessionManager.addSession(event.getSession());
    }

    @EventListener
    public void handleSessionDisconnect(SessionDisconnectEvent event) {
        sessionManager.removeSession(event.getSession());
    }
}

3. 优化入站拦截器,即时验证用户操作

修改AuthChannelInterceptor,在用户发送消息或订阅时再次验证JWT,避免定时检测间隔内的无效操作:

@Component
public class AuthChannelInterceptor implements ChannelInterceptor {
    private static final String TOKEN_COOKIE_NAME = "authToken";
    @Autowired
    private TokenHandler tokenHandler;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        if (accessor == null) return message;

        StompCommand command = accessor.getCommand();
        if (StompCommand.SEND.equals(command) || StompCommand.SUBSCRIBE.equals(command)) {
            Map<String, Object> sessionAttributes = SimpMessageHeaderAccessor.getSessionAttributes(message.getHeaders());
            String authToken = (sessionAttributes != null) ? (String) sessionAttributes.get(TOKEN_COOKIE_NAME) : null;

            if (authToken == null) {
                throw new BadCredentialsException("No auth token found in session attributes");
            }
            try {
                tokenHandler.parseUserFromToken(authToken);
            } catch (Exception e) {
                accessor.setLeaveMutable(true);
                accessor.setError("Token expired or invalid");
                accessor.setCommand(StompCommand.DISCONNECT);
                return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders());
            }
        }
        return message;
    }
}

4. 收紧WebSocket安全配置

修改WebSocketSecurityConfig,确保仅认证用户能进行消息发送与订阅:

@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
    @Override
    protected boolean sameOriginDisabled() {
        return true;
    }
    
    @Override
    protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
         messages
             .simpTypeMatchers(SimpMessageType.CONNECT, SimpMessageType.DISCONNECT, SimpMessageType.HEARTBEAT).permitAll()
             .simpDestMatchers("/topic/**", "/app/**").authenticated()
             .anyMessage().denyAll();
    }
}

内容的提问来源于stack exchange,提问作者bojackhorseman99

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.26 21:32:45