Implementing Secure RSocket services with Spring Boot
Mario Gray
This guide will discuss RSocket service security with Spring Boot, by way of Spring Security. We will surface RSocket routes that enforce specific security measures and describe what this means internally. This guide will inform you of the additional configuration options provided when configuring for Spring Security on a Spring Boot 2.7.x/RSocket application.
It is assumed the developer knows Kotlin, uses Spring Boot, and has an understanding of the Reactive Streams on the JVM. If you’re new to Spring Security for Reactive streams, then this guide should help shed light on the subject. Of course, the best place to understand are the reference docs, so please read them!
Authorization vs Authentication
Authentication is the process which lets our apps identify a user. Authentication systems are methods which describe a secure process of identification. For example you’re familiar with multi factor authentication which uses username and password followed by a side-channel delivered code (via text message, Email, etc..). You might also have used SSO (Single Sign-n) which aggregates multiple backends that coordinate a single authentication session for the user. At the end of the day, they still rely on some form of user input - maybe a face, a fingerprint, or plain old username and password.
This example will focus on username and password authentication to illustrate the mechanism underneath which are similar regardless of authentication method.
Authorization (access control) is the process which lets your application determine how access is granted to users. This begins to sound straight forward, but can be surfaced in our application in a number of ways. OAuth is popular since it allows an application share user-rights with an unrelated system. On top of OAuth is usually Role Based Access Control - in which a user may have granted privileges given by role ’names’ - e.g. ‘WRITE’, ‘READ’ for a given resource. Additionally, RBAC relies on the application to make these decisions as you will see later in this guide.
The Application
The Example app is an RSocket service we will use to test authentication and authorization.
The service interface is as follows:
interface TreeService {
fun shakeForLeaf(): Mono<String>
companion object {
val LEAF_COLORS = listOf("Green", "Yellow", "Orange", "Brown", "Red")
}
}
Above, we have a function that returns a Mono<String>
of colors.
We can then write the backing implementation:
class TreeServiceImpl : TreeService {
override fun shakeForLeaf(): Mono<String> = Mono.just(LEAF_COLORS.get(Random.nextInt(LEAF_COLORS.size)))
}
Subclass the service interface to create the RSocket controller using @MessageMapping:
interface TreeControllerMapping : TreeService {
@MessageMapping("shake")
override fun shakeForLeaf(): Mono<String>
@MessageMapping("status")
fun status(@AuthenticationPrincipal user: Mono<UserDetails>): Mono<String> =
user.hasElement().map (Boolean::toString)
}
The open ‘status’ route contains an argument decorated with @AuthenticationPrincipal to inject - if available - the current logged in user.
Next, subclass our service once more and apply Spring Security annotations. Use @PreAuthorize, which is the preferred way for securing reactive streams through annotation.
interface TreeServiceSecurity : TreeService {
@PreAuthorize("hasRole('SHAKE')")
override fun shakeForLeaf(): Mono<String>
}
Finally, we can put the whole thing together and expose it as an RSocket server with help from Spring Boot!
Configuring the App
The production application will turn on various subsystems for Spring Security to work with Reactive streams, and RSocket services. We also expose the service using @Controller
by composing security, message-mapping routes, and backing service implementation. The listing below shows this configuration:
@EnableReactiveMethodSecurity // 1
@EnableRSocketSecurity // 2
@SpringBootApplication
class App {
// ...
@Controller
class ServerTreeController : TreeControllerMapping,
TreeServiceSecurity, TreeService by TreeServiceImpl() // 3
// ...
companion object {
@JvmStatic
fun main(args: Array<String>) {
runApplication<App>(*args)
}
}
}
Here are the steps in enabling Reactive method, and RSocket security:
- Enable the RSocketSecurity bean by decorating a configuration class with @EnableRSocketSecurity. What this does is as stated in documentation - it allows configuring RSocket based security.
- Enable security-specific annotations on Reactive Streams (return types of Publisher) with the @EnableReactiveMethodSecurity annotation to the main configuration class.
- The RSocket messaging controller is fully configured here.
Review of User Management
Spring Security provides concrete User objects that implement the UserDetails interface. The User.UserBuilder object provides a fluent builder for describing instances of User
.
We will also need to reactively read/write users to some kind of storage. This activity is exposed for Reactive services through ReactiveUserDetailsService. The easiest way to use this is by creating an instance of the in-memory MapReactiveUserDetailService.
To review, we can completely populate a ReactiveUserDetailService
in our production app:
class App {
// ...
@Bean
open fun userDetailService(): ReactiveUserDetailsService =
MapReactiveUserDetailsService(
User.builder()
.username("plumber")
.password("{noop}nopassword") // 1
.roles("SHAKE")
.build(),
User.builder()
.username("gardner")
.password("{noop}superuser")
.roles("RAKE", "LOGIN")
.build()
)
}
The above code sample reads well, but there is some nuance with the password specification:
- The builder supports the algorithm hint using curly braces. Here we specify
noop
(plaintext) password encoding. In the background, Spring Security uses an DelegatingPasswordEncoder to determine the proper encoder to use such as pbkdf2, scrypt, sha256, etc…
WARNING: Please do not use plaintext
{noop}
in production! A good write-up describing the evolution of password storage and hints for what you can do to prevent tampering can be found in the Spring Security docs
About User Resolution
Spring Security uses a ReactiveSecurityContextHolder to manage a SecurityContext in Reactor’s Context. Spring Security (or the developer) can then use a AuthenticationPrincipalArgumentResolver by way of an RSocketMessageHandler to access this context, and resolve the logged in user at method level.
There is a nice to know informal example that describes how one would resolve a custom User
object with the AuthenticationPrincipalArgumentResolver docs.
Review of RSocket Server Security
By using @EnableRSocketSecurity
, we gain RSocket security through Payload Interceptors. Interceptors themselves are cross-cutting, and Spring Security uses them to work on processing at various parts of an interaction such as:
- Transport level
- At the level of accepting new connections
- Performing requests
- Responding to requests
Since a payload can have many metadata formats to confer credential exchange, Spring’s RSocketSecurity bean provides a fluent builder for configuring Simple, Basic, JWT, and custom authentication methods, in addition to RBAC authorization.
The RSocketSecurity
provided builder will describe a set of AuthenticationPayloadInterceptors that converts payload metadata into an Authentication instances inside the SecurityContext
.
To further our understanding of the configuration, lets examine the SecuritySocketAcceptorInterceptorConfiguration class, which sets up the default security configuration for RSocket.
This class, imported by @EnableRSocketSecurty
, will configure a PayloadSocketAcceptorInterceptor for Simple and basic authentications. The PayloadExchangeMatchers describe which exchanges require authentication:
package org.springframework.security.config.annotation.rsocket;
class SecuritySocketAcceptorInterceptorConfiguration {
//...
private PayloadSocketAcceptorInterceptor defaultInterceptor(ObjectProvider<RSocketSecurity> rsocketSecurity) {
rsocket.basicAuthentication(Customizer.withDefaults()) // 1
.simpleAuthentication(Customizer.withDefaults()) // 2
.authorizePayload((authz) -> authz // 3
.setup().authenticated() // 4
.anyRequest().authenticated() // 5
.matcher((e) -> MatchResult.match()).permitAll() // 6
);
//...
}
The authorizePayload
method decides how we can apply authorization at connection setup
and request
exchanges. The configuration above include:
- Basic credential passing for backwards compatibility; this is deprecated in favor of #2
- Simple credential passing is supported by default; this is the winning spec and supersedes Basic.
- Access control rules that specifies which exchanges must be authenticated before being granted access to the server.
- Builds a PayloadExchangeMatcher to ensures that
SETUP
exchanges require authentication metadata. - Builds another PayloadExchangeMatcher for
request
exchanges requiring authentication. - This custom matcher matches everything, and permits access.
Request vs Setup: The PayloadExchangeType defines any
request
exchange as one of the following internal RSocket Protocol units of operation; FIRE_AND_FORGET, REQUEST_RESPONSE, REQUEST_STREAM, REQUEST_CHANNEL and METADATA_PUSH. SETUP and PAYLOAD (payload frames can have metadata) units are consideredSETUP
exchanges.
Review of Reactive Method Security
With the usage of @EnableReactiveMethodSecurity
in our main class, we gained the ability to annotate reactive streams with rules for authorization. This happens mainly in the ReactiveAuthorizationManager instances for specific use cases. Out of the box, we get the support for a variety of expressions with @PreAuthorize to introspect the authenticated user for necessary privileges. There are a variety of built-in expressions that we can use.
Here are built-in expressions supported as defined in SecurityExpressionOperations and described in the Docs:
Expression | Description |
---|---|
hasRole(role: String) | Returns true if the current principal has the specified role. |
For example, hasRole(‘admin’) By default if the supplied role does not start with ‘ROLE_’ it will be added. This can be customized by modifying the defaultRolePrefix on DefaultWebSecurityExpressionHandler. | |
hasAnyRole(vararg roles: String) | Returns true if the current principal has any of the supplied roles (given as a comma-separated list of strings) |
hasAuthority(authority: String) | Returns true if the current principal has the specified authority. For example, hasAuthority('read') |
hasAnyAuthority(vararg authorities: String) | Returns true if the current principal has any of the supplied authorities (given as a comma-separated list of strings) For example, hasAnyAuthority('read', 'write') |
principal | Allows direct access to the principal object representing the current user |
authentication | Allows direct access to the current Authentication object obtained from the SecurityContext |
permitAll | Always evaluates to true |
denyAll | Always evaluates to false |
isAnonymous() | Returns true if the current principal is an anonymous user |
isRememberMe() | Returns true if the current principal is a remember-me user |
isAuthenticated() | Returns true if the user is not anonymous |
isFullyAuthenticated() | Returns true if the user is not an anonymous or a remember-me user |
hasPermission(target: Any, permission: Any) | Returns true if the user has access to the provided target for the given permission. For example, hasPermission(domainObject, 'read') |
hasPermission(targetId: Any, targetType: String, permission: Any) | Returns true if the user has access to the provided target for the given permission. For example, hasPermission(1, 'com.example.domain.Message', 'read' ) |
To gain fundamental understanding of the underpinnings of Authorization, I encourage you to read the Spring Docs. This documentation is robust and does well in describing exactly how Authorization operates under the hood - especially for situations where you have legacy framework code and want to customize.
CURRENTLY: For custom expressions, Spring Security supports return values of
boolean
and cannot be wrapped in deferred values such as a reactivePublisher
. As such, the expressions must not block.
Security at the Client
Spring RSocket creates a RSocketRequesterBuilder bean at startup. This bean provides a builder for creating new RSocketRequesters. An RSocketRequester
provides a single connection interface to RSocket operations usually across a network.
RSocket Security can be applied at the setup
and or request
levels. We will discuss both methods next.
Authentication Styles on the Client
We can secure the entire RSocket connection by sending metadata in the SETUP frame. The RSocketRequester.Builder
builder lets us specify setupMetadata
that contains authentication metadata.
Our custom RequestFactory
class makes it so we don’t repeat the connection builder every time a requester is needed. We either need an authenticated connection or a non-authenticated connection. We will create the authenticating Requester below:
open class RequesterFactory(private val port: String) {
companion object {
val SIMPLE_AUTH = MimeTypeUtils.parseMimeType(WellKnownMimeType.MESSAGE_RSOCKET_AUTHENTICATION.string) // 1
}
open fun authenticatedRequester(username: String, password: String): RSocketRequester =
RSocketRequester
.builder()
.rsocketStrategies { strategiesBuilder ->
strategiesBuilder.encoder(SimpleAuthenticationEncoder())
} // 2
.setupMetadata(UsernamePasswordMetadata(username, password), SIMPLE_AUTH) //3
.connectTcp("localhost", port.toInt())
.block()!!
//..
}
The lines of code we want to inspect here relate to the specifics for setup
frame authentication metadata:
- Requester needs to know how to encode our
Simple
authentication metadata. - Which needs to be registered as an encoder in Spring’s RSocketStrategies.
- Then use
setupMetadata
to encode credentials going into the setup frame.
Next, we need a non-authenticated requester:
open fun requester(): RSocketRequester =
RSocketRequester
.builder()
.rsocketStrategies { strategiesBuilder ->
strategiesBuilder.encoder(SimpleAuthenticationEncoder())
}
.connectTcp("localhost", port.toInt())
.block()!!
We need to keep the strategy encoder for Simple
authentication so that we can still send authenticated requests at request time. Other than that, nothing else is different.
Next, we can create some tests to demonstrate connectivity and test whether our configuration is valid.
Testing the Client and Server
Lets produce some integration tests. We want to standup the RSocketServer on it’s network port, then send real authenticated frames over the wire. We will also know whether authenticated connections are acting secure by ensuring proper rejection of an unauthenticated setup
frame.
@SpringBootTest // 1
class RequesterFactoryTests {
@Test
fun `no setup authentication is REJECTEDSETUP`(@Autowired requesterFactory: RequesterFactory) {
val requester = requesterFactory.requester() // 2
val request = requester
.route("status")
.retrieveMono<String>() // 3
StepVerifier
.create(request)
.verifyError(RejectedSetupException::class.java) //4
}
Lets inspect what this test means:
- Using
@SpringBootTest
ensures we get full autowiring of our production code to configure the RSocket server. - Create a requester that omits authentication metadata in the
setup
frame. - The test site is simple and merely sends a request to the
status
route that returns whether we are authenticated or not. - The ‘status’ route is not locked down, but our server configuration states that connection setup must be authenticated. Thus, we will expect a RejectedSetupExeption error upon request.
Next, we will test when we send authenticated requests without sending the authentication setup
frame:
@Test
fun `sends credential metadata in request is REJECTEDSETUP`(@Autowired requesterFactory: RequesterFactory) {
val requester = requesterFactory.requester()
val request = requester
.route("status")
.metadata(UsernamePasswordMetadata("shaker", "nopassword"), RequesterFactory.SIMPLE_AUTH) // 1
.retrieveMono<String>()
StepVerifier
.create(request)
.verifyError(RejectedSetupException::class.java) // 2
}
This test case is very similar to the previous one except:
- We only authenticate the request alone with
Simple
authentication. - This still wont work, and will result with RejectedSetupException since our server expects authentication in the
setup
frame.
Authorization Tests
Next, we will perform proper setup
, and test for route authorization. Recall earlier we have a TreeServiceSecurity
class that adds @PreAuthorize
to our service methods. Lets test this with a User of insufficient privilege:
@Test
fun `underprivileged shake request is APPLICATIONERROR Denied`(@Autowired requesterFactory: RequesterFactory) {
val request = requesterFactory.requester("raker", "nopassword")
.route("shake")
.retrieveMono<String>()
StepVerifier
.create(request)
.verifyError(ApplicationErrorException::class.java)
}
This test will:
- Create the authenticated requester. Remember; this sends authentication in the
setup
frame. Thus, requests will be allowed. - Send a request to the ‘shake’ route. This service method is
@PreAuthorized
protected for users having ‘SHAKE’ role. - Since we don’t have this kind of permission for the ‘raker’ user, we will get ApplicationErrorException with the message ‘Denied’.
NOTE: To ensure safer communication while using
Simple
authentication, you might apply TLS security across the transport. This way, no one can snoop the network for unencrypted authentication payloads.
Method Security Tests
We can get better test unit isolation by removing or ignoring the RSocketServer, and issuing requests directly to the service instance. This can be done using a compliment of method testing supports provided out of the box by Spring Security.
For example, we want to test that authorization on the shakeForLeaves()
service method which requires Users with the ‘shake’ role. We can actually mock a user in tests by decorating our test method using the @WithMockUser annotation:
@SpringBootTest
class MethodSecurityTests {
@Test
@WithMockUser("testuser", roles = ["SHAKE"]) //1
fun `should return success calling shake with given mockUser`(@Autowired svc: TreeService) {
StepVerifier
.create(svc.shakeForLeaf())
.assertNext {
Assertions
.assertThat(it)
.isNotNull
.containsAnyOf(*TreeService.LEAF_COLORS.toTypedArray())
}
}
We can also test with users populated from our own ReactiveUserDetailsService
with help from the @WithUserDetails annotation as follows:
@Test
@WithUserDetails("shaker")
fun `should return success calling shake with given withUserDetails`(@Autowired svc: TreeService) {
StepVerifier
.create(svc.shakeForLeaf())
.assertNext {
Assertions
.assertThat(it)
.isNotNull
.containsAnyOf(*TreeService.LEAF_COLORS.toTypedArray())
}
.verifyComplete()
}
There is more to testing secure methods in reactive environments. To learn more about Spring Security test support, check out the docs which give detailed explanation and examples for the above mentioned supports and more!
Closing and Next Steps
This guide introduced you to Spring Boot and Spring Security with RSocket. One key take-away, that Spring Security configuration can allow Simple
or other authentication schemes such as JWT and Kerberos. Understanding how permissions work out of the box in Spring Security, and applying authorization to Reactive Methods helps when custom logic is needed. Furthermore, it is safer to use some form of transport security when implementing simple
RSocket security because authentication frames are transmitted plaintext. We will discuss securing RSocket connections with TLS in another guide.
Then next step on this topic will take advantage of Spring Security’s JWT interface. For in-depth implementation details on that topic now, please see the Spring Security Samples project on Github.
Informational and Learning Material
Ben Wilcock’s Getting Started to RSocket Security
Going coroutine with Reactive Spring Boot
Building WebApps with Spring-Boot and Kotlin
XACML for when RBAC is not enough