Spring中的Websocket身份验证和授权

Posted

技术标签:

【中文标题】Spring中的Websocket身份验证和授权【英文标题】:Websocket Authentication and Authorization in Spring 【发布时间】:2018-01-06 09:40:01 【问题描述】:

我一直在努力使用 Spring-Security 正确实现 Stomp (websocket) AuthenticationAuthorization为了后代,我会回答我自己的问题以提供指导。

问题

Spring WebSocket 文档(用于身份验证)看起来不清楚 ATM(恕我直言)。我不明白如何正确处理 AuthenticationAuthorization

我想要什么

使用登录名/密码对用户进行身份验证。 防止匿名用户通过 WebSocket 连接。 添加授权层(用户、管理员、...)。 Principal 在控制器中可用。

我不想要的

在 HTTP 协商端点上进行身份验证(因为大多数 javascript 库不会将身份验证标头与 HTTP 协商调用一起发送)。

【问题讨论】:

很棒的文章。将身份验证进一步推迟到第一个 SEND 帧的处理,这是一种更糟糕的方法吗?与 CONNECT 框架相反。我尚不清楚它可能产生的任何好处,但与您在答案中描述的方式相比,这是否可能是否定的? ...首先发送或订阅,事实上 恕我直言,最好在 CONNECT 上进行(以及支持 v1.2 的 STOMP),因为它是一个通用入口点,而不是 SEND、SUBSCRIBE、BEGIN 或任何其他可能在未来 RFC 中引入的 Frame 【参考方案1】:

如上所述,文档看起来不清楚(恕我直言),在 Spring 提供一些清晰的文档之前,这里有一个样板,可以让您免于花费两天时间试图了解安全链在做什么。

Rob-Leggett 做了一个很好的尝试,但他是forking some Springs class,我觉得这样做不太舒服。

开始之前需要了解的事项:

安全链httpWebSocket安全配置是完全独立的。 Spring AuthenticationProvider 根本不参与 Websocket 身份验证。 在我们的例子中,身份验证不会发生在 HTTP 协商端点上,因为我所知道的任何 JavaScript STOMP (websocket) 库都不会随 HTTP 请求一起发送必要的身份验证标头。 一旦在 CONNECT 请求上设置,用户 (simpUser) 将被存储在 websocket 会话中,不再需要对更多消息进行身份验证。

Maven 部门

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-messaging</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-messaging</artifactId>
</dependency>

WebSocket 配置

下面的配置注册了一个简单的消息代理(一个我们稍后将保护的简单端点)。

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends WebSocketMessageBrokerConfigurer 
    @Override
    public void configureMessageBroker(final MessageBrokerRegistry config) 
        // These are endpoints the client can subscribes to.
        config.enableSimpleBroker("/queue/topic");
        // Message received with one of those below destinationPrefixes will be automatically router to controllers @MessageMapping
        config.setApplicationDestinationPrefixes("/app");
    

    @Override
    public void registerStompEndpoints(final StompEndpointRegistry registry) 
        // Handshake endpoint
        registry.addEndpoint("stomp"); // If you want to you can chain setAllowedOrigins("*")
    

Spring 安全配置

由于 Stomp 协议依赖于第一个 HTTP 请求,我们需要授权对我们的 stomp 握手端点的 HTTP 调用。

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter 
    @Override
    protected void configure(final HttpSecurity http) throws Exception 
        // This is not for websocket authorization, and this should most likely not be altered.
        http
                .httpBasic().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests().antMatchers("/stomp").permitAll()
                .anyRequest().denyAll();
    

然后我们将创建一个负责对用户进行身份验证的服务。
@Component
public class WebSocketAuthenticatorService 
    // This method MUST return a UsernamePasswordAuthenticationToken instance, the spring security chain is testing it with 'instanceof' later on. So don't use a subclass of it or any other class
    public UsernamePasswordAuthenticationToken getAuthenticatedOrFail(final String  username, final String password) throws AuthenticationException 
        if (username == null || username.trim().isEmpty()) 
            throw new AuthenticationCredentialsNotFoundException("Username was null or empty.");
        
        if (password == null || password.trim().isEmpty()) 
            throw new AuthenticationCredentialsNotFoundException("Password was null or empty.");
        
        // Add your own logic for retrieving user in fetchUserFromDb()
        if (fetchUserFromDb(username, password) == null) 
            throw new BadCredentialsException("Bad credentials for user " + username);
        

        // null credentials, we do not pass the password along
        return new UsernamePasswordAuthenticationToken(
                username,
                null,
                Collections.singleton((GrantedAuthority) () -> "USER") // MUST provide at least one role
        );
    

