DEV Community

Jackson Williams
Jackson Williams

Posted on

Secure Your APIs in 5 Minutes: Token-Based RSocket with JWT

RSocket provides a robust messaging system, built on top of the reactive streaming framework, and supports a variety of protocols including TCP, WebSocket, HTTP 1.1, and HTTP 2. Its programming language-agnostic interaction models, such as REQUEST_RESPONSE, REQUEST_FNF, REQUEST_STREAM, and REQUEST_CHANNEL, cater to diverse communication scenarios, including Microservices, API Gateway, Sidecar Proxy, and Message Queue.

When securing communication, RSocket-based applications can easily adopt TLS-based and Token-based solutions. While RSocket can reuse TLS over TCP or WebSocket, this article focuses on token-based implementation to demonstrate the Role-Based Access Control (RBAC) feature.

As the most widely adopted technology for OAuth2 globally, JSON Web Token (JWT) is an ideal choice due to its programming language-agnostic nature. After thorough research, I firmly believe that combining RSocket with JWT is an excellent approach to implementing secure communication between services, particularly for Open API. For a more detailed guide on securing APIs, visit computerstechnicians.com. Now, let’s delve deeper into the intricacies.

Implementing RSocket for Secure Communication

The primary question is how to utilize tokens for inter-service communication in RSocket.

There are two approaches to transmitting tokens from the requester to the responder. One method involves embedding the token into metadata API during setup, while the other involves sending the token as metadata, accompanied by payload as data, with each request.

Beyond that, routing plays a pivotal role in authorization, indicating the resource on the responder side. In RSocket extensions, there exists a routing metadata extension to extend the four interaction models. If tag payloads are supported by both requester and responder, it’s straightforward to define authorization at the top layer.

Understanding JSON Web Token (JWT)

