Spring 环境下的 Websocket 认证与授权

我一直在努力用 Spring-Security. 为了子孙后代,我将回答我自己的问题,提供一个指导。正确地实现 Stomp (websocket) 认证授权


问题

SpringWebSocket 文档(用于身份验证)看起来不清楚 ATM (IMHO) ,而且我不能理解如何正确处理 认证授权


我想要的

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

我不想要的

  • 在 HTTP 协商端点上进行身份验证(因为大多数 JavaScript 库不会随 HTTP 协商调用一起发送身份验证标头)。
61989 次浏览

如上所述,在 Spring 提供一些清晰的文档之前,文档看起来不清楚(IMHO) ,这里有一个样板文件,可以帮助您避免花费两天时间来理解安全链正在做什么。

一个非常好的尝试是由 Rob-Leggett,但是,他是 在斯普林斯上课,我不觉得舒服这样做。

开始之前要知道的事情:

  • 译注:WebSocket的安全链 安全配置是完全独立的。
  • Spring abc0完全不参与 Websocket 认证。
  • 在我们的例子中,身份验证不会发生在 HTTP 协商端点上,因为我所知道的 JavaScripts STOMP (websocket)库中没有一个在发送 HTTP 请求的同时发送必要的身份验证标头。
  • 一旦在 CONNECT 请求上设置,使用者(simpUser)将存储在 websocket 会话中,并且不需要对进一步的消息进行更多的身份验证。

Maven Deps

<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


差不多了,现在我们需要创建一个拦截器来设置 CONNECT 消息上的“ simUser”头部或抛出“ 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 这是一个重要的细节,没有记录在春季安全


最后再创建两个类来分别处理 Authorization 和 Authentication。
@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);
}


}

注意: @Order至关重要,不要忘记它,它允许我们的拦截器首先在安全链中注册。

@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;
}
}

对于 java 客户端使用这个测试的例子:

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

使用 Spring 身份验证是一件痛苦的事情。你可以用一种简单的方法来做。创建一个 web 过滤器,自己读取授权令牌,然后执行身份验证。

@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;
}
}