注意:UsernamePasswordAuthenticationToken必须至少有一个 GrantedAuthority,如果你使用另一个构造函数,Spring 会自动设置isAuthenticated = false

差不多了,现在我们需要创建一个拦截器,它将设置 `simpUser` 标头或在 CONNECT 消息上抛出 `AuthenticationException`。
@Component
public class AuthChannelInterceptorAdapter extends ChannelInterceptor 
    private static final String USERNAME_HEADER = "login";
    private static final String PASSWORD_HEADER = "passcode";
    private final WebSocketAuthenticatorService webSocketAuthenticatorService;

    @Inject
    public AuthChannelInterceptorAdapter(final WebSocketAuthenticatorService webSocketAuthenticatorService) 
        this.webSocketAuthenticatorService = webSocketAuthenticatorService;
    

    @Override
    public Message<?> preSend(final Message<?> message, final MessageChannel channel) throws AuthenticationException 
        final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        if (StompCommand.CONNECT == accessor.getCommand()) 
            final String username = accessor.getFirstNativeHeader(USERNAME_HEADER);
            final String password = accessor.getFirstNativeHeader(PASSWORD_HEADER);

            final UsernamePasswordAuthenticationToken user = webSocketAuthenticatorService.getAuthenticatedOrFail(username, password);

            accessor.setUser(user);
        
        return message;
    

注意:preSend() 必须返回一个 UsernamePasswordAuthenticationToken,弹簧安全链中的另一个元素对此进行测试。 请注意:如果您的UsernamePasswordAuthenticationToken 是在没有通过GrantedAuthority 的情况下构建的,则身份验证将失败,因为没有授予权限的构造函数自动设置authenticated = false 这是一个重要的细节,没有在spring-security 中记录强>。

最后再创建两个类来分别处理授权和身份验证。
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketAuthenticationSecurityConfig extends  WebSocketMessageBrokerConfigurer 
    @Inject
    private AuthChannelInterceptorAdapter authChannelInterceptorAdapter;
    
    @Override
    public void registerStompEndpoints(final StompEndpointRegistry registry) 
        // Endpoints are already registered on WebSocketConfig, no need to add more.
    

    @Override
    public void configureClientInboundChannel(final ChannelRegistration registration) 
        registration.setInterceptors(authChannelInterceptorAdapter);
    


注意:@OrderCRUCIAL别忘了,它允许我们的拦截器首先在安全链中注册。

@Configuration
public class WebSocketAuthorizationSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer 
    @Override
    protected void configureInbound(final MessageSecurityMetadataSourceRegistry messages) 
        // You can customize your authorization mapping here.
        messages.anyMessage().authenticated();
    

    // TODO: For test purpose (and simplicity) i disabled CSRF, but you should re-enable this and provide a CRSF endpoint.
    @Override
    protected boolean sameOriginDisabled() 
        return true;
    

【讨论】:

引用Spring Security guide "更具体地说,要确保用户已通过您的 WebSocket 应用程序进行身份验证,所需要做的就是确保您设置 Spring Security 来验证基于 HTTP 的 Web应用程序。”因此,重点是,您使用标准 Spring Security 方法验证对 http 端点的访问,然后在 CONNECT 上验证 CSRF 并在配置的 STOMP 目标上使用基于角色的安全性。我仍然不确定上述用例。 事实上,没有一个 javascrip STOMP 库将身份验证标头与 HTTP 握手调用一起传递。 Spring 选择允许用户仅通过 HTTP 进行身份验证。但我们不能为此责怪他们,WebSocket RFC 在这个主题上并不清楚并且非常宽容:该协议没有规定服务器可以在 WebSocket 握手期间对客户端进行身份验证的任何特定方式。 Spring 描述的方法状态是: 您应该在访问 HTTP 协商端点(握手端点)时提供足够的信息(登录密码或其他),以允许 Spring 通过 Spring-Security 链对您进行身份验证。但是没有一个 javaScript STOMP 库会在 HTTP 协商调用中发送这些信息。这些标头在nativeHeaders: login: xxxx, passcode: xxxx 中与CONNECT 消息一起发送。因此,如果 HTTP 调用没有发送任何信息,则此时您无法进行身份验证。 是的WebSockets reuse the same authentication information that is found in the HTTP request when the WebSocket connection was made。没错,Websockets(stomp 也是如此)依赖于第一个 HTTP 协商调用,并且 spring 期望身份验证将在此处进行。但是没有一个单一的 stomp JS 库在此协商调用期间转发凭据。因此,您需要在协商后进行身份验证。因此使用 websocket 进行身份验证。这就是为什么我说这两条链是解耦的。 直到今天,您的帖子仍然是唯一有效的方法。 Spring Boot 文档仍然缺乏明确的说明。非常感谢【参考方案2】:

对于 java 客户端,请使用这个经过测试的示例:

StompHeaders connectHeaders = new StompHeaders();
connectHeaders.add("login", "test1");
connectHeaders.add("passcode", "test");
stompClient.connect(WS_HOST_PORT, new WebSocketHttpHeaders(), connectHeaders, new MySessionHandler());

【讨论】:

这也适用于 stomp.js。但这些是 STOMP 标头(在 CONNECT 框架上)。最初的问题是 stomp.js 不会设置 Spring Security 用来自动认证的 HTTP websocket-handshake headers。【参考方案3】:

使用 spring 身份验证很痛苦。你可以用一种简单的方式做到这一点。创建一个网络过滤器并自己读取授权令牌,然后执行身份验证。

@Component
public class CustomAuthenticationFilter implements Filter 
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException 
        if (servletRequest instanceof HttpServletRequest) 
            HttpServletRequest request = (HttpServletRequest) servletRequest;
            String authorization = request.getHeader("Authorization");
            if (/*Your condition here*/) 
                // logged
                filterChain.doFilter(servletRequest, servletResponse);
             else 
                HttpServletResponse response = (HttpServletResponse) servletResponse;
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                response.getWriter().write("\"message\": "\Bad login\"");
            
        
    

    @Override
    public void init(FilterConfig filterConfig) throws ServletException 
    

    @Override
    public void destroy() 
    

然后在您的配置中使用弹簧机制定义过滤器:

@Configuration
public class SomeConfig 
    @Bean
    public FilterRegistrationBean<CustomAuthenticationFilter> securityFilter(
            CustomAuthenticationFilter customAuthenticationFilter)
        FilterRegistrationBean<CustomAuthenticationFilter> registrationBean
                = new FilterRegistrationBean<>();

        registrationBean.setFilter(customAuthenticationFilter);
        registrationBean.addUrlPatterns("/*");
        return registrationBean;
    

【讨论】:

我同意 spring 安全性还有很长的路要走,但是这样做你失去了 spring-security 带来的所有便利(能够从请求上下文中的任何地方获取用户,测试模拟,.. .) 复杂性没有方便。在我的方法中,您仍然可以注入您的服务并检查用户名和密码。例如,您可以注入 WebSocketAuthenticatorService 并通过拆分授权进行检查,如果这是基本身份验证,则获取用户名传递。只要可行,这里没有正确或错误的解决方案,我更喜欢这个,因为它对我来说更优雅。其他人可能更喜欢其他人。

以上是关于Spring中的Websocket身份验证和授权的主要内容,如果未能解决你的问题,请参考以下文章

带有 JWT 令牌的 Spring Security 和 Websocket

ldap spring security http基本身份验证

Spring websocket jwt身份验证

Shiro Spring JDBCRealm 身份验证和授权

使用 Spring Security 在 Spring 中进行 WebSocket 身份验证

使用 CAS 进行身份验证和 LDAP 进行授权的 Spring 项目