I'm using org.springframework.security:spring-security-messaging.
To start with I have a project that is successfully authenticating using oauth...
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ...
audiences:
- ...
@Configuration
@EnableWebSecurity
class SecurityConfig(
@Autowired(required = false) private val requestLoggingFilter: RequestLoggingFilter?
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.oauth2ResourceServer { oauth2 ->
oauth2.jwt { }
}
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/protected/**").authenticated()
.anyRequest().permitAll()
}
return http.build()
}
The basic http JWT functions as expected. So I try to add a web socket example that I can also try...
@Configuration
@EnableWebSocketMessageBroker
class WebsocketConfig : WebSocketMessageBrokerConfigurer {
override fun configureMessageBroker(config: MessageBrokerRegistry) {
config.enableSimpleBroker("/topic")
config.setApplicationDestinationPrefixes("/app")
}
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/gs-guide-websocket")
}
}
@Controller
class WebSocketController {
private val logger = LoggerFactory.getLogger(WebSocketController::class.java)
@MessageMapping("/hello")
@SendTo("/topic/greetings")
fun greeting(message: TestMessage): Greeting {
logger.info(message.toString())
return Greeting("Hello, " + message.name + "!")
}
@MessageMapping("/status")
@SendTo("/topic/chat")
fun status(message: String): String {
return "Status: $message"
}
}
Now I am trying to add security so I add the following to the security config...
@Profile("secure")
@Configuration
@EnableWebSocketSecurity
class WebSocketSecurityConfig {
@Bean
fun messageAuthorizationManager(
messages: MessageMatcherDelegatingAuthorizationManager.Builder
): AuthorizationManager<Message<*>> {
messages
// Next 2 lines are required for requests without auth.
// Remove these if all paths require auth
.simpTypeMatchers(SimpMessageType.CONNECT).permitAll()
.simpTypeMatchers(SimpMessageType.DISCONNECT).permitAll()
.simpDestMatchers("/app/status").permitAll()
.simpDestMatchers("/app/hello").authenticated()
.anyMessage().authenticated()
return messages.build()
}
}
When I try sending the non-protected message like
SEND
destination:/app/status
{"name":"test"}
I see that the message is working and being send, however, when I try to call with something like
SEND
destination:/app/hello
Authorization: Bearer ey...
{"name":"test"}
And I still get
org.springframework.messaging.MessageDeliveryException: Failed to send message to ExecutorSubscribableChannel[clientInboundChannel]
at org.springframework.messaging.support.AbstractMessageChannel.send(AbstractMessageChannel.java:149) ~[spring-messaging-6.2.2.jar:6.2.2]
at org.springframework.messaging.support.AbstractMessageChannel.send(AbstractMessageChannel.java:125) ~[spring-messaging-6.2.2.jar:6.2.2]
at org.springframework.web.socket.messaging.StompSubProtocolHandler.handleMessageFromClient(StompSubProtocolHandler.java:343) ~[spring-websocket-6.2.2.jar:6.2.2]
...
Caused by: org.springframework.security.access.AccessDeniedException: Access Denied
at org.springframework.security.messaging.access.intercept.AuthorizationChannelInterceptor.preSend(AuthorizationChannelInterceptor.java:75) ~[spring-security-messaging-6.4.2.jar:6.4.2]
at org.springframework.messaging.support.AbstractMessageChannel$ChannelInterceptorChain.applyPreSend(AbstractMessageChannel.java:181) ~[spring-messaging-6.2.2.jar:6.2.2]
at org.springframework.messaging.support.AbstractMessageChannel.send(AbstractMessageChannel.java:135) ~[spring-messaging-6.2.2.jar:6.2.2]
... 44 common frames omitted
Same as with no header at all. The same token works fine on a regular web endpoint so what am I missing?
even when I add the following...
logging:
level:
org.springframework.security: DEBUG
org.springframework.messaging: DEBUG
org.springframework.web.socket: DEBUG
org.springframework.security.oauth2: TRACE
I changed the endpoint to ws but not I see
2025-02-02T10:47:16.609-05:00 DEBUG 88460 --- [love-monkey] [nio-7080-exec-3] o.s.w.s.s.s.WebSocketHttpRequestHandler : GET /ws
2025-02-02T10:47:16.641-05:00 DEBUG 88460 --- [love-monkey] [nio-7080-exec-3] s.w.s.h.LoggingWebSocketHandlerDecorator : New StandardWebSocketSession[id=e28c3083-e231-c00d-dc20-2cdb4d01ecac, uri=ws://....net/ws]
2025-02-02T10:47:18.340-05:00 DEBUG 88460 --- [love-monkey] [nio-7080-exec-4] .s.m.a.i.AuthorizationChannelInterceptor : Authorizing message send
2025-02-02T10:47:18.341-05:00 DEBUG 88460 --- [love-monkey] [nio-7080-exec-4] .s.m.a.i.AuthorizationChannelInterceptor : Failed to authorize message with authorization manager org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager@5a4d7ce9 and result AuthorizationDecision [granted=false]
2025-02-02T10:47:18.341-05:00 DEBUG 88460 --- [love-monkey] [nio-7080-exec-4] o.s.w.s.m.StompSubProtocolHandler : Failed to send message to MessageChannel in session e28c3083-e231-c00d-dc20-2cdb4d01ecac
org.springframework.messaging.MessageDeliveryException: Failed to send message to ExecutorSubscribableChannel[clientInboundChannel]
Also here is an example project that has the same issue if you want to play
To help debug
Let's try putting code on top of this repo from OP and creating a test environment that uses fake bearer tokens.
CustomCsrfChannelInterceptor & WebSocketSecurityConfig
First make sure you have a custom csrf interceptor to avoid confusion. This was kind of tricky because csrf-header cannot be turned off, but I found a hack in this answer. Not for production use.
// https://stackoverflow.com/a/77057255/14072498
@Component("csrfChannelInterceptor")
class CustomCsrfChannelInterceptor : ChannelInterceptor {
override fun preSend(
message: Message<*>,
channel: MessageChannel
): Message<*> {
return message
}
}
@Configuration
@EnableWebSocketSecurity
class WebSocketSecurityConfig {
@Bean
fun messageAuthorizationManager(
messages: MessageMatcherDelegatingAuthorizationManager.Builder
): AuthorizationManager<Message<*>> {
messages
// Next 2 lines are required for requests without auth.
// Remove these if all paths require auth
.simpTypeMatchers(SimpMessageType.CONNECT).permitAll()
.simpTypeMatchers(SimpMessageType.DISCONNECT).permitAll()
.simpDestMatchers("/app/status").permitAll()
.simpDestMatchers("/app/hello").authenticated()
.anyMessage().authenticated()
return messages.build()
}
}
Also make a custom JWTDecoder to avoid having to use a real one. You can make this a TestConfig or a real config with a profile if you want to test in a dev environment, I chose the later.
@Configuration
@Profile("fake-jwt")
class FakeJwtConfig {
@Bean
fun jwtDecoder(): JwtDecoder {
return JwtDecoder { token ->
if (token != "test.token") {
throw JwtException("Invalid token")
}
Jwt.withTokenValue(token)
.header("alg", "none")
.claim("sub", "user")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.build()
}
}
}
WebSocketController
Let's log the Authentication
in the controller
@Controller
class WebSocketController {
private val logger = LoggerFactory.getLogger(WebSocketController::class.java)
@MessageMapping("/hello")
@SendTo("/topic/greetings")
fun greeting(message: TestMessage): Greeting {
logger.info(message.toString())
return Greeting("Hello, " + message.name + "!")
}
@MessageMapping("/status")
@SendTo("/topic/chat")
fun status(message: String): String {
return "Status: $message"
}
}
The Test
To ensure the issue isn't with Postman (since that can be complicated) lets create a few tests to make sure everything is working correctly
The test should pass showing that everything is working.