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
}
Related
I am trying to use Spring Boot RSocket with Security using JWT Tokens. It is giving me an Access Denied error with no other useful information to help debug with?
Access Denied.
ApplicationErrorException (0x201): Access Denied at
app//io.rsocket.exceptions.Exceptions.from(Exceptions.java:76) at
app//io.rsocket.core.RSocketRequester.handleFrame(RSocketRequester.java:261)
at
app//io.rsocket.core.RSocketRequester.handleIncomingFrames(RSocketRequester.java:211)
at
app//reactor.core.publisher.LambdaSubscriber.onNext(LambdaSubscriber.java:160)
at
app//io.rsocket.core.ClientServerInputMultiplexer$InternalDuplexConnection.onNext(ClientServerInputMultiplexer.java:248)
at
app//io.rsocket.core.ClientServerInputMultiplexer.onNext(ClientServerInputMultiplexer.java:129)
at
app//io.rsocket.core.ClientServerInputMultiplexer.onNext(ClientServerInputMultiplexer.java:48)
at
app//reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:122)
at
app//reactor.netty.channel.FluxReceive.onInboundNext(FluxReceive.java:364)
at
app//reactor.netty.channel.ChannelOperations.onInboundNext(ChannelOperations.java:404)
at
app//reactor.netty.http.client.HttpClientOperations.onInboundNext(HttpClientOperations.java:725)
at
app//reactor.netty.http.client.WebsocketClientOperations.onInboundNext(WebsocketClientOperations.java:161)
at
app//reactor.netty.channel.ChannelOperationsHandler.channelRead(ChannelOperationsHandler.java:93)
at
app//io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
at
app//io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
at
app//io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
at
app//io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:327)
at
app//io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:299)
at
app//io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
at
app//io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
at
app//io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
at
app//io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410)
at
app//io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
at
app//io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
at
app//io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919)
at
app//io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166)
at
app//io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:722)
at
app//io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:658)
at
app//io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:584)
at app//io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:496)
at
app//io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:997)
at
app//io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at
app//io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base#11.0.14/java.lang.Thread.run(Thread.java:834)
Security Config file
#Configuration
#EnableRSocketSecurity
#EnableReactiveMethodSecurity
class SecurityConfig {
#Bean
fun authorization(rsocketSecurity: RSocketSecurity): PayloadSocketAcceptorInterceptor {
val security: RSocketSecurity =
rsocketSecurity.authorizePayload { authorize: RSocketSecurity.AuthorizePayloadsSpec ->
authorize
.anyRequest().authenticated()
.anyExchange().permitAll()
}
.jwt { jwtSpec ->
jwtSpec.authenticationManager(jwtReactiveAuthenticationManager(jwtDecoder()))
}
return security.build()
}
#Bean
fun jwtDecoder(): ReactiveJwtDecoder {
return TokenUtils.jwtAccessTokenDecoder()
}
#Bean
fun jwtReactiveAuthenticationManager(decoder: ReactiveJwtDecoder): JwtReactiveAuthenticationManager {
val converter = JwtAuthenticationConverter()
val authoritiesConverter = JwtGrantedAuthoritiesConverter()
authoritiesConverter.setAuthorityPrefix("ROLE_")
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter)
val manager = JwtReactiveAuthenticationManager(decoder)
manager.setJwtAuthenticationConverter(ReactiveJwtAuthenticationConverterAdapter(converter))
return manager
}
#Bean
fun rsocketMessageHandler() = RSocketMessageHandler() .apply {
argumentResolverConfigurer.addCustomResolver(AuthenticationPrincipalArgumentResolver())
routeMatcher = PathPatternRouteMatcher()
rSocketStrategies = rsocketStrategies()
}
#Bean
fun rsocketStrategies() = RSocketStrategies.builder()
.routeMatcher(PathPatternRouteMatcher())
.build()
}
Message Controller file
#MessageMapping("api.v1.messages")
#Controller
class MessageController {
#MessageMapping("stream")
suspend fun receive(
#Payload inboundMessages: Flow<String>,
#AuthenticationPrincipal jwt: String
) {
println("MessageController: jwt: $jwt")
println("MessageController: inbound message: " + inboundMessages.first())
}
}
Testing using MessageControllerTest file
#SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MessageControllerTest(
#Autowired val rsocketBuilder: RSocketRequester.Builder,
#LocalServerPort val serverPort: Int
) {
#ExperimentalTime
#ExperimentalCoroutinesApi
#Test
fun `test that messages API streams latest messages`() {
val admin = HelloUser(userId = "9527", password = "password", role = HelloRole.ADMIN)
val token: UserToken = TokenUtils.generateAccessToken(admin)!!
val authenticationMimeType: MimeType =
MimeTypeUtils.parseMimeType(WellKnownMimeType.MESSAGE_RSOCKET_AUTHENTICATION.string)
runBlocking {
val rSocketRequester = rsocketBuilder.websocket(URI("ws://localhost:${serverPort}/rsocket"))
launch {
rSocketRequester.route("api.v1.messages.stream")
.metadata(token.token!!, authenticationMimeType)
.dataWithType(flow {
emit(
"Hey from test class"
)
})
.retrieveFlow<Void>()
.collect()
}
}
}
}
I've add the rest of the code example I did to GitHub https://github.com/CJMobileApps/rsocket-jwt-security-example
I figured it out. RSocket currently has a bug or just bad documentation. The MimeType for JWTs BearerTokenMetadata.BEARER_AUTHENTICATION_MIME_TYPE says that it is deprecated and to use MimeTypeUtils.parseMimeType(WellKnownMimeType.MESSAGE_RSOCKET_AUTHENTICATION.string) instead, however that does not work.
When passing tokens continue using/use BearerTokenMetadata.BEARER_AUTHENTICATION_MIME_TYPE along with the token string.
.metadata(token.token!!, BearerTokenMetadata.BEARER_AUTHENTICATION_MIME_TYPE)
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
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)
}
}
}
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 developing a custom logging framework for springboot to log rest-template requests and response and is working fine. Am trying to implement the same for 'Feign-Client' and am faced with couple of issues.
For request logging, am leveraging FeignRequestInterceptor and it is working fine, only problem here is I cannot retrieve the full request URL.
Below method is giving me only relative URL.
requestTemplate.url()
To log the response, only way i could find was the ResponseDecoder. There I'm able to retrieve everything other than the payload. When accessing the payload from
InputStream is = response.body().asInputStream();
String payload = new String(IOUtils.toByteArray(is));
This method works, but the original stream is closed because of which logging happens fine, but client is throwing exception when returning response.
'trying to open closed stream'
I would like suggestions if there are better ways of logging request response in Feign similar to spring rest-template. Or if the method I have adopted is fine, help me resolve the problems above.
You can configure a custom feign.Logger instance to handle this. There are two built in, JavaLogger which uses java.util.logging and Slf4JLogger that uses slf4j. You can create your own logger implementation by extending feign.Logger and registering it as a #Bean.
That logger should be picked up by Spring and registered with your FeignClient. Here is the Logger base class to get you started:
protected abstract void log(String configKey, String format, Object... args);
Create your own instance, implement this method and it will be called before the request and after the response is returned. No need to update the interceptor or create a response decoder.
in your RestConfiguration you need to up default level of logging feignClient and override by #Bean feignLogger like:
#Configuration(proxyBeanMethods = false)
#EnableCircuitBreaker
#EnableFeignClients(basePackageClasses = [Application::class])
class RestConfiguration: WebMvcConfigurer {
#Bean
fun feignLoggerLevel(): Logger.Level {
return Logger.Level.FULL
}
#Bean
fun feignLogger(): Logger {
return FeignClientLogger()
}
}
and implement your logger (logbook format):
import feign.Logger
import feign.Request
import feign.Response
import feign.Util.*
import org.slf4j.LoggerFactory
class FeignClientLogger : Logger() {
private val log = LoggerFactory.getLogger(this::class.java)
override fun logRequest(configKey: String?, logLevel: Level?, request: Request?) {
if (request == null)
return
val feignRequest = FeignRequest()
feignRequest.method = request.httpMethod().name
feignRequest.url = request.url()
for (field in request.headers().keys) {
for (value in valuesOrEmpty(request.headers(), field)) {
feignRequest.addHeader(field, value)
}
}
if (request.requestBody() != null) {
feignRequest.body = request.requestBody().asString()
}
log.trace(feignRequest.toString())
}
override fun logAndRebufferResponse(
configKey: String?,
logLevel: Level?,
response: Response?,
elapsedTime: Long
): Response? {
if (response == null)
return response
val feignResponse = FeignResponse()
val status = response.status()
feignResponse.status = response.status()
feignResponse.reason =
(if (response.reason() != null && logLevel!! > Level.NONE) " " + response.reason() else "")
feignResponse.duration = elapsedTime
if (logLevel!!.ordinal >= Level.HEADERS.ordinal) {
for (field in response.headers().keys) {
for (value in valuesOrEmpty(response.headers(), field)) {
feignResponse.addHeader(field, value)
}
}
if (response.body() != null && !(status == 204 || status == 205)) {
val bodyData: ByteArray = toByteArray(response.body().asInputStream())
if (logLevel.ordinal >= Level.FULL.ordinal && bodyData.isNotEmpty()) {
feignResponse.body = decodeOrDefault(bodyData, UTF_8, "Binary data")
}
log.trace(feignResponse.toString())
return response.toBuilder().body(bodyData).build()
} else {
log.trace(feignResponse.toString())
}
}
return response
}
override fun log(p0: String?, p1: String?, vararg p2: Any?) {}
}
class FeignResponse {
var status = 0
var reason: String? = null
var duration: Long = 0
private val headers: MutableList<String> = mutableListOf()
var body: String? = null
fun addHeader(key: String?, value: String?) {
headers.add("$key: $value")
}
override fun toString() =
"""{"type":"response","status":"$status","duration":"$duration","headers":$headers,"body":$body,"reason":"$reason"}"""
}
class FeignRequest {
var method: String? = null
var url: String? = null
private val headers: MutableList<String> = mutableListOf()
var body: String? = null
fun addHeader(key: String?, value: String?) {
headers.add("$key: $value")
}
override fun toString() =
"""{"type":"request","method":"$method","url":"$url","headers":$headers,"body":$body}"""
}