To grasp this article, understanding the following five aspects of JWT is sufficient.

  1. JWT encompasses JSON Web Signature (JWS), JSON Web Encryption (JWE), JSON Web Key (JWK), and JSON Web Algorithms (JWA).

    1. HS256 is a symmetric-key algorithm, whereas RS256/ES256 is an asymmetric-key algorithm based on Public Key Infrastructure (PKI). Both are defined in the JWA specification. HS256 combines HMAC (Keyed-Hash Message Authentication Code) with SHA-256, while RS256 utilizes RSASSA paired with SHA-256, and ES256 employs ECDSA (Elliptic Curve Digital Signature Algorithm) alongside SHA-256.

    2. In terms of the secret length, assuming the HS256 algorithm is employed for token generation, the secret characters should exceed 32, given that HS256 necessitates a secret of at least 256 bits (considering that 1 character is equivalent to 8 bits).

    3. The Access Token is utilized by the responder for decoding, verification, and authorization purposes, whereas the Refresh Token serves to regenerate tokens, particularly when the Access Token has expired or become invalid.

    4. Proper handling is necessary after the user signs out, especially when the access token remains valid during this period, to prevent unauthorized access.

    Secure Data Exchange

    Now, let’s proceed to the demonstration. We have two types of APIs: token and resource. Access to the resource API is only possible once the token has been verified and authenticated.

    Workflow

    • We employ the signin API to generate tokens for the requester, which requires a username and password. Upon successful authentication, the responder signs, saves, and returns the Access Token and Refresh Token to the requester.
    • The refresh API is used to renew tokens and requires a refresh token. After decoding and authorization, the responder signs, saves, and returns the Access Token and Refresh Token to the requester.

    • We define info/list/hire/fire as resource APIs to demonstrate various read/write actions.

    • The signout API handles cases of stolen tokens, as previously discussed, to prevent unauthorized access.

    Workflow diagram

    Authentication

    Given that we utilize Role-Based Access Control (RBAC) as our authorization mechanism, the authentication component should provide an identity repository (User -Role-Permission) to store and retrieve identity information within the responder, ensuring secure authentication.

    Additionally, we provide a token repository to store, revoke, and read tokens, which is used to verify authentication decoded from the token. Since authorization information is encrypted and compressed within the token, we use repository information to double-check these authorizations. If they match, we can confirm the request’s authenticity and legitimacy.

    Authentication

    API Interaction Model Role
    Sign In Request/Response All Users
    Sign Out Fire-and-Forget Authenticated Users
    Refresh Token Request/Response All Users
    User Information Request/Response User, Administrator
    User List Request/Stream User, Administrator
    Hire User Request/Response Administrator
    Terminate User Request/Response Administrator

    Implementing Spring Boot with Enhanced Security

    Token Signing

    For seamless integration across diverse programming languages, it is crucial to establish a unified standard for the cryptographic algorithm and constants employed in encryption and compression within this demonstration.

    In this implementation, we have selected HS256 as the preferred algorithm, featuring an access token validity period of 5 minutes and a refresh token validity period of 7 days.

    public static final long ACCESS_TOKEN_VALIDITY_PERIOD = 5;
    public static final long REFRESH_TOKEN_VALIDITY_PERIOD = 7;
    private static final MacAlgorithm ENCRYPTION_MECHANISM = MacAlgorithm.HS256;
    private static final String HASHING_ALGORITHM = "HmacSHA256";

    Let's examine the generated access token code:

    public static UserToken generateAccessToken(HelloUser  user) {
        Algorithm ENCRYPTION_TECHNIQUE = Algorithm.HMAC256(ACCESS_SECRET_KEY);
        return generateToken(user, ENCRYPTION_TECHNIQUE, ACCESS_TOKEN_VALIDITY_PERIOD, ChronoUnit.MINUTES);
    }
    
    private static UserToken generateToken(HelloUser  user, Algorithm encryptionTechnique, long expirationTime, ChronoUnit timeUnit) {
        String tokenIdentifier = UUID.randomUUID().toString();
        Instant currentTime = Instant.now();
        Instant expirationTimeInstant;
        if (currentTime.isSupported(timeUnit)) {
            expirationTimeInstant = currentTime.plus(expirationTime, timeUnit);
        } else {
            log.error("unit param is not supported");
            return null;
        }
        String token = JWT.create()
                .withJWTId(tokenIdentifier)
                .withSubject(user.getUserId())
                .withClaim("scope", user.getRole())
                .withExpiresAt(Date.from(expirationTimeInstant))
                .sign(encryptionTechnique);
        return UserToken.builder().tokenId(tokenIdentifier).token(token).user(user).build();
    }

    Important Note:

    The claim key name in the above code is not arbitrarily chosen, as “scope” is utilized in the framework as the default method to decode the role from the token.

    Subsequently, the token decoder code is as follows:

    java public static ReactiveJwtDecoder acquireAccessTokenDecoder() { SecretKeySpec secretKey = new SecretKeySpec(ACCESS_SECRET_KEY.getBytes(), HMAC_SHA_256); return NimbusReactiveJwtDecoder.withSecretKey(secretKey) .messageAuthenticationAlgorithm(MAC_ALGORITHM) .build(); } public static ReactiveJwtDecoder jwtAccessTokenDecoder() { return new HelloJwtDecoder(acquireAccessTokenDecoder()); } // HelloJwtDecoder @Overridepublic Mono<Jwt> decode(String token) throws JwtException { return reactiveJwtDecoder.decode(token).doOnNext(jwt -> { String id = jwt.getId(); HelloUser auth = tokenRepository.retrieveAuthFromAccessToken(id); if (auth == null) { throw new JwtException("Invalid HelloUser "); } //TODO helloJwtService.setTokenId(id); }); }

    The decode method in

    the HelloJwtDecoder will be triggered by the framework during every request handling cycle, to transform the token string value into a jwt:

    @BeanPayloadSocketAcceptorInterceptor authorization(RSocketSecurity rsocketSecurity) {
        RSocketSecurity security = pattern(rsocketSecurity)
                .jwt(jwtSpec -> {
                    try {
                        jwtSpec.authenticationManager(jwtReactiveAuthenticationManager(jwtDecoder()));
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                });
        return security.build();
    }
    
    @Beanpublic ReactiveJwtDecoder jwtDecoder() throws Exception {
        return TokenUtils.jwtAccessTokenDecoder();
    }
    
    @Beanpublic JwtReactiveAuthenticationManager jwtReactiveAuthenticationManager(ReactiveJwtDecoder decoder) {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
        authoritiesConverter.setAuthorityPrefix("ROLE_");
        converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
        JwtReactiveAuthenticationManager manager = new JwtReactiveAuthenticationManager(decoder);
        manager.setJwtAuthenticationConverter(new ReactiveJwtAuthenticationConverterAdapter(converter));
        return manager;
    }

    Revoke Token

    To simplify the environment of the demo running, the way to revoke token implements here by guava cache. You can use some powerful components, like Redis, to do that.

    Once the time is up, the access token will be revoked automatically.

    On the other hand, when the requester sends signout, this cache will be invoked as event-driven.

    Cache<String, HelloUser> accessTokenTable = CacheBuilder.newBuilder()
                .expireAfterWrite(TokenUtils.ACCESS_EXPIRE, TimeUnit.MINUTES).build();
    
    public void deleteAccessToken(String tokenId) {
      accessTokenTable.invalidate(tokenId);
    }

    Verification of Identity

    The authenticate function, designed for signin, operates on the same principles as the HTTP basic authentication mechanism, boasting a remarkable simplicity:

    HelloUser   user = userRepository.retrieve(principal);
    if (user.getPassword().equals(credential)) {
      return user;
    }

    In contrast, the alternative authenticate, tailored for refresh, involves a series of more complex steps:

    • Acquiring a decoder and leveraging it to decode the token string value into a JWT object

    • Implementing a reactive approach to map JWT to authentication

    • Retrieving the authentication details from the repository

    • Validating that the authentication information from the database and token are identical

    • Returning the authentication object in a streaming manner

    return reactiveJwtDecoder.decode(refreshToken).map(jwt -> {
        try {
            HelloUser   user = HelloUser  .builder().userId(jwt.getSubject()).role(jwt.getClaim("scope")).build();
            log.info("Verification successful. User: {}", user);
            HelloUser   auth = tokenRepository.getAuthFromRefreshToken(jwt.getId());
            if (user.equals(auth)) {
                return user;
            }
        } catch (Exception e) {
            log.error("", e);
        }
        return new HelloUser  ();
    });

    Permission Management

    As I mentioned earlier, this demo is built upon Role-Based Access Control (RBAC); the routing is the crucial aspect. For the sake of concision, I will refrain from showcasing the open APIs version, and instead, provide a concise overview:

    // HelloSecurityConfig
    protected RSocketSecurity pattern(RSocketSecurity security) {
        return security.authorizePayload(authorize -> authorize
                .route("signin.v1").permitAll()
                .route("refresh.v1").permitAll()
                .route("signout.v1").authenticated()
                .route("hire.v1").hasRole(ADMIN)
                .route("fire.v1").hasRole(ADMIN)
                .route("info.v1").hasAnyRole(USER, ADMIN)
                .route("list.v1").hasAnyRole(USER, ADMIN)
                .anyRequest().authenticated()
                .anyExchange().permitAll()
        );
    }
    
    // HelloJwtSecurityConfig
    @Configuration@EnableRSocketSecuritypublic class HelloJwtSecurityConfig extends HelloSecurityConfig {
      @Bean  PayloadSocketAcceptorInterceptor authorization(RSocketSecurity rsocketSecurity) {
        RSocketSecurity security = pattern(rsocketSecurity)
        ...

    I put the route-based RBAC defination in parent class to easy to extend the security by using other way, e.g. TLS.

    SpringBoot provides MessageMapping annotation to let us define the route for messaging, which means streaming api in RSocket.

    @MessageMapping("signin.v1")    Mono<HelloToken> signin(HelloUser helloUser) {
        ...

    Prerequisites

    From 2.2.0-Release  onwards, Spring Boot has been incorporating RSocket support. Furthermore, since version 2.3, it has also been providing RSocket security features. As 2.3.0 was not yet generally available when I wrote this article, the version I am showcasing is 2.3.0.M4.

    • spring-boot.version 2.3.0.M4

    • spring.version 5.2.5.RELEASE

    • spring-security.version 5.3.1.RELEASE

    • rsocket.version 1.0.0-RC6

    • reactor-netty.version 0.9.5.RELEASE

    • netty.version 4.1.45.Final

    • reactor-core.version 3.3.3.RELEASE

    • jjwt.version 0.9.1

    Compilation, Execution, and Testing

    bash build.sh
    bash run_responder.sh
    bash run_requester.sh
    bash curl_test.sh

    cURL Test

    echo "Logging in as user"
    read accessToken refreshToken < <(echo $(curl -s "http://localhost:8989/api/signin?u=0000&p=Zero4" | jq -r '.accessToken,.refreshToken'))
    echo "Access Token  :${accessToken}"
    echo -e "Refresh Token :${refreshToken}\\n"
    
    echo "[user] refresh:"
    curl -s "http://localhost:8989/api/refresh/${refreshToken}" | jq
    echo
    
    echo "[user] info:"
    curl "http://localhost:8989/api/info/1"
    echo -e "\\n"
    
    echo "[user] list:"
    curl -s "http://localhost:8989/api/list" | grep data -c
    echo
    
    echo "[user] hire:"
    curl -s "http://localhost:8989/api/hire" \
    -H "Content-Type: application/stream+json;charset=UTF-8" \
    -d '{"id":"18","value":"伏虎羅漢"}' | jq -r ".message"

    Hidden Gem

    The resource API component illustrates the employee lifecycle, from recruitment to termination. For a more comprehensive understanding, please explore Eighteen_Arhats!

    Final Thoughts

    Initially, I had planned to provide a Golang implementation, but unfortunately, the RSocket for Golang lacks an open routing API, rendering it impractical to achieve this. However, a glimmer of hope remains: Jeff will soon be making them accessible.

    I find it intriguing to demonstrate this using alternative languages, such as Rust and NodeJs. Perhaps I’ll even author a series of articles on the subject.

    By the way, the source code for this demo is available on GitHub.

Top comments (0)