我正在使用 Spring Boot 3.1.0,并且我正在尝试在我的应用程序中实现一些 websockets。我正在按照互联网上的教程来实现它们并生成一些统一的测试以确保其正常工作。
代码与教程非常相似。一个配置类和一个控制器:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {
//Where is listening to messages
public static final String SOCKET_RECEIVE_PREFIX = "/app";
//Where messages will be sent.
public static final String SOCKET_SEND_PREFIX = "/topic";
//URL where the client must subscribe.
public static final String SOCKETS_ROOT_URL = "/ws-endpoint";
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint(SOCKETS_ROOT_URL)
.setAllowedOrigins("*")
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes(SOCKET_RECEIVE_PREFIX)
.enableSimpleBroker(SOCKET_SEND_PREFIX);
}
}
@Controller
public class WebSocketController {
@MessageMapping("/welcome")
@SendTo("/topic/greetings")
public String greeting(String payload) {
System.out.println("Generating new greeting message for " + payload);
return "Hello, " + payload + "!";
}
@SubscribeMapping("/chat")
public MessageContent sendWelcomeMessageOnSubscription() {
return new MessageContent(String.class.getSimpleName(), "Testing");
}
}
教程中未包含的额外信息是安全配置:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
private static final String[] AUTH_WHITELIST = {
// -- Swagger
"/v3/api-docs/**", "/swagger-ui/**",
// Own
"/",
"/info/**",
"/auth/public/**",
//Websockets
WebSocketConfiguration.SOCKETS_ROOT_URL,
WebSocketConfiguration.SOCKET_RECEIVE_PREFIX,
WebSocketConfiguration.SOCKET_SEND_PREFIX,
WebSocketConfiguration.SOCKETS_ROOT_URL + "/**",
WebSocketConfiguration.SOCKET_RECEIVE_PREFIX + "/**",
WebSocketConfiguration.SOCKET_SEND_PREFIX + "/**"
};
private final JwtTokenFilter jwtTokenFilter;
@Value("${server.cors.domains:null}")
private List<String> serverCorsDomains;
@Autowired
public WebSecurityConfig(JwtTokenFilter jwtTokenFilter) {
this.jwtTokenFilter = jwtTokenFilter;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
//Will use the bean KendoUserDetailsService.
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
//Disable cors headers
.cors(cors -> cors.configurationSource(generateCorsConfigurationSource()))
//Disable csrf protection
.csrf(AbstractHttpConfigurer::disable)
//Sessions should be stateless
.sessionManagement(httpSecuritySessionManagementConfigurer ->
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(httpSecurityExceptionHandlingConfigurer ->
httpSecurityExceptionHandlingConfigurer
.authenticationEntryPoint((request, response, ex) -> {
RestServerLogger.severe(this.getClass().getName(), ex.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage());
})
.accessDeniedHandler((request, response, ex) -> {
RestServerLogger.severe(this.getClass().getName(), ex.getMessage());
response.sendError(HttpServletResponse.SC_FORBIDDEN, ex.getMessage());
})
)
.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests((requests) -> requests
.requestMatchers(AUTH_WHITELIST).permitAll()
.anyRequest().authenticated());
return http.build();
}
private CorsConfigurationSource generateCorsConfigurationSource() {
final CorsConfiguration configuration = new CorsConfiguration();
if (serverCorsDomains == null || serverCorsDomains.contains("*")) {
configuration.setAllowedOriginPatterns(Collections.singletonList("*"));
} else {
configuration.setAllowedOrigins(serverCorsDomains);
configuration.setAllowCredentials(true);
}
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.addExposedHeader(HttpHeaders.AUTHORIZATION);
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
测试是:
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Test(groups = "websockets")
@AutoConfigureMockMvc(addFilters = false)
public class BasicWebsocketsTests extends AbstractTestNGSpringContextTests {
@BeforeClass
public void authentication() {
//Generates a user for authentication on the test.
AuthenticatedUser authenticatedUser = authenticatedUserController.createUser(null, USER_NAME, USER_FIRST_NAME, USER_LAST_NAME, USER_PASSWORD, USER_ROLES);
headers = new WebSocketHttpHeaders();
headers.set("Authorization", "Bearer " + jwtTokenUtil.generateAccessToken(authenticatedUser, "127.0.0.1"));
}
@BeforeMethod
public void setup() throws ExecutionException, InterruptedException, TimeoutException {
WebSocketClient webSocketClient = new StandardWebSocketClient();
this.webSocketStompClient = new WebSocketStompClient(webSocketClient);
this.webSocketStompClient.setMessageConverter(new MappingJackson2MessageConverter());
}
@Test
public void echoTest() throws ExecutionException, InterruptedException, TimeoutException {
BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(1);
StompSession session = webSocketStompClient.connectAsync(getWsPath(), this.headers,
new StompSessionHandlerAdapter() {
}).get(1, TimeUnit.SECONDS);
session.subscribe("/topic/greetings", new StompFrameHandler() {
@Override
public Type getPayloadType(StompHeaders headers) {
return String.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
blockingQueue.add((String) payload);
}
});
session.send("/app/welcome", TESTING_MESSAGE);
await().atMost(1, TimeUnit.SECONDS)
.untilAsserted(() -> Assert.assertEquals("Hello, Mike!", blockingQueue.poll()));
}
得到的结果是:
ERROR 2024-01-05 20:58:55.068 GMT+0100 c.s.k.l.RestServerLogger [http-nio-auto-1-exec-1] - com.softwaremagico.kt.rest.security.WebSecurityConfig$$SpringCGLIB$$0: Full authentication is required to access this resource
java.util.concurrent.ExecutionException: jakarta.websocket.DeploymentException: Failed to handle HTTP response code [401]. Missing [WWW-Authenticate] header in response.
这在 StackOverflow 上并不新鲜,我已经查看了其他问题的一些建议。解决方案与之前版本的 Spring Boot 略有不同,安全配置也随着版本的不同而不断发展。
如果启用 websockets 记录器:
logging.level.org.springframework.messaging=trace
logging.level.org.springframework.web.socket=trace
我可以看到身份验证标头在那里:
TRACE 2024-01-06 08:25:54.109 GMT+0100 o.s.w.s.c.s.StandardWebSocketClient [SimpleAsyncTaskExecutor-1] - Handshake request headers: {Authorization=[Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxLFRlc3QuVXNlciwxMjcuMC4wLjEsIiwiaXNzIjoiY29tLnNvZnR3YXJlbWFnaWNvIiwiaWF0IjoxNzA0NTI1OTUzLCJleHAiOjE3MDUxMzA3NTN9.-ploHleAF6IpUmP4IPzLV1nYNHnigpamYgS9e3Gp183SLri-37QZA2TDKIbE6iTDCunF0JRYry7xSsq_Op1UgQ], Sec-WebSocket-Key=[cxyjc2DjRRfm/elvG0261A==], Connection=[upgrade], Sec-WebSocket-Version=[13], Host=[127.0.0.1:38373], Upgrade=[websocket]}
注: REST 端点的安全性运行良好。我有类似的测试,我生成一个具有某些角色的用户,然后使用它进行身份验证。 测试代码可在here获取,无需任何特殊配置即可执行。
我尝试过的:
Bearer
信息并生成 JWT 令牌的标头。没有成功。 JWT 生成方法必须正确,才能成功用于 REST 服务。@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
禁用安全性。同样的 401 错误。withSockJs()
并将其删除。我还尝试使用 jakarta websocket 包生成非 stomp websocket,问题完全相同:401 错误。
从我的角度来看,JWT 标头似乎未正确包含在测试中。但我看不到代码的问题。
那么你有几个问题:
ws path
。看看常数private String getWsPath() {
return String.format("ws://127.0.0.1:%d/kendo-tournament-backend/%s", port,
WebSocketConfiguration.SOCKETS_ROOT_URL);
}
SockJsClient
。在其他情况下,您将收到400
错误this.webSocketStompClient = new WebSocketStompClient(new SockJsClient(Arrays.asList(new WebSocketTransport(new StandardWebSocketClient()))));
StringMessageConverter
,因为您在控制器中返回String
:this.webSocketStompClient.setMessageConverter(new StringMessageConverter());
await().atMost(3, TimeUnit.SECONDS)
.untilAsserted(() -> Assert.assertEquals(blockingQueue.poll(), String.format("Hello, %s!", TESTING_MESSAGE)));