使用框架介绍

  • spring boot 1.4.3.RELEASE

  • spring websocket 4.3.5.RELEASE

  • spring security 4.1.3.RELEASE

  • sockjs-client 1.0.2

  • stompjs 2.3.3

项目介绍

由于公司需要使用websocket主动给前端用户推送消息,公司的项目是使用jhipster自动生成的微服务项目,而spring boot本身就集成了websocket,这样我们不用自己处理所有的网络细节代码。我们的项目主要为:
前端 - nodeJS代理 - 后端 - 计算系统(由于我们公司是做云计算的,计算系统是一个底层系统)
项目的主要流程是:

遇到的问题

由于我们使用的是spring security oauth2 来进行认证,而且我们需要吧websocket消息推送给指定用户,这样为了保证websocket和http协议使用的同一套认证系统,我们就必须要把websocket认证集成到spring security中。

第一个问题认证403错误

首先贴出websocket的配置代码


public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

    private final static Logger LOG = LoggerFactory.getLogger(WebSocketConfig.class);

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/api/v1/socket/send");  // 推送消息前缀
        registry.setApplicationDestinationPrefixes("/api/v1/socket/req"); // 应用请求前缀
        registry.setUserDestinationPrefix("/user");//推送用户前缀
    }



    /**
     * 建立连接的端点
     * @param registry
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/api/v1/socket/fallback").setAllowedOrigins("*").withSockJS().setInterceptors(httpSessionHandshakeInterceptor());
    }
}

第一次在打开websocket的时候发现三次握手都没有出现就直接报403,最后仔细看错误信息发现错误信息的链接为:/api/v1/socket/fallback/info,而且该请求使用的是http请求不是websocket请求。最后发现每一个websocket在连接端点之前都会发送一个http GET请求用于保证该服务是存在的。而该请求是程序自动发送的自能自动携带cookie数据,无法发送自定义header。

spring boot自带的认证是:如果/api/v1/socket/fallback/info该请求通过认证,那么websocket的所有请求以及发送全部自动绑定该认证用户。如果我们想办法让/api/v1/socket/fallback/info请求通过认证,那么接下来所有的问题都将解决。问题是我们的token是使用自定义header实现的认证。所以该方法不成立,所以只能让websocket自己认证。

为了解决/api/v1/socket/fallback/info请求的403问题我在安全配置中加入.authorizeRequests().antMatchers("/api/v1/socket/fallback/**").permitAll()这样第一步判断服务是否存在就解决了,这里离解决websocket的认证问题只是第一步。

第二个问题:如果发送token给后端

stomp 客户端可以直接在websocket请求中加入自定义header,如下:

let socket = new SockJS('/api/v1/socket/fallback')
let stompClient = Stomp.over(socket)
let token = localStorage.getItem('Auth-Token') // eslint-disable-line
stompClient.connect({'Auth-Token': token}, frame => {
  stompClient.subscribe('/user/api/v1/socket/send/greetings', data => {
    // TODO
  })
})

第三个问题:后端如何认证

我们在创建连接的时候前端需要将token发送到后端,现在我们已经将token发送到后端了,但是后端如何接受并处理token得到认证数据呢?带着这个问题开始google吧!http://stackoverflow.com/questions/39422053/spring-4-x-token-based-websocket-sockjs-fallback-authentication这个链接正好解决了我的问题,

UPDATE 2016-12-13 : the issue referenced below is now marked fixed, so the hack below is no longer necessary which Spring 4.3.5 or above. See https://github.com/spring-projects/spring-framework/blob/master/src/asciidoc/web-websocket.adoc#token-based-authentication.

原来这个问题在4.3.5版本中已经被继承进去了,查看自己的版本是4.3.4,不解释直接升级版本到4.3.5,然后加如代码

@EnableWebSocketMessageBroker
public class MyConfig extends AbstractWebSocketMessageBrokerConfigurer {

  @Override
  public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.setInterceptors(new ChannelInterceptorAdapter() {

        @Override
        public Message<?> preSend(Message<?> message, MessageChannel channel) {

            StompHeaderAccessor accessor =
                MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

            if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                String jwtToken = accessor.getFirstNativeHeader("Auth-Token");
                    if (StringUtils.isNotEmpty(jwtToken)) {
                        UserAuthenticationToken authToken = tokenService.retrieveUserAuthToken(jwtToken);
                        SecurityContextHolder.getContext().setAuthentication(authToken);
                        accessor.setUser(authToken);
                    }
            }

            return message;
        }
    });
  }
}

开始测试,发现还是报错MissingCsrfTokenException,然后开始debug代码,发现代码错误的代码为:

public final class CsrfChannelInterceptor extends ChannelInterceptorAdapter {
    private final MessageMatcher<Object> matcher;

    public CsrfChannelInterceptor() {
        this.matcher = new SimpMessageTypeMatcher(SimpMessageType.CONNECT);
    }

    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        if(!this.matcher.matches(message)) {
            return message;
        } else {
            Map sessionAttributes = SimpMessageHeaderAccessor.getSessionAttributes(message.getHeaders());
            CsrfToken expectedToken = sessionAttributes == null?null:(CsrfToken)sessionAttributes.get(CsrfToken.class.getName());
            if(expectedToken == null) {  // 在这里为null
                throw new MissingCsrfTokenException((String)null);  //报错
            } else {
                String actualTokenValue = SimpMessageHeaderAccessor.wrap(message).getFirstNativeHeader(expectedToken.getHeaderName());
                boolean csrfCheckPassed = expectedToken.getToken().equals(actualTokenValue);
                if(csrfCheckPassed) {
                    return message;
                } else {
                    throw new InvalidCsrfTokenException(expectedToken, actualTokenValue);
                }
            }
        }
    }
}

仔细查看里面的数据,原来这里是需要在header中存放一些数据,于是乎将configureClientInboundChannel方法修正为:


    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.setInterceptors(new ChannelInterceptorAdapter() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    String jwtToken = accessor.getFirstNativeHeader("Auth-Token");
                    LOG.debug("webSocket token is {}", jwtToken);
                    if (StringUtils.isNotEmpty(jwtToken)) {
                        Map sessionAttributes = SimpMessageHeaderAccessor.getSessionAttributes(message.getHeaders());
                        sessionAttributes.put(CsrfToken.class.getName(), new DefaultCsrfToken("Auth-Token", "Auth-Token", jwtToken));
                        UserAuthenticationToken authToken = tokenService.retrieveUserAuthToken(jwtToken);
                        SecurityContextHolder.getContext().setAuthentication(authToken);
                        accessor.setUser(authToken);
                    }
                }
                return message;
            }
        });
    }

然后修改websocket安全配置为:

@Configuration
public class WebsocketSecurityConfiguration extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    @Override
    protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
        messages.anyMessage().permitAll();
    }

    @Override
    protected boolean sameOriginDisabled() {
        return true;
    }
}

这样websocket 集成spring boot token的认证就搞定了。

03-05 21:46