I have written a sample to demo client/server communication with WebSocket protocol.
The server code:
#SpringBootApplication
class WebSocketServerApplication {
#Bean
fun webSocketMapping(mapper: ObjectMapper): HandlerMapping? {
val map = mapOf("/ws/messages" to ChatSocketHandler(mapper))
val simpleUrlHandlerMapping = SimpleUrlHandlerMapping().apply {
urlMap = map
order = 10
}
return simpleUrlHandlerMapping
}
#Bean
fun handlerAdapter(): WebSocketHandlerAdapter = WebSocketHandlerAdapter()
}
fun main(args: Array<String>) {
runApplication<WebSocketServerApplication>(*args)
}
class ChatSocketHandler(val mapper: ObjectMapper) : WebSocketHandler {
val sink = Sinks.replay<Message>(100);
val outputMessages: Flux<Message> = sink.asFlux();
override fun handle(session: WebSocketSession): Mono<Void> {
println("handling WebSocketSession...")
session.receive()
.map { it.payloadAsText }
.map { Message(id= UUID.randomUUID().toString(), body = it, sentAt = Instant.now()) }
.doOnNext { println(it) }
.subscribe(
{ message: Message -> sink.next(message) },
{ error: Throwable -> sink.error(error) }
);
return session.send(
Mono.delay(Duration.ofMillis(100))
.thenMany(outputMessages.map { session.textMessage(toJson(it)) })
)
}
fun toJson(message: Message): String = mapper.writeValueAsString(message)
}
data class Message #JsonCreator constructor(
#JsonProperty("id") var id: String? = null,
#JsonProperty("body") var body: String,
#JsonProperty("sentAt") var sentAt: Instant = Instant.now()
)
I have provided a client written in Angular, it works well, the codes is here.
When trying to a test for the server.
#SpringBootTest()
class WebsocketServerApplicationTests {
lateinit var client: WebSocketClient;
#Autowired
lateinit var mapper: ObjectMapper;
#BeforeEach
fun setup() {
this.client = ReactorNettyWebSocketClient()
}
#Test
fun contextLoads() {
val replay = Sinks.replay<Message>(10)
client.execute(
URI("ws://localhost:8080/ws/messages")
) { session: WebSocketSession ->
println("Starting to send messages")
session.receive()
.map { mapper.readValue(it.payloadAsText, Message::class.java) }
.subscribe { replay.next(it) }
session.send(
Mono.delay(Duration.ofSeconds(1)).thenMany(
Flux.just("test message", "test message2")
.map(session::textMessage)
)
).then()
}.subscribe()
StepVerifier.create(replay.asFlux().takeLast(2))
.consumeNextWith { it -> assertThat(it.body).isEqualTo("test message") }
.consumeNextWith { it -> assertThat(it.body).isEqualTo("test message2") }
.verifyComplete()
}
}
When starting up the application, run the test, it is frozen, not work as expected.
The problem is on the test side.
Complete your stream to make takeLast(n) working
First of all, you expect to take the last 2 elements from the stream. However, that is going to happen when and only when there is onComplete signal, which let the Flux.takeLast know that there is the end of the stream, so the last n observed elements are last.
In your code, you listen to the WebsocketInbound messages and send them to the ReplaySink. However, the FluxSink#complete message is never called, which means takeLast(2) will hang forever as expected.
Solution
On the one hand, the solution seems to be obvious:
session.receive()
.map { mapper.readValue(it.payloadAsText, Message::class.java) }
.subscribe ({ replay.next(it) }, { replay.error(it) }, { replay.complete() })
However, there is might be a trick:
.receive sends a terminal signal only when the WebSocket connection is closed.
Therefore, in order to receive a terminal signal, please ensure that the server closes the connection on its side. Otherwise, the test will still hang waiting for the final terminal signal.
If the connection close is not desired, try to simply use .take(2).
Finally fixed this issue myself after reading some posts on stackoverflow and source codes of testing reactive WebSocket in the spring framework.
Spring reactive ReactorNettyWebSocketClient not logging anything
How to use Spring Reactive WebSocket and transform it into the Flux stream?
WebSocketIntegrationTests
#SpringBootTest()
class WebSocketServerApplicationTests {
lateinit var client: WebSocketClient
#Autowired
lateinit var mapper: ObjectMapper
#BeforeEach
fun setup() {
this.client = ReactorNettyWebSocketClient()
}
#Test
fun contextLoads() {
val replay = Processors.replay<Message>(100)
try {
client.execute(
URI("ws://localhost:8080/ws/messages")
) { session: WebSocketSession ->
val receiveMono = session.receive()
.map { mapper.readValue(it.payloadAsText, Message::class.java) }
.log("received from server::")
.subscribeWith(replay)
.then()
session
.send(
Mono.delay(Duration.ofMillis(500)).thenMany(
Flux.just("test message", "test message2")
.map(session::textMessage)
)
)
.then(receiveMono)
}.block(Duration.ofSeconds(5L))
// assert
assertThat(replay.blockLast(Duration.ofSeconds(5L))?.body).isEqualTo("test message2")
} catch (e: Exception) {
println(e.message)
}
}
}
Related
I am trying to connect to the google cloud platform pub/sub from behind a proxy.
Using Spring lib "org.springframework.cloud:spring-cloud-gcp-starter-pubsub" which uses the google pub sub client, which in order to make the pull call for the subscription uses gRPC calls.
In order to set the proxy I can use GRPC_PROXY_EXP environment variable but I also need credentials to go through this proxy.
I've tries several approaches, including configuring the org.springframework.cloud.gcp.pubsub.support.SubscriberFactory similar to here https://medium.com/google-cloud/accessing-google-cloud-apis-though-a-proxy-fe46658b5f2a
#Bean
fun inboundQuotationsChannelAdapter(
#Qualifier("inboundQuotationsMessageChannel") quotationsChannel: MessageChannel,
mpProperties: ConfigurationProperties,
defaultSubscriberFactory: SubscriberFactory
): PubSubInboundChannelAdapter {
Authenticator.setDefault(ProxyAuthenticator("ala","bala"))
val proxySubscriberFactory: DefaultSubscriberFactory = defaultSubscriberFactory as DefaultSubscriberFactory
proxySubscriberFactory.setCredentialsProvider(ProxyCredentialsProvider(getCredentials()))
val headers = mutableMapOf(Pair("Proxy-Authorization", getBasicAuth()))
proxySubscriberFactory.setChannelProvider(SubscriberStubSettings.defaultGrpcTransportProviderBuilder()
.setHeaderProvider(FixedHeaderProvider.create(headers)).build())
val proxySubscriberTemplate = PubSubSubscriberTemplate(proxySubscriberFactory)
val adapter = PubSubInboundChannelAdapter(proxySubscriberTemplate, mpProperties.gcp.quotationSubscription)
adapter.outputChannel = quotationsChannel
adapter.ackMode = AckMode.MANUAL
adapter.payloadType = ActivityStateChanged::class.java
return adapter
}
#Throws(IOException::class)
fun getCredentials(): GoogleCredentials {
val httpTransportFactory = getHttpTransportFactory(
"127.0.0.1", 3128, "ala", "bala"
)
return GoogleCredentials.getApplicationDefault(httpTransportFactory)
}
fun getHttpTransportFactory(
proxyHost: String?,
proxyPort: Int,
proxyUsername: String?,
proxyPassword: String?
): HttpTransportFactory? {
val proxyHostDetails = HttpHost(proxyHost, proxyPort)
val httpRoutePlanner: HttpRoutePlanner = DefaultProxyRoutePlanner(proxyHostDetails)
val credentialsProvider: CredentialsProvider = BasicCredentialsProvider()
credentialsProvider.setCredentials(
AuthScope(proxyHostDetails.hostName, proxyHostDetails.port),
UsernamePasswordCredentials(proxyUsername, proxyPassword)
)
val httpClient: HttpClient = ApacheHttpTransport.newDefaultHttpClientBuilder()
.setRoutePlanner(httpRoutePlanner)
.setProxyAuthenticationStrategy(ProxyAuthenticationStrategy.INSTANCE)
.setDefaultCredentialsProvider(credentialsProvider)
.setDefaultRequestConfig(
RequestConfig.custom()
.setAuthenticationEnabled(true)
.setProxy(proxyHostDetails)
.build())
.addInterceptorLast(HttpRequestInterceptor { request, context ->
request.addHeader(
BasicHeader(
"Proxy-Authorization",
getBasicAuth()
)
)
})
.build()
val httpTransport: HttpTransport = ApacheHttpTransport(httpClient)
return HttpTransportFactory { httpTransport }
}
Also tried using #GRpcGlobalInterceptor from LogNet
https://github.com/LogNet/grpc-spring-boot-starter
#Bean
#GRpcGlobalInterceptor
fun globalServerInterceptor(): ServerInterceptor {
return GrpcServerInterceptor(configurationProperties)
}
#Bean
#GRpcGlobalInterceptor
fun globalClientInterceptor(): ClientInterceptor {
return GrpcClientInterceptor(configurationProperties)
}
with
class GrpcClientInterceptor(private val configurationProperties: ConfigurationProperties) :
ClientInterceptor {
private val proxyUsername = configurationProperties.http.proxy.username
private val proxyPassword = configurationProperties.http.proxy.password
private val proxyHeaderKey = Metadata.Key.of("Proxy-Authorization", Metadata.ASCII_STRING_MARSHALLER)
private fun getBasicAuth(): String {
val usernameAndPassword = "$proxyUsername:$proxyPassword"
val encoded = Base64.getEncoder().encodeToString(usernameAndPassword.toByteArray())
return "Basic $encoded"
}
override fun <ReqT, RespT> interceptCall(
method: MethodDescriptor<ReqT, RespT>?,
callOptions: CallOptions?, next: Channel
): ClientCall<ReqT, RespT>? {
return object : SimpleForwardingClientCall<ReqT, RespT>(next.newCall(method, callOptions)) {
override fun start(responseListener: Listener<RespT>?, headers: Metadata) {
headers.put(proxyHeaderKey, getBasicAuth())
super.start(object : SimpleForwardingClientCallListener<RespT>(responseListener) {
override fun onHeaders(headers: Metadata) {
super.onHeaders(headers)
}
}, headers)
}
}
}
}
class GrpcServerInterceptor(private val configurationProperties: ConfigurationProperties) :
ServerInterceptor {
private val proxyUsername = configurationProperties.http.proxy.username
private val proxyPassword = configurationProperties.http.proxy.password
override fun <ReqT : Any?, RespT : Any?> interceptCall(
call: ServerCall<ReqT, RespT>?,
headers: io.grpc.Metadata?,
next: ServerCallHandler<ReqT, RespT>?
): ServerCall.Listener<ReqT> {
val proxyHeaderKey = Metadata.Key.of("Proxy-Authorization", Metadata.ASCII_STRING_MARSHALLER)
if (!headers!!.containsKey(proxyHeaderKey))
headers!!.put(proxyHeaderKey, getBasicAuth())
return next!!.startCall(call, headers)
}
private fun getBasicAuth(): String {
val usernameAndPassword = "$proxyUsername:$proxyPassword"
val encoded = Base64.getEncoder().encodeToString(usernameAndPassword.toByteArray())
return "Basic $encoded"
}
}
(also tried the annotation directly on class level - ofc it did not work)
Also tried using #GrpcGlobalServerInterceptor and #GrpcGlobalClientInterceptor from https://github.com/yidongnan/grpc-spring-boot-starter/tree/v2.12.0.RELEASE but this dependency crashed the app entirely
Here you can find an example on how to set the proxy credentials from the Java API documentation to configuring-a-proxy ;
public CloudTasksClient getService() throws IOException {
TransportChannelProvider transportChannelProvider =
CloudTasksStubSettings.defaultGrpcTransportProviderBuilder()
.setChannelConfigurator(
new ApiFunction<ManagedChannelBuilder, ManagedChannelBuilder>() {
#Override
public ManagedChannelBuilder apply(ManagedChannelBuilder managedChannelBuilder) {
return managedChannelBuilder.proxyDetector(
new ProxyDetector() {
#Nullable
#Override
public ProxiedSocketAddress proxyFor(SocketAddress socketAddress)
throws IOException {
return HttpConnectProxiedSocketAddress.newBuilder()
.setUsername(PROXY_USERNAME)
.setPassword(PROXY_PASSWORD)
.setProxyAddress(new InetSocketAddress(PROXY_HOST, PROXY_PORT))
.setTargetAddress((InetSocketAddress) socketAddress)
.build();
}
});
}
})
.build();
CloudTasksSettings cloudTasksSettings =
CloudTasksSettings.newBuilder()
.setTransportChannelProvider(transportChannelProvider)
.build();
return CloudTasksClient.create(cloudTasksSettings);
}
Take into consideration the note where it says that gRPC proxy is currently experimental.
There are two clients that communicate with google. One using "http" and one using "gRPC". (https instead of http is also possible)
The Solution Dan posted is just the solution for gRPC.
Here my Solution for http by using an Apache-Http-Client.
try (InputStream jsonCredentialsFile = json-file as InputStream) {
GoogleCredentials credentials = GoogleCredentials.fromStream(jsonCredentialsFile, new HttpTransportFactory() {
public HttpTransport create() {
DefaultHttpClient httpClient = new DefaultHttpClient();
httpClient.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, new HttpHost("myProxyHost", myProxyPort, "http"));
// DefaultHttpClient is deprecated but this recommended solution does not work in this context.
// org.apache.http.impl.client.InternalHttpClient.getParams() always throws UnsupportedOperationException
// HttpClientBuilder builder = HttpClientBuilder.create();
// if (StringUtils.isBlank(proxyServer) || proxyport < 0) {
// builder.setProxy(new HttpHost(proxyServer, proxyport, "http"));
// }
// CloseableHttpClient httpClient = builder.build();
return new ApacheHttpTransport(httpClient);
}
})
.createScoped(Lists.newArrayList("https://www.googleapis.com/auth/cloud-platform"));
DocumentProcessorServiceSettings.Builder dpssBuilder = DocumentProcessorServiceSettings.newBuilder()
.setEndpoint(endpoint)
.setCredentialsProvider(FixedCredentialsProvider.create(credentials));
dpssBuilder.setTransportChannelProvider(transportProvider);
DocumentProcessorServiceClient client = DocumentProcessorServiceClient.create(dpssBuilder.build());
// use the client
}
Expected result: connecting to the RSocket websocket based endpoint from front end that includes authentication information as metadata will trigger PayloadSocketAcceptorInterceptor's jwt authentication system.
Actual result: This only happens when sending responseRequest from JS frontend, fails when doing the same with streamRequest. No errors. Not one of the authentication related methods get called in the classes below. I've logged all of them.
Code for RSocketConfig:
#Configuration
#EnableRSocketSecurity
#EnableReactiveMethodSecurity
class RSocketConfig {
#Autowired
lateinit var rSocketAuthenticationManager: RSocketAuthenticationManager
#Bean
fun rSocketMessageHandler(strategies: RSocketStrategies?): RSocketMessageHandler? {
val handler = RSocketMessageHandler()
handler.argumentResolverConfigurer.addCustomResolver(AuthenticationPrincipalArgumentResolver())
handler.rSocketStrategies = strategies!!
return handler
}
#Bean
fun authorization(rsocket: RSocketSecurity): PayloadSocketAcceptorInterceptor {
rsocket.authorizePayload { authorize: AuthorizePayloadsSpec ->
authorize
.route("flux-stream").authenticated()
.anyRequest().authenticated()
.anyExchange().permitAll()
}
.jwt { jwtSpec: RSocketSecurity.JwtSpec ->
try {
jwtSpec.authenticationManager(rSocketAuthenticationManager)
} catch (e: Exception) {
throw RuntimeException(e)
}
}
return rsocket.build()
}
#Bean
fun rSocketRequester(strategies: RSocketStrategies, props: RSocketProperties): Mono<RSocketRequester> =
RSocketRequester.builder()
.rsocketStrategies(strategies)
.connectWebSocket(getUri(props))
fun getUri(props: RSocketProperties): URI =
URI.create(String.format("ws://localhost:${props.server.port}${props.server.mappingPath}"))
}
Code for RSocketAuthenticationManager:
#Component
class RSocketAuthenticationManager(): ReactiveAuthenticationManager {
#Autowired
lateinit var cognitoConfig: CognitoConfig
#Override
override fun authenticate(authentication: Authentication): Mono<Authentication> {
val authToken: String = authentication.credentials.toString()
try {
return if(isTokenValid(authToken)) {
val decoded = JWT.decode(authToken)
decoded.claims.entries.forEach { (key, value) -> println("$key = ${value.asString()}") }
val authorities: MutableList<GrantedAuthority> = ArrayList()
println("authentication successful!")
Mono.just(UsernamePasswordAuthenticationToken(decoded.subject, null, authorities))
} else {
println("invalid authentication token")
Mono.empty<Authentication>();
}
} catch (e: Exception) {
println("authentication errored")
e.printStackTrace()
return Mono.empty<Authentication>()
}
}
#Throws(Exception::class)
fun isTokenValid(token: String): Boolean {
// code borrowed from
// https://github.com/awslabs/cognito-proxy-rest-service/blob/2f9a9ffcc742c8ab8a694b7cf39dc5d8b3247898/src/main/kotlin/com/budilov/cognito/services/CognitoService.kt#L41
// Decode the key and set the kid
val decodedJwtToken = JWT.decode(token)
val kid = decodedJwtToken.keyId
val http = UrlJwkProvider(URL(cognitoConfig.jwksUrl))
// Let's cache the result from Cognito for the default of 10 hours
val provider = GuavaCachedJwkProvider(http)
val jwk = provider.get(kid)
val algorithm = Algorithm.RSA256(jwk.publicKey as RSAKey)
val verifier = JWT.require(algorithm)
.withIssuer(cognitoConfig.jwtTokenIssuer)
.build() //Reusable verifier instance
val jwt = try {
verifier.verify(token)
} catch (e: Exception) {
false
}
return (jwt != null)
}
}
Dependencies related to the issue:
implementation("org.springframework.boot:spring-boot-starter-webflux:2.3.0.RELEASE")
implementation("org.springframework.boot:spring-boot-starter-websocket:2.3.0.RELEASE")
implementation("org.springframework.boot:spring-boot-configuration-processor:2.3.0.RELEASE")
implementation("org.springframework.boot:spring-boot-starter-rsocket:2.3.0.RELEASE")
implementation("org.springframework.boot:spring-boot-starter-integration:2.3.0.RELEASE")
implementation("org.springframework.boot:spring-boot-starter-security:2.3.0.RELEASE")
implementation("org.springframework.security:spring-security-rsocket:5.4.2")
implementation("org.springframework.security:spring-security-messaging:5.4.2")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server:2.3.0.RELEASE")
implementation("com.auth0:java-jwt:3.3.0")
implementation("com.auth0:jwks-rsa:0.1.0")
I'm not too familiar with Spring Security, so maybe I'm missing something obvious.
You should have a service method annotated to receive the authentication principal.
#MessageMapping("runCommand")
suspend fun runCommand(request: CommandRequest, rSocketRequester: RSocketRequester, #AuthenticationPrincipal jwt: String): Flow<CommandResponse> {
Can you extract a simpler project that you can share on github to work through why it's not working?
A full example is here https://spring.io/blog/2020/06/17/getting-started-with-rsocket-spring-security
The issue has been solved, for anyone in need check the repos history with fixes: https://github.com/Braffolk/spring-rsocket-stream-jwt-authentication/commits/master
We have successfully developed webosocket+stomp+rabbitmq for our project with security. It is working fine although we have some problems in solving the following case:
The workflow of this websocket works as following:
First user subscribes to the websocket endpoint which is working as expected
Second after being authorized by user's token, the user tries to subscribe the following endpoint '/user/queue/' + chatRoomId +
'.messages'. Here chatroomId defines to which chatroom user connects
to, which is also working fine, however here user can connect any
chatroomid which is not being validated in the backend which is also
the big matter we are trying to solve.
stompClient.subscribe('/user/queue/' + chatRoomId + '.messages', incomingMessages);
My question is how can i validate any users when they try to subscribe to this endpoint? I mean is there any way to handle each specific subscriptions
This is our front end code. if u need complete page i will upload it
function connect() {
socket = new SockJS('http://localhost:9600/wsss/messages');
stompClient = Stomp.over(socket);
// var stompClient = Stomp.client("ws://localhost:9600/ws/messages");
// stompClient.connect({ 'chat_id' : chatRoomId,
// 'X-Authorization' : 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0Iiwic2NvcGVzIjoiUk9MRV9BRE1JTiIsImVtYWlsIjoiYWRtaW5AZ21haWwuY29tIiwiaWF0IjoxNTc5MDgxMzg5LCJleHAiOjE1ODE2NzMzODl9.H3mnti0ZNtH6uLe-sOfrr5jzwssvGNcBiHGg-nUQ6xY' },
// stompSuccess, stompFailure);
stompClient.connect({ 'chatRoomId' : chatRoomId,
'login' : 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyODg5Iiwic2NvcGVzIjoiUk9MRV9VU0VSLFJPTEVfTU9ERVJBVE9SIiwiaWF0IjoxNTgyMDMxMDA0LCJleHAiOjE1ODQ2MjMwMDR9.NGAAed4R46FgrtgyDmrLSrmd-o3tkqbF60vOg8vAWYg' },
stompSuccess, stompFailure);
}
function stompSuccess(frame) {
enableInputMessage();
successMessage("Your WebSocket connection was successfuly established!");
console.log(frame);
stompClient.subscribe('/user/queue/' + chatRoomId + '.messages', incomingMessages);
stompClient.subscribe('/topic/notification', incomingNotificationMessage);
// stompClient.subscribe('/app/join/notification', incomingNotificationMessage);
}
And here is the code I am using for my backend
#Configuration
#EnableWebSocketMessageBroker
#Order(Ordered.HIGHEST_PRECEDENCE + 99)
class WebSocketConfig #Autowired constructor(
val jwtTokenUtil: TokenProvider
) : WebSocketMessageBrokerConfigurer {
#Autowired
#Resource(name = "userService")
private val userDetailsService: UserDetailsService? = null
#Autowired
private lateinit var authenticationManager: AuthenticationManager
#Value("\${spring.rabbitmq.username}")
private val userName: String? = null
#Value("\${spring.rabbitmq.password}")
private val password: String? = null
#Value("\${spring.rabbitmq.host}")
private val host: String? = null
#Value("\${spring.rabbitmq.port}")
private val port: Int = 0
#Value("\${endpoint}")
private val endpoint: String? = null
#Value("\${destination.prefix}")
private val destinationPrefix: String? = null
#Value("\${stomp.broker.relay}")
private val stompBrokerRelay: String? = null
override fun configureMessageBroker(config: MessageBrokerRegistry) {
config.enableStompBrokerRelay("/queue/", "/topic/")
.setRelayHost(host!!)
.setRelayPort(port)
.setSystemLogin(userName!!)
.setSystemPasscode(password!!)
config.setApplicationDestinationPrefixes(destinationPrefix!!)
}
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/websocket").setAllowedOrigins("*").setAllowedOrigins("*")
registry.addEndpoint("/websocket/messages").addInterceptors(customHttpSessionHandshakeInterceptor()).setAllowedOrigins("*")
registry.addEndpoint("/wsss/messages").addInterceptors(customHttpSessionHandshakeInterceptor()).setAllowedOrigins("*").withSockJS()
}
#Bean
fun customHttpSessionHandshakeInterceptor(): CustomHttpSessionHandshakeInterceptor {
return CustomHttpSessionHandshakeInterceptor()
}
override fun configureClientInboundChannel(registration: ChannelRegistration) {
registration.interceptors(object : ChannelInterceptor {
override fun preSend(message: Message<*>, channel: MessageChannel): Message<*> {
val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java)
if (StompCommand.CONNECT == accessor!!.command || StompCommand.STOMP == accessor.command) {
val authorization = accessor.getNativeHeader("login")
println("X-Authorization: {$authorization}")
val authToken = authorization!![0].split(" ")[1]
val username = jwtTokenUtil.getUsernameFromToken(authToken)
if (username != null) {
if(username.contains("#")) {
val userDetails = userDetailsService!!.loadUserByUsername(username)
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
val authentication = jwtTokenUtil.getAuthentication(authToken, SecurityContextHolder.getContext().authentication, userDetails)
accessor.user = authentication
}
} else {
val authorities = jwtTokenUtil.getAuthoritiesFromToken(authToken)
val usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken(username, "", authorities)
val authentication = authenticationManager.authenticate(usernamePasswordAuthenticationToken)
accessor.user = authentication
}
}
}
return message
}
})
}
}
Here is the events handler
#Component
class WebSocketEvents {
#EventListener
fun handleSessionConnected(event: SessionConnectEvent) {
val headers = SimpMessageHeaderAccessor.wrap(event.message)
if ( headers.getNativeHeader("chatRoomId") != null && headers.getNativeHeader("chatRoomId")!!.isNotEmpty()){
val chatId = headers.getNativeHeader("chatRoomId")!![0]
if (headers.sessionAttributes != null)
headers.sessionAttributes!!["chatRoomId"] = chatId
}
}
#EventListener
fun handleSessionDisconnect(event: SessionDisconnectEvent) {
val headers = SimpMessageHeaderAccessor.wrap(event.message)
val chatRoomId = headers.sessionAttributes!!["chatRoomId"].toString()
}
}
So far what I have tried: As you can see above when user first connecting to the websocket endpoint http://localhost:9600/wsss/messages it is sending token and chatroom id (headers) and I am handling this in events listener component by resetting chatroomid into header attributes.
What I really need to do is take chatroom id while user subscribing to this specific destionation and apply validation whether he belongs to this chatroom and if so just give him permission | let him join the chat if not return error
I really appreciate any thought or workarounds!
I have spent couple of day searching for an answer but did not find any so I have figured out by myself. Here is my solution for this problem, though it is not complete one.
I have created separate interceptor class for handling all connection types as I was doing while catching subscribe command. So
it came to my mind, why not to use Subscribe command to listen users
actions and respond to it properly. For instance like this
#Component
class WebSocketTopicHandlerInterceptor constructor() : ChannelInterceptor {
override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? {
val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java)
if (StompCommand.CONNECT == accessor!!.command || StompCommand.STOMP == accessor.command) {
val authorization = accessor.getNativeHeader("login").apply { if (isNullOrEmpty()) throw LoggedError(AuthorizationException()) }
val authToken = authorization!![0].split(" ").apply { if (size <= 1) throw LoggedError(InvalidTokenException("Token is not valid")) }[1]
val username = jwtTokenUtil.getUsernameFromToken(authToken)
//DO YOUR AUTHENTICATION HERE
}
if (StompCommand.SUBSCRIBE == accessor.command) {
val destination = accessor.destination
if (destination.isNullOrBlank()) throw LoggedError(CustomBadRequestException("Subscription destionation cannot be null! U DUMB IDIOT!"))
val chatPattern = "/user/queue/+[a-zA-Z0-9-]+.messages".toRegex()
val notificationPattern = "/topic/notification".toRegex()
if (chatPattern.matches(accessor.destination!!)) println("working")
// FINDING OUT WHERE USER IS TRYING TO SUBSCRIBE ALL ROUTING LOGIC GOES HERE...
when {
chatPattern.matches(destination) -> {
//do your all logic here
}
notificationPattern.matches(destination) -> {
//do your all logic here
}
}
}
return message
}
}
IS THERE ANYTHING DOES NOT MAKE SENSE JUST LET ME KNOW I WILL BE VERY HAPPY TO ELABORATE ON ANYTHING FURTHER.
What I have done in my case is that I have figured out where the user is going and do my all validation there otherwise user cannot subscribe to any channel which means it is very secure.
I'm trying to configure subscription mapping for stomp over websockets in a spring boot application without any luck. I'm fairly certian I have the stomp/websocket stuff configured correctly as I am able to subscribe to topics that are being published to by a kafka consumer, but using the #SubscribeMapping is not working at all.
Here is my controller
#Controller
class TestController {
#SubscribeMapping("/topic/test")
fun testMapping(): String {
return "THIS IS A TEST"
}
}
And here is my configuration
#Configuration
#EnableWebSocketMessageBroker
#Order(Ordered.HIGHEST_PRECEDENCE + 99)
class WebSocketConfig : AbstractWebSocketMessageBrokerConfigurer() {
override fun configureMessageBroker(config: MessageBrokerRegistry) {
config.setApplicationDestinationPrefixes("/app", "/topic")
config.enableSimpleBroker("/queue", "/topic")
config.setUserDestinationPrefix("/user")
}
override fun registerStompEndpoints(registry:StompEndpointRegistry) {
registry.addEndpoint("/ws").setAllowedOrigins("*")
}
override fun configureClientInboundChannel(registration: ChannelRegistration?) {
registration?.setInterceptors(object: ChannelInterceptorAdapter() {
override fun preSend(message: Message<*>, channel: MessageChannel): Message<*> {
val accessor: StompHeaderAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java)
if (StompCommand.CONNECT.equals(accessor.command)) {
Optional.ofNullable(accessor.getNativeHeader("authorization")).ifPresent {
val token = it[0]
val keyReader = KeyReader()
val creds = Jwts.parser().setSigningKey(keyReader.key).parseClaimsJws(token).body
val groups = creds.get("groups", List::class.java)
val authorities = groups.map { SimpleGrantedAuthority(it as String) }
val authResult = UsernamePasswordAuthenticationToken(creds.subject, token, authorities)
SecurityContextHolder.getContext().authentication = authResult
accessor.user = authResult
}
}
return message
}
})
}
}
And then in the UI code, I'm using angular with a stompjs wrapper to subscribe to it like this:
this.stompService.subscribe('/topic/test')
.map(data => data.body)
.subscribe(data => console.log(data));
Subscribing like this to topics that I know are emitting data works perfectly but the subscribemapping does nothing. I've also tried adding an event listener to my websocket config to test that the UI is actually sending a subscription event to the back end like this:
#EventListener
fun handleSubscribeEvent(event: SessionSubscribeEvent) {
println("Subscription event: $event")
}
#EventListener
fun handleConnectEvent(event: SessionConnectEvent) {
println("Connection event: $event")
}
#EventListener
fun handleDisconnectEvent(event: SessionDisconnectEvent) {
println("Disconnection event: $event")
}
Adding these event listeners I can see that all the events that I'm expecting from the UI are coming through in the kotlin layer, but my controller method never gets called. Is there anything obvious that I'm missing?
Try the following:
#Controller
class TestController {
#SubscribeMapping("/test")
fun testMapping(): String {
return "THIS IS A TEST"
}
}
I need to access a websocket-service which closes an open websocket-connection after 24h. How do I have to implement the reconnect with Spring-Boot 2 and Webflux?
This is what I have so far (taken from https://github.com/artembilan/webflux-websocket-demo):
#GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> getStreaming() throws URISyntaxException {
ReactorNettyWebSocketClient client = new ReactorNettyWebSocketClient();
EmitterProcessor<String> output = EmitterProcessor.create();
Mono<Void> sessionMono = client.execute(new URI("ws://localhost:8080/echo"),
session -> session.receive()
.timeout(Duration.ofSeconds(3))
.map(WebSocketMessage::getPayloadAsText)
.subscribeWith(output)
.then());
return output.doOnSubscribe(s -> sessionMono.subscribe());
}
As soon as the connection gets lost (3 seconds no input anymore), a TimeoutException is thrown. But how can I reconnect the socket?
There is no out-of-the-box solution, reconnection mechanism is not part of JSR 356 - Java API for WebSocket. But you can implement it on your own - a simple example with Spring events:
Step 1 - Create an event class.
public class ReconnectionEvent extends ApplicationEvent {
private String url;
public ReconnectionEvent(String url) {
super(url);
this.url = url;
}
public String getUrl() {
return url;
}
}
Step 2 - Provide a method for websocket connection. An example:
...
#Autowired
private ApplicationEventPublisher publisher;
...
public void connect(String url) {
ReactorNettyWebSocketClient client = new ReactorNettyWebSocketClient();
EmitterProcessor<String> output = EmitterProcessor.create();
Mono<Void> sessionMono = client.execute(URI.create(url),
session -> session.receive()
.map(WebSocketMessage::getPayloadAsText)
.log()
.subscribeWith(output)
.doOnTerminate(() -> publisher.publishEvent(new ReconnectEvent(url)))
.then());
output
.doOnSubscribe(s -> sessionMono.subscribe())
.subscribe();
}
Check doOnTerminate() method - when the Flux terminates, either by completing successfully or with an error, it emits a ReconnectEvent. If necessary, you can emit the reconnection event on other Flux's callbacks (for example only on doOnError()).
Step 3 - Provide a listener, that connects again on given url when a reconnection event occures.
#EventListener(ReconnectEvent.class)
public void onApplicationEvent(ReconnectEvent event) {
connect(event.getUrl());
}
I did something by using UnicastProcessor of reactor.
...
public abstract class AbstractWsReconnectClient {
private Logger ...
protected UnicastProcessor<AbstractWsReconnectClient> reconnectProcessor = UnicastProcessor.create();
protected AbstractWsReconnectClient(Duration reconnectDuration) {
reconnect(reconnectDuration);
}
public abstract Mono<Void> connect();
private void reconnect(Duration duration) {
reconnectProcessor.publish()
.autoConnect()
.delayElements(duration)
.flatMap(AbstractWsReconnectClient::connect)
.onErrorContinue(throwable -> true,
(throwable, o) -> {
if (throwable instanceof ConnectException) {
logger.warn(throwable.getMessage());
} else {
logger.error("unexpected error occur during websocket reconnect");
logger.error(throwable.getMessage());
}
})
.doOnTerminate(() -> logger.error("websocket reconnect processor terminate "))
.subscribe();
}
}
When the WebSocketClient is terminate, invoke UnicastProcessor.onNext
public Mono<Void> connect() {
WebSocketClient client = new ReactorNettyWebSocketClient();
logger.info("trying to connect to sso server {}", uri.toString());
return client.execute(uri, headers, ssoClientHandler)
.doOnTerminate(() -> {
logger.warn("sso server {} disconnect", uri.toString());
super.reconnectProcessor.onNext(this);
});
}