Pure JWT Authentication - Spring Boot 3.4.x

Preface

The content of this article will include some critical and snarky comments about the ever-changing nature of the Spring Security framework, which, throughout the years, has shown a tendency to apply different philosophies to tackle security problems with varying degrees of success. Still, I appreciate (like many other developers) all the people’s work that went into creating one of the most recognizable and complete tools in the programming world.

The length of this article does not equal the complexity of the content, so don’t be scared. Also, some errors might have crept in because of the amount of text/code. Post a comment or PM me, and I will fix it ASAP.


Prerequisites

  • Installed JDK 21 & Maven 3.

  • Spring framework basic knowledge:

    • Bean Lifecycle

    • Controller

    • Persistence (Hibernate & JPA)

    • Security

  • Lombok library basic knowledge.


Introduction

Motivation

I wanted to implement JWT authentication by leveraging Spring auto-configuration (after all, that’s why we use frameworks) while striking the right balance between the amount of code and its readability.

Unfortunately, some features get deprecated every half a year because of Spring Security’s nature, while new APIs are introduced. As a result, neither ChatGPT nor StackOverflow will be able to provide you with a good example of how to implement up-to-date JWT Authentication—it needs to be done old-school by going through Spring documentation or Javadocs (and sometimes even source code…).

Of course, some things you’ll see here will be up for debate. For example, it’s my personal preference to restrict access to endpoints by applying method security (fine-grained, annotation-based AOP approach) without global web security interfering (which authorization methodology reminds me of XML days…).

Also, I’m looking for a job and wanted to exhibit my reasoning skills, which I hope you will find more worthy than just using AI-based tools.

Specification Requirements

  • No custom filters.

  • Fine-grained control for each endpoint by leveraging method security.

  • Fine-tuned method security AOP pointcuts only targeting controllers without degrading the performance of the whole application.

  • Seamless integration with authorization Authorities functionality.

  • Custom-derived security annotations for better readability.

  • No deprecated functionality.

  • No external security libraries (only Spring Boot starters).

  • Deny all requests by default (as recommended by OWASP), unless explicitly allowed (using method security annotations).

  • Stateful Refresh Token (eligible for revocation) & Stateless Access Token.

  • Efficient access token generation based on the data projections.

Considerations

  • This article is intended to be read in order. Skippable sections are marked as Optional.

  • I will explain every line of code in each section and include the associated source file at the end. So be strong and bear with me until my thought process is finished.

  • The authority roles used in this article (USER, ADMIN) are not treated as hierarchical, so ADMIN won’t automatically assume USER. Still, I’ll present and reference the appropriate solution in the Spring documentation so you can implement it independently.

  • We’ll use OAuth 2 starters (for JWT-related tools), but we won’t try to adhere to any particular OAuth 2 flow. Our implementation will focus strictly on a basic, well-known refresh and access token mechanism (for which I additionally include security recommendations for SPA) that you can use as a basis for your future customization if needed.

  • When providing whole file sources, some imports relate strictly to how I structure my project. Still, I include them for you so there is no confusion about which method/class/annotation comes from Spring packages and which from my application packages.

  • I use Lombok val extensively - hate me all you want.

  • We’re going to work on an example application named Gamingor.

Expected Result

  • To give you some motivation beforehand, this is what we are trying to achieve:

    • Endpoints secured with custom security annotations. Easy to reason about, don’t you think?

        @PostMapping("/login")
        @ResponseStatus(HttpStatus.OK)
        @PermitAll
        public JwtTokenResponse login(@Valid @RequestBody UserLoginRequest request) {
            return this.adapter.generateRefreshTokenThroughApi(request);
        }
      
        @PostMapping("/token")
        @ResponseStatus(HttpStatus.OK)
        @PermitOnlyAuthenticated
        public JwtTokenResponse token(JwtAuthenticationToken refreshToken) {
            return this.adapter.generateAccessTokenThroughApi(refreshToken);
        }
      
        @GetMapping("/admin")
        @ResponseStatus(HttpStatus.ACCEPTED)
        @PermitAuthenticatedWithRole(Role.Const.ADMIN)
        public void adminTokenStub(JwtAuthenticationToken accessToken) {
            log.info(
                    "Token with {} role test was successful for user {}.",
                    Role.ADMIN, accessToken.getName()
            );
        }
      
    • Valid Refresh Token body after unwrapping.

        // Header
        {
          "alg": "HS256"
        }
      
        // Payload
        {
         // Username (Subject)
          "sub": "userTest101",
          // 30 Days Expiration
          "iat": 1744454064,
          "exp": 1747046064,
          // Refresh Token ID (Persisted In DB)
          "jti": "f1a9e126-47fd-4c98-b5b9-c0fe6926b15b"
        }
      
        // Computed Signature (HS256)
      
    • Valid Access Token body (with assigned ADMIN role) after unwrapping.

        // Header
        {
          "alg": "HS256"
        }
      
        // Payload
        {
          // Username (Subject)
          "sub": "userTest101",
          // 1 Hour Expiration
          "iat": 1744454297,
          "exp": 1744457897,
          // Granted Authorities (Validated By Endpoint Authorization)
          "scope": "ADMIN"    
        }
      
        // Computed Signature (HS256)
      
💡
You might have noticed that we do not use all recognizable JWT claims (RFC 7519). The reason is simple—we do not need them. Issuer is our single app backend. We won’t enforce any single AudienceNot Before is unnecessary because JWT becomes usable at token creation.
💡
We only use one JWT OAuth 2 extension (RFC 9068) related field scope to store granted authorities.

Spring (Web) Security Refresher [Optional]

  • If I were lazy, I would have given you these links regarding overview, authentication, and authorization, and called it a day (still, documentation is nice—check it out someday).

  • But if you want a more straightforward, bare-bones explanation that gonna help us to reason about the request processing (because your ticket has only 5 points and I’m wasting your time):

    • Authentication - an object consisting of the authenticated user information:

      • Username

      • Password (most often not, because it gets cleared for obvious security reasons)

      • Granted Authorities

    • Granted Authority:

      • Either permission (fine-grained) or role (wider).

      • How Spring interprets this distinction (with method security annotations) is rather funny. For example, to make permission read recognised as a role, you add the ROLE_ prefix, so you’ll end up with ROLE_read.

      • If you want to create an abomination of a system in a company that you hate working for, you can use USER as a permission and ROLE_read as a role, but let’s be professional…

    • Security Context Holder & Security Context - unique objects valid for a lifetime of request processing (more generally, for a lifetime of thread lifecycle—in this case, a servlet). Security Context holds the Authentication object instantiated by filters for later use by our application (at least in our case; the Authentication object can also be instantiated manually).

    • Filters - they sit between the HTTP request and controller endpoint (request → filters → endpoint) and act as intelligent guards that:

      1. Decode your request - deny if the request is malformed.

      2. Look for credentials in your request—they know where to look based on the authentication method you’ve chosen in web security configuration (JWT, basic username and password, etc.).

      3. Validate credentials either in place (like with JWT by using secret key) or by asking the database (like with basic username & password):

        • If the credentials are correct, create an Authentication object populated with user authentication data, put this object in a security context, and proceed.

        • If the credentials are missing, do not create the Authentication object and proceed.

        • If incorrect credentials are provided, deny the request and dispatch to the appropriate exception handler.

      4. And other funky things that we do not need to know for the sake of our implementation…

    • Request-level Authorization:

      • Applied globally.

      • Authorization applies only to HTTP requests processing endpoints (controllers).

      • Enabled by using web security configuration (@EnableWebSecurity & @Configuration).

      • You can override chosen parts of it to adjust to your needs.

    • Method-level Authorization:

      • Applied to every annotated method or all methods in an annotated class.

      • Does not care about class type as long as the security context is accessible for authorization evaluation (by encompassing Granted Authorities).

      • Enabled by using method security configuration (@EnableMethodSecurity & @Configuration).

      • You can override chosen parts of it to adjust to your needs.

Web Request Security Flow

                       +-------------------------+
                       |       HTTP Request      |
                       +-----------+-------------+
                                   |
                                   v
                              +----+----+
                              | Filters |
                              +----+----+
                                   |
                                   v
               +-------------------+------------------------+             
               |  1. Decode & reject if malformed           |
               +-------------------|------------------------+
               |  2. Extract credentials (JWT, Basic, etc.) |
               +-------------------+------------------------+
                                   |
                                   v
                            +------+-------+
                            | Credentials? |
                            +------+-------+
                                   |              
                         ┌─────────┴────────────┐        
                         |                      |        
                         v                      v        
                        (No)                  (Yes)     
                         |                      |        
                         v                      v        
                +--------+--------+    +--------+--------+  
                |   Anonymous     |    |     Validate    | 
                +--------+--------+    +--------+--------+  
                         |                      |        
                         |                      v        
                         |           ┌──────────┴───────────────────┐  
                         |           |                              |  
                         |           v                              v  
                         |     +-----+-----+   +--------------------+----------------------+
                         |     |   Deny    |   | 1. Create Authentication object           |
                         |     |  request  |   +--------------------|----------------------+
                         |     +-----------+   | 2. Set Authentication in Security Context |
                         |                     +--------------------+----------------------+         
                         |                                          |
                         |                                          |
                         |                                          |
                         |                                          |
                         |                                          |
                         └──────────────────────────────────────────┘
                                              |
                                              v
                 +----------------------------+----------------------------------+
                 |    Evaluate Granted Authorities from Authentication object    | 
                 | based on chosen authorization mechanism (web/method security) |
                 |      and allowed Granted Authorities for given endpoint       | 
                 +---------------------------------------------------------------+
                                              |
                                              v
                                  +-----------+-----------+
                                  | Controller / Endpoint |
                                  +-----------------------+

Handling JWT in a Single Page Application (SPA) [Optional]

  • I thought it would be beneficial to touch upon the topic of JWT handling in the context of SPA, because I predict it will be the case for 90% of people reading this article.

Be careful with cookies

  • In SPA, we tend to loosen web browser restrictions as much as possible (SameSite, CORS, etc.) for our development convenience.

  • Unfortunately, it comes with a cost. These different functionalities were put in place to help adjust security on a case-by-case basis. By omitting each one of them without a second thought, we are slowly compromising the safety of our app flow.

  • Incorrect cookie management might result in one of the most dangerous vulnerabilities - CSRF.

  • It would require another article to analyze all associated risks, but here are some good resources to begin with:

  • I often see this recommendation: keep the refresh token in the HttpOnly cookie. The attacker’s XSS-embedded .js script won’t be able to access the token, so you are safe.

  • The point of the XSS attack is not only to get a token, but also to fetch/change data on behalf of the user by using the injected script.

  • If we look at it from this perspective, we’ll get what you would call XSS-assisted request forgery.

  • Consider this simple attack flow:

    1. Attacker injects a malicious .js script into the website.

    2. The user visits this website and logs in.

    3. The server generates a refresh token and sets it in an HttpOnly cookie.

    4. The browser gets the response and sets a token cookie. This cookie won’t be accessible from JavaScript but will be attached to every future request (to the server-associated domain, which sets a cookie).

    5. The malicious script sends a request to the access token endpoint, and the refresh token HttpOnly cookie is automatically attached.

    6. The server returns an access token in a response.

    7. A malicious script can have fun and do what it wants.

  • Even if the server had returned an access token in the HttpOnly cookie, this token would still be attached to every future request, authorizing the malicious script actions. It shares the same flaw as the refresh token.

  • It’s going to ensure a narrower exposure window.

  • If a JavaScript-accessible token is present, the attacker could send it to the server he/she control.

  • However, in the case of an HttpOnly stored token, the attacker can only use a refresh token as long as a vulnerability (XSS) exists.

  • Now, it may seem that your users become safe after pushing a new version of the patched website.

  • In this case, there is a silent assumption that users will refresh the page right away, but you can’t be sure—it’s an SPA, and the user may work for another couple of days (or even more) on the same, locally stored page version.

  • Still, users are more likely to refresh the page daily, which would be much shorter than a refresh token held by a malicious actor for weeks.

  • I think it’s safe to say that it’s going to mitigate XSS at least partially.

So what should I do?

  • You have three possible outcomes:

    • Correctly configured cookie-based authorization (WIN):

      • No CSRF

      • Partial mitigation of XSS (no token extraction)

    • Misconfigured cookie-based authorization (LOSS):

      • CSRF (which you are not aware of)

      • Partial mitigation of XSS (no token extraction)

    • Authorization flow without cookies (DRAW):

      • No CSRF

      • No mitigation of XSS (you are aware of it)

  • Using non-cookie-based authorization won’t cause XSS; it will just not help mitigate it after the script has already been injected.

  • On the other hand, misconfigured cookie-based authorization will cause CSRF.

  • Asses if you are feeling comfortable with cookies being part of your authorization flow:

    • If no, then:

      • Refresh token in local storage.

      • Access token in session storage.

      • Tokens are injected in the authorization header (bearer ).

    • If yes, then:

      • Refresh token in HttpOnly cookie.

      • Access token ideally in HttpOnly cookie.

      • CSRF (non-HttpOnly) token.

      • Implemented custom logic on the backend for extracting and validating JWT tokens from cookies.

  • Treat SPA as a full-fledged application:

    1. Use some observability mechanism inside your SPA to watch for critical updates. It should take into consideration two scenarios:

      • Watch for updates when the website is in use.

      • When the website is being opened again, immediately check for a new update (it’s for replacing the locally retrieved, non-refreshed copy).

    2. Refresh the website if you get a notification about a critical update.

    3. Ensure that any CDN or browser does not cache a pushed website critical update.

    4. Wait some time (which you must asses for your case) for critical update to propagate.

    5. Revoke all refresh tokens.

  • Still, be aware that an XSS-injected script can override your observability mechanism on the frontend if the attacker is motivated enough to analyse your code in depth. You can try some additional obfuscation techniques, but in the long run, you stand no chance if people can reverse-engineer kernel-level DRMs. Also, due to additional tinkering with the code, you can significantly degrade SPA performance.


Maven Dependencies

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.4</version>
        <relativePath/>
    </parent>

    <groupId>com.ml</groupId>
    <artifactId>gamingor</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>gamingor</name>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <scope>test</scope>
            <version>3.27.3</version>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
            <version>2.19.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </path>
                        <path>
                            <groupId>org.springframework.boot</groupId>
                            <artifactId>spring-boot-configuration-processor</artifactId>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Recommended Security Configuration Folder Structure

gamingor/
├── GamingorApplication.java
└── configuration/
    └── security/
        ├── annotations/
        │   ├── PermitAll.java
        │   ├── PermitAuthenticatedWithRole.java
        │   ├── PermitAuthenticatedWithRoles.java
        │   ├── PermitOnlyAuthenticated.java
        │   ├── PermitOnlyUnauthenticated.java
        ├── MethodSecurityConfig.java
        ├── Role.java
        └── WebSecurityConfig.java

Web Security Configuration

Security Filter Chain

  1. To use method security, we must allow all requests to reach the method (endpoint) level.

    It might look dangerous, but in the end, we’re going to have even better security (deny by default) than what web security config would be able to offer to us.

     .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll())
    
  2. We disable the CSRF token configuration (we won’t use cookie-based authentication).

     .csrf(AbstractHttpConfigurer::disable)
    
  3. We are using JWT, so we should set a stateless session.

     .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    
  4. We enable JWT OAuth 2 auto-configurations, which will instantiate filters responsible for:

    • Validating tokens (expiration, secret).

    • Decoding widely recognized token fields like scope (RFC 9068).

    • Instantiating the JwtAuthenticationToken object (Authentication class inheritor) for later use in our controllers.

    .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
    .exceptionHandling((exceptions) -> exceptions
            .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
            .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
    );

Finished Security Filter Chain

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    ...

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll())
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
                .exceptionHandling((exceptions) -> exceptions
                        .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
                        .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
                );

        return httpSecurity.build();
    }

    ...
}

JWT Secret Key Spec (HS256)

  • We configure the base64-encoded JWT Secret to be provided through an environment variable (JWT_SECRET) and used in our web security configuration during HS256 symmetric encryption and decryption.
# application.yaml
...

# Custom (Non-Spring Default) Properties
security:
  jwt:
    # Base64 Encoded Jwt Secret Environment Variable
    base64.secret: ${JWT_SECRET}
    expiration.seconds:
      # 1 Hour
      access: 3600
      # 30 Days
      refresh: 2592000
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    private final SecretKeySpec secretKeySpec;

    public WebSecurityConfig(@Value("${security.jwt.base64.secret}") String base64SecretKey) {
        this.secretKeySpec = new SecretKeySpec(
                Base64.getDecoder().decode(base64SecretKey), "HmacSHA256"
        );
    }

    ...
}

JWT Encoder

  • Encoder is being used explicitly during our access & refresh token generation implementation.
  1. We must implement a simple decorator pattern that enriches the JWT Encoder with the specified encryption algorithm (HS256). Without it, we will get the error “unable to choose signing key encryption” when encoding.

     @RequiredArgsConstructor
     public static class JwtEncoderHS256 implements JwtEncoder {
         private final JwtEncoder jwtEncoder;
    
         @Override
         public Jwt encode(JwtEncoderParameters parameters) throws JwtEncodingException {
             val algorithmEnrichedParameters = JwtEncoderParameters.from(
                     JwsHeader.with(MacAlgorithm.HS256).build(),
                     parameters.getClaims()
             );
    
             return this.jwtEncoder.encode(algorithmEnrichedParameters);
         }
     }
    
  2. Then we return our wrapped encoder.

     @Bean
     public JwtEncoder jwtEncoder() {
         val jwtEncoder = new NimbusJwtEncoder(new ImmutableSecret<>(this.secretKeySpec));
         return new JwtEncoderHS256(jwtEncoder);
     }
    

Finished JWT Encoder

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    ...

    @Bean
    public JwtEncoder jwtEncoder() {
        val jwtEncoder = new NimbusJwtEncoder(new ImmutableSecret<>(this.secretKeySpec));
        return new JwtEncoderHS256(jwtEncoder);
    }

    @RequiredArgsConstructor
    public static class JwtEncoderHS256 implements JwtEncoder {
        private final JwtEncoder jwtEncoder;

        @Override
        public Jwt encode(JwtEncoderParameters parameters) throws JwtEncodingException {
            val algorithmEnrichedParameters = JwtEncoderParameters.from(
                    JwsHeader.with(MacAlgorithm.HS256).build(),
                    parameters.getClaims()
            );

            return this.jwtEncoder.encode(algorithmEnrichedParameters);
        }
    }

    ...
}

JWT Decoder

  • Decoder is being used implicitly by filters during HTTP request validation and JwtAuthenticationToken object instantiation.
  1. We configure the JWT Decoder to prefix scope roles from the token with ROLE_ string during decoding (it’s gonna be important for our security annotations).

     @Bean
     public JwtAuthenticationConverter jwtAuthenticationConverter() {
         JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
         grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
    
         JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
         jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
         return jwtAuthenticationConverter;
     }
    
  2. Then we return the decoder to be used by filters.

     @Bean
     public JwtDecoder jwtDecoder() {
         return NimbusJwtDecoder
                 .withSecretKey(this.secretKeySpec)
                 .macAlgorithm(MacAlgorithm.HS256).build();
     }
    

Finished JWT Decoder

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    ...

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder
                .withSecretKey(this.secretKeySpec)
                .macAlgorithm(MacAlgorithm.HS256).build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }
}

Whole Web Security Configuration

import com.nimbusds.jose.jwk.source.ImmutableSecret;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.web.SecurityFilterChain;

import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    private final SecretKeySpec secretKeySpec;

    public WebSecurityConfig(@Value("${security.jwt.base64.secret}") String base64SecretKey) {
        this.secretKeySpec = new SecretKeySpec(
                Base64.getDecoder().decode(base64SecretKey), "HmacSHA256"
        );
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
    // CUSTOM SECURITY FILTER CHAIN
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll())
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
                .exceptionHandling((exceptions) -> exceptions
                        .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
                        .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
                );

        return httpSecurity.build();
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
    // JWT ENCODER
    @Bean
    public JwtEncoder jwtEncoder() {
        val jwtEncoder = new NimbusJwtEncoder(new ImmutableSecret<>(this.secretKeySpec));
        return new JwtEncoderHS256(jwtEncoder);
    }

    @RequiredArgsConstructor
    public static class JwtEncoderHS256 implements JwtEncoder {
        private final JwtEncoder jwtEncoder;

        @Override
        public Jwt encode(JwtEncoderParameters parameters) throws JwtEncodingException {
            val algorithmEnrichedParameters = JwtEncoderParameters.from(
                    JwsHeader.with(MacAlgorithm.HS256).build(),
                    parameters.getClaims()
            );

            return this.jwtEncoder.encode(algorithmEnrichedParameters);
        }
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
    // JWT DECODER
    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder
                .withSecretKey(this.secretKeySpec)
                .macAlgorithm(MacAlgorithm.HS256).build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }
}

Method Security Configuration

Custom Authorization Manager (Deny All By Default)

  1. To begin with, we create a helper method to evaluate if an annotated element (method/class) has any security annotations present.

     private boolean doesAnySecurityAnnotationExist(AnnotatedElement annotatedElement) {
         val annotations = MergedAnnotations.from(
                 annotatedElement, MergedAnnotations.SearchStrategy.DIRECT
         );
    
         return annotations.get(PreAuthorize.class).isPresent() ||
                 annotations.get(PreFilter.class).isPresent() ||
                 annotations.get(PostAuthorize.class).isPresent() ||
                 annotations.get(PostFilter.class).isPresent();
     }
    
    💡
    I’m using .isPresent() instead of .isDirectlyPresent(). This will force Spring to consider custom-derived security annotations that we’ll implement later.
    💡
    Do not mistake PreFilter & PostFilter for servlet filters. It’s an entirely different concept related to filtering acquired/returned data based on granted authorities (so it’s not binary Deny or Allow).
  2. At first, we intercept the method that receives data.

     val invokedMethod = invocation.getMethod();
    
  3. Then, we check if the method or class of the method is annotated with a security annotation—if yes, then return and do not interrupt the security evaluation process.

     if (doesAnySecurityAnnotationExist(invokedMethod) ||
             doesAnySecurityAnnotationExist(invokedMethod.getDeclaringClass())) {
         return null;
     }
    
  4. If no security annotation is found, then access will be denied.

     return new AuthorizationDecision(false);
    
  5. We also need to implement a non-default, deprecated interface method…

     @Deprecated
     @Override
     public AuthorizationDecision check(Supplier<Authentication> auth, MethodInvocation invocation) {
         return null;
     }
    

Finished Custom Authorization Manager (Deny All By Default)

@Configuration
@EnableMethodSecurity
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class MethodSecurityConfig {
    ...

    private static class DenyAllByDefaultMethodSecurityAuthorizationManager implements
            AuthorizationManager<MethodInvocation> {
        ...

        @Override
        public AuthorizationResult authorize(Supplier<Authentication> auth, MethodInvocation invocation) {
            val invokedMethod = invocation.getMethod();

            if (doesAnySecurityAnnotationExist(invokedMethod) ||
                    doesAnySecurityAnnotationExist(invokedMethod.getDeclaringClass())) {
                return null;
            }

            return new AuthorizationDecision(false);
        }

        private boolean doesAnySecurityAnnotationExist(AnnotatedElement annotatedElement) {
            val annotations = MergedAnnotations.from(
                    annotatedElement, MergedAnnotations.SearchStrategy.DIRECT
            );

            return annotations.get(PreAuthorize.class).isPresent() ||
                    annotations.get(PreFilter.class).isPresent() ||
                    annotations.get(PostAuthorize.class).isPresent() ||
                    annotations.get(PostFilter.class).isPresent();
        }

        @Deprecated
        @Override
        public AuthorizationDecision check(Supplier<Authentication> auth, MethodInvocation invocation) {
            return null;
        }
    }
}
💡
From Spring documentation: BeanDefinition.ROLE_INFRASTRUCTURE indicates that the bean provides an entirely background role and is irrelevant to the end-user. This hint is used when registering beans that are completely part of the internal workings of an org.springframework.beans.factory.parsing.ComponentDefinition.

Custom Authorization Manager’s Interceptor

  1. We create a static factory method that takes the pointcut pattern and applies it to the authorization logic and evaluation order (because there are more interceptors).

     public static AuthorizationManagerBeforeMethodInterceptor toControllerConfiguredInterceptor(
             String methodPointcutPattern, int order
     ) {...}
    
  2. We filter the pointcut pattern to only apply to the controllers.

     val appControllerPattern = new JdkRegexpMethodPointcut();
     appControllerPattern.setPattern(methodPointcutPattern);
     appControllerPattern.setClassFilter(clazz ->
             hasAnnotation(clazz, Controller.class) || hasAnnotation(clazz, RestController.class)
     );
    
  3. We create an interceptor instance.

     val interceptor = new AuthorizationManagerBeforeMethodInterceptor(
             appControllerPattern, new DenyAllByDefaultMethodSecurityAuthorizationManager()
     );
     interceptor.setOrder(order);
    

Finished Custom Authorization Manager’s Interceptor

@Configuration
@EnableMethodSecurity
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class MethodSecurityConfig {
    ...

    private static class DenyAllByDefaultMethodSecurityAuthorizationManager implements
            AuthorizationManager<MethodInvocation> {

        public static AuthorizationManagerBeforeMethodInterceptor toControllerConfiguredInterceptor(
                String methodPointcutPattern, int order
        ) {
            val appControllerPattern = new JdkRegexpMethodPointcut();
            appControllerPattern.setPattern(methodPointcutPattern);
            appControllerPattern.setClassFilter(clazz ->
                    hasAnnotation(clazz, Controller.class) || hasAnnotation(clazz, RestController.class)
            );

            val interceptor = new AuthorizationManagerBeforeMethodInterceptor(
                    appControllerPattern, new DenyAllByDefaultMethodSecurityAuthorizationManager()
            );
            interceptor.setOrder(order);

            return interceptor;
        }

        ...
    }
}

Custom Authorization Manager’s Interceptor Registration

  1. We configure AOP functionality to apply to our application source package. Of course, this is different for every application, but it comes down to providing the class name in which the main method resides (assuming you adhere to the convention of putting the main class at the top of the package structure).

     private static final String APP_METHOD_POINTCUT_PATTERN = "%s.*".formatted(
                 GamingorApplication.class.getPackage().getName()
     );
    
  2. Then, the custom authorization manager we created is injected and put in order before any security annotation evaluation (our interceptor [1] → preFilter [100] → preAuthorize [200]).

     @Bean
     @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
     Advisor preSecurityAnnotationsProcessing() {
         return DenyAllByDefaultMethodSecurityAuthorizationManager.toControllerConfiguredInterceptor(
                 APP_METHOD_POINTCUT_PATTERN, AuthorizationInterceptorsOrder.FIRST.getOrder()
         );
     }
    
@Configuration
@EnableMethodSecurity
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class MethodSecurityConfig {

    private static final String APP_METHOD_POINTCUT_PATTERN = "%s.*".formatted(
            GamingorApplication.class.getPackage().getName()
    );

    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    Advisor preSecurityAnnotationsProcessing() {
        return DenyAllByDefaultMethodSecurityAuthorizationManager.toControllerConfiguredInterceptor(
                APP_METHOD_POINTCUT_PATTERN, AuthorizationInterceptorsOrder.FIRST.getOrder()
        );
    }

    ...
}

Enabling Custom Security Annotations

  • This cryptic bean will allow annotation processing of our custom security annotations derived from Spring Security.
@Configuration
@EnableMethodSecurity
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class MethodSecurityConfig {
    ...

    @Bean
    static AnnotationTemplateExpressionDefaults templateExpressionDefaults() {
        return new AnnotationTemplateExpressionDefaults();
    }

    ...
}
💡
From the Spring documentation: You expose AnnotationTemplateExpressionDefaults using a static method to ensure that Spring publishes it before it initializes Spring Security’s method security @Configuration classes.

Finished Method Security Configuration

import com.ml.gamingor.GamingorApplication;
import lombok.val;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.aop.Advisor;
import org.springframework.aop.support.JdkRegexpMethodPointcut;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.access.prepost.PreFilter;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.authorization.method.AuthorizationInterceptorsOrder;
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RestController;

import java.lang.reflect.AnnotatedElement;
import java.util.function.Supplier;

import static org.springframework.core.annotation.AnnotatedElementUtils.hasAnnotation;

@Configuration
@EnableMethodSecurity
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class MethodSecurityConfig {

    private static final String APP_METHOD_POINTCUT_PATTERN = "%s.*".formatted(
            GamingorApplication.class.getPackage().getName()
    );

    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    Advisor preSecurityAnnotationsProcessing() {
        return DenyAllByDefaultMethodSecurityAuthorizationManager.toControllerConfiguredInterceptor(
                APP_METHOD_POINTCUT_PATTERN, AuthorizationInterceptorsOrder.FIRST.getOrder()
        );
    }

    @Bean
    static AnnotationTemplateExpressionDefaults templateExpressionDefaults() {
        return new AnnotationTemplateExpressionDefaults();
    }

    private static class DenyAllByDefaultMethodSecurityAuthorizationManager implements
            AuthorizationManager<MethodInvocation> {

        public static AuthorizationManagerBeforeMethodInterceptor toControllerConfiguredInterceptor(
                String methodPointcutPattern, int order
        ) {
            val appControllerPattern = new JdkRegexpMethodPointcut();
            appControllerPattern.setPattern(methodPointcutPattern);
            appControllerPattern.setClassFilter(clazz ->
                    hasAnnotation(clazz, Controller.class) || hasAnnotation(clazz, RestController.class)
            );

            val interceptor = new AuthorizationManagerBeforeMethodInterceptor(
                    appControllerPattern, new DenyAllByDefaultMethodSecurityAuthorizationManager()
            );
            interceptor.setOrder(order);

            return interceptor;
        }

        @Override
        public AuthorizationResult authorize(Supplier<Authentication> auth, MethodInvocation invocation) {
            val invokedMethod = invocation.getMethod();

            if (doesAnySecurityAnnotationExist(invokedMethod) ||
                    doesAnySecurityAnnotationExist(invokedMethod.getDeclaringClass())) {
                return null;
            }

            return new AuthorizationDecision(false);
        }

        private boolean doesAnySecurityAnnotationExist(AnnotatedElement annotatedElement) {
            val annotations = MergedAnnotations.from(
                    annotatedElement, MergedAnnotations.SearchStrategy.DIRECT
            );

            return annotations.get(PreAuthorize.class).isPresent() ||
                    annotations.get(PreFilter.class).isPresent() ||
                    annotations.get(PostAuthorize.class).isPresent() ||
                    annotations.get(PostFilter.class).isPresent();
        }

        @Deprecated
        @Override
        public AuthorizationDecision check(Supplier<Authentication> auth, MethodInvocation invocation) {
            return null;
        }
    }
}
💡
As you might have noticed, I treat PreFilter & PostFilter as enough to allow the default authorization flow to proceed. If you want to ensure only PreAuthorize & PostAuthorize are considered, remove “annotations.get(PreFilter.class).isPresent()and annotations.get(PostFilter.class).isPresent()from the helper method.

Custom Security Annotations

Role Constants & Enum

  • Method security annotation for the chosen role requires a compile-time constant.

      public static class Const {
              public static final String USER = "USER";
              public static final String ADMIN = "ADMIN";
      }
    
  • Method security annotation for multiple chosen roles requires quoted compile-time constants.

      public static class Quoted {
              public static final String USER = "'" + Const.USER + "'";
              public static final String ADMIN = "'" + Const.ADMIN + "'";
      }
    
  • Whole Role enum.

  • I know that USER(Const.USER) == USER.name(). Still, I wanted to make it crystal clear what kind of value we are working with and allow flexibility if you wish to change it later.

  • For example, if you change Const.USER = “user”, then the USER enum value (assuming you’re going to use .toString()) and Quoted.USER will get automatically updated.

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public enum Role {
    USER(Const.USER),
    ADMIN(Const.ADMIN);

    private final String role;

    @Override
    public String toString() {
        return this.role;
    }

    public static class Const {
        public static final String USER = "USER";
        public static final String ADMIN = "ADMIN";
    }

    public static class Quoted {
        public static final String USER = "'" + Const.USER + "'";
        public static final String ADMIN = "'" + Const.ADMIN + "'";
    }
}

Role Hierarchy [Optional]

  • As promised.
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;

...

import static com.ml.gamingor.shared.infrastructure.configuration.security.Role.ADMIN;
import static com.ml.gamingor.shared.infrastructure.configuration.security.Role.USER;


@Configuration
@EnableMethodSecurity
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class MethodSecurityConfig {
    ...

    @Bean
    static RoleHierarchy roleHierarchy() {
        return RoleHierarchyImpl.withDefaultRolePrefix()
                .role(ADMIN.toString())
                .implies(USER.toString())
                .build();
    }

    @Bean
    static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy);
        return expressionHandler;
    }

    ...
}

Permit All

  • @PermitAll@PreAuthorize(“permitAll()”)
import org.springframework.security.access.prepost.PreAuthorize;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("permitAll()")
public @interface PermitAll {
}

Permit Only Authenticated

  • @PermitOnlyAuthenticated@PreAuthorize(“isAuthenticated()”)
import org.springframework.security.access.prepost.PreAuthorize;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAuthenticated()")
public @interface PermitOnlyAuthenticated {
}

Permit Only Unauthenticated

  • @PermitOnlyUnauthenticated@PreAuthorize(“isAnonymous()”)
import org.springframework.security.access.prepost.PreAuthorize;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAnonymous()")
public @interface PermitOnlyUnauthenticated {
}

Permit Authenticated With Role

  • @PermitAuthenticatedWithRole(String value) → @PreAuthorize("hasRole('{value}')")

  • Example: @PermitAuthenticatedWithRole(Role.Const.ADMIN)

import org.springframework.security.access.prepost.PreAuthorize;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('{value}')")
public @interface PermitAuthenticatedWithRole {
    String value();
}

Permit Authenticated With Roles

  • @PermitAuthenticatedWithRoles(String[] roles) → @PreAuthorize("hasAnyRole({roles})")

  • Example: @PermitAuthenticatedWithRoles(roles = {Role.Quoted.USER, Role.Quoted.ADMIN})

import org.springframework.security.access.prepost.PreAuthorize;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAnyRole({roles})")
public @interface PermitAuthenticatedWithRoles {
    String[] roles();
}

JWT Tokens Generation Implementation

  • The functionalities presented here are part of a larger project (Gitlab). I try to create a clean hexagonal architecture with Spring’s more in-depth features in mind (like this article). Consequently, you wouldn’t be able to copy and paste things if I had given you the code of my source files.

  • But you are in luck, because I will provide you with a more streamlined, layered (controller, service) implementation. I point it out because this section might be more prone to errors (like some incorrect import or method name that magically changes across examples). As mentioned in the Preface, contact me, and I will fix it ASAP.

  • I will put things like mapping, validation, descriptive operations, etc., in private (helper) methods. You can extract them to other classes as you wish - I just wanted the service classes to be self-contained. This article is already very long without the creation of additional files.

App Configuration

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class ApplicationConfiguration {

    @Bean
    @Primary
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
                .setSerializationInclusion(JsonInclude.Include.NON_ABSENT)
                .registerModule(new JavaTimeModule())
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }
}
# application.yaml

spring:
  main.banner-mode: off
  threads.virtual.enabled: true
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate.ddl-auto: validate
    open-in-view: false

...

Persistence

PostgreSQL Schema

CREATE TABLE authority (
    id SMALLSERIAL PRIMARY KEY,
    role VARCHAR(50) UNIQUE NOT NULL
);
INSERT INTO authority (role) VALUES ('ADMIN'), ('USER');

CREATE TABLE gamingor_user (
    id UUID PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    username VARCHAR(30) UNIQUE NOT NULL,
    password VARCHAR(100) NOT NULL,
    verified BOOLEAN NOT NULL,
    locked BOOLEAN NOT NULL,
    created TIMESTAMP WITH TIME ZONE NOT NULL,
    updated TIMESTAMP WITH TIME ZONE NOT NULL
);

CREATE TABLE user_authority (
    user_id UUID NOT NULL,
    authority_id SMALLINT NOT NULL,
    PRIMARY KEY (user_id, authority_id),
    FOREIGN KEY (user_id) REFERENCES gamingor_user(id) ON DELETE CASCADE,
    FOREIGN KEY (authority_id) REFERENCES authority(id) ON DELETE CASCADE
);

CREATE TABLE refresh_token (
    id UUID PRIMARY KEY,
    user_id UUID NOT NULL,
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    revoked BOOLEAN NOT NULL,
    created TIMESTAMP WITH TIME ZONE NOT NULL,
    updated TIMESTAMP WITH TIME ZONE NOT NULL,
    FOREIGN KEY (user_id) REFERENCES gamingor_user(id) ON DELETE CASCADE
);

Authority Entity

import com.ml.gamingor.shared.infrastructure.configuration.security.Role;
import jakarta.persistence.*;
import lombok.*;

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString
@Entity
@Table(name = "authority")
public class AuthorityJpaEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Short id;

    @EqualsAndHashCode.Include
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, unique = true, length = 50)
    private Role role;
}

User Entity

import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.Accessors;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.SourceType;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.Instant;
import java.util.*;

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString
@Entity
@Table(name = "gamingor_user")
public class UserJpaEntity {

    @Id
    private UUID id;

    @Column(nullable = false, unique = true)
    private String email;

    @EqualsAndHashCode.Include
    @Column(nullable = false, unique = true, length = 30)
    private String username;

    @Column(nullable = false, length = 100)
    private String password;

    @Column(name = "verified", nullable = false)
    @Accessors(fluent = true)
    private Boolean isVerified;

    @Column(name = "locked", nullable = false)
    @Accessors(fluent = true)
    private Boolean isLocked;

    @CreationTimestamp(source = SourceType.DB)
    @Column(nullable = false, updatable = false)
    private Instant created;

    @UpdateTimestamp(source = SourceType.DB)
    @Column(nullable = false)
    private Instant updated;

    @ToString.Exclude
    @Builder.Default
    @ManyToMany(
            fetch = FetchType.EAGER,
            cascade = {
                    CascadeType.PERSIST,
                    CascadeType.MERGE
            }
    )
    @JoinTable(
            name = "user_authority",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "authority_id")
    )
    private Set<AuthorityJpaEntity> authorities = new HashSet<>();
}

Refresh Token Entity

import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.Accessors;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.SourceType;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.Instant;
import java.util.UUID;

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString
@Entity
@Table(name = "refresh_token")
public class RefreshTokenJpaEntity {

    @EqualsAndHashCode.Include
    @Id
    private UUID id;

    @Column(name = "expires_at", nullable = false, updatable = false)
    private Instant expiresAt;

    @Column(name = "revoked",nullable = false)
    @Accessors(fluent = true)
    private Boolean isRevoked;

    @Column(nullable = false, updatable = false)
    private Instant created;

    @Column(nullable = false)
    private Instant updated;

    @ToString.Exclude
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private UserJpaEntity user;
}

Authority Entity Repository

import com.ml.gamingor.shared.infrastructure.configuration.security.Role;
import com.ml.gamingor.user.infrastructure.out.database.jpa.data.entity.AuthorityJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Set;

@Repository
public interface AuthorityJpaRepository extends JpaRepository<AuthorityJpaEntity, Short> {
    Set<AuthorityJpaEntity> findByRoleIn(Set<Role> roles);
}

User Entity Repository

import com.ml.gamingor.user.infrastructure.out.database.jpa.data.entity.UserJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;
import java.util.UUID;

@Repository
public interface UserJpaRepository extends JpaRepository<UserJpaEntity, UUID> {
    boolean existsByUsername(String username);
    Optional<UserJpaEntity> findByUsername(String username);
}

Refresh Token Repository

import com.ml.gamingor.shared.infrastructure.configuration.security.Role;
import com.ml.gamingor.user.infrastructure.out.database.jpa.data.entity.RefreshTokenJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.Optional;
import java.util.Set;
import java.util.UUID;

@Repository
public interface RefreshTokenJpaRepository extends JpaRepository<RefreshTokenJpaEntity, UUID> {

    @Modifying
    @Query("UPDATE RefreshTokenJpaEntity token SET token.isRevoked = :revoked, token.updated = INSTANT WHERE token.id = :id")
    void updateRevokedById(@Param("id") UUID id, @Param("revoked") boolean revoked);

    @Modifying
    @Query("UPDATE RefreshTokenJpaEntity token SET token.isRevoked = :revoked, token.updated = INSTANT WHERE token.user.id = :userId")
    void updateAllRevokedByUserId(@Param("userId") UUID userId, @Param("revoked") boolean revoked);

    interface AccessTokenGenerationProjection {
        UUID getId();
        boolean getIsRevoked();
        UserView getUser();

        interface UserView {
            String getUsername();
            boolean getIsLocked();
            Set<AuthorityView> getAuthorities();
        }

        interface AuthorityView {
            Role getRole();
        }
    }

    Optional<AccessTokenGenerationProjection> findProjectedById(UUID id);
}

Exceptions

Gamingor Api Exception

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.springframework.http.HttpStatus;

@RequiredArgsConstructor
@Getter
@ToString
public abstract class GamingorApiException extends RuntimeException {
    private final HttpStatus httpStatus;
    private final String title;
    private final String details;
    private final String action;
}
💡
I know I should’ve used an Exception for the expected exceptions we will throw. Unfortunately, it does not play nicely with a Global Exception Handler. We must describe each method with a throws clause and handle them, or use @SneakyThrows to silence them. But our Global Exception Handler intercepts and handles all thrown exceptions.

Resource Already Exists Exception

import org.springframework.http.HttpStatus;

public class ResourceAlreadyExistsException extends GamingorApiException {
    public ResourceAlreadyExistsException(String resourceName, String resourceId) {
        super(HttpStatus.BAD_REQUEST,
                "%s already exists.".formatted(resourceName),
                "%s with identifier %s already exists.".formatted(resourceName, resourceId),
                "Try %s with other identifier.".formatted(resourceName)
        );
    }
}

Resource Not Found Exception

import org.springframework.http.HttpStatus;

public class ResourceNotFoundException extends GamingorApiException {
    public ResourceNotFoundException(String resourceName, String resourceId) {
        super(HttpStatus.NOT_FOUND,
                "%s not found.".formatted(resourceName),
                "Couldn't find %s with identifier %s.".formatted(resourceName, resourceId),
                "Provide correct identifier for %s.".formatted(resourceName)
        );
    }
}

Authorization Exception

import org.springframework.http.HttpStatus;

public class AuthorizationException extends GamingorApiException {
    public AuthorizationException(String title, String details, String action) {
        super(HttpStatus.FORBIDDEN, title, details, action);
    }
}

Api Error Response

import lombok.*;

@Getter
@Setter
@Builder
@ToString
public class ApiErrorResponse {
    private String title;
    private String details;
    private String action;
}

Global Exception Handler

Helpers

@RestControllerAdvice
@Slf4j
public class GlobalWebExceptionHandler {

    private static final String BAD_REQUEST_ACTION = "Retry request with correct parameters";

    ...

    private static void logApiError(ApiErrorResponse apiError) {
        log.warn("Api Error: {}", apiError);
    }
}

Gamingor Api Exception Handler

@ExceptionHandler(GamingorApiException.class)
public ResponseEntity<ApiErrorResponse> handleGamingorApiException(GamingorApiException e) {
    val apiError = ApiErrorResponse.builder()
            .title(e.getTitle())
            .details(e.getDetails())
            .action(e.getAction()).build();

    logApiError(apiError);
    return ResponseEntity.status(e.getHttpStatus()).body(apiError);
}

Conversion Failed Exception Handler

  • Thrown when Spring fails to convert a request parameter, path variable, or request body value.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(ConversionFailedException.class)
public ApiErrorResponse handleConversionFailedException(ConversionFailedException e) {
    val apiError = ApiErrorResponse.builder()
            .title(HttpStatus.BAD_REQUEST.getReasonPhrase())
            .details(e.getMessage())
            .action(BAD_REQUEST_ACTION).build();

    logApiError(apiError);
    return apiError;
}

Constraint Violation Exception Handler

  • Thrown when a constraint on bean validation is violated.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(ConstraintViolationException.class)
public ApiErrorResponse handleConstraintViolationException(ConstraintViolationException e) {
    Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
    //noinspection OptionalGetWithoutIsPresent
    val apiError = ApiErrorResponse.builder()
            .title(HttpStatus.BAD_REQUEST.getReasonPhrase())
            .details(violations.stream().findFirst().get().getMessage())
            .action(BAD_REQUEST_ACTION).build();

    logApiError(apiError);
    return apiError;
}

Method Argument Not Valid Exception

  • Thrown when the request body (annotated with @Valid) validation fails.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
    val fieldErrors = e.getBindingResult().getFieldErrors().stream()
            .map(FieldError::getField).collect(Collectors.joining());

    val apiError = ApiErrorResponse.builder()
            .title(HttpStatus.BAD_REQUEST.getReasonPhrase())
            .details(fieldErrors)
            .action(BAD_REQUEST_ACTION).build();

    logApiError(apiError);
    return apiError;
}

Validation Exception

  • More generic bean validation exception catch.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(ValidationException.class)
public ApiErrorResponse handleValidationException(ValidationException e) {
    val apiError =  ApiErrorResponse.builder()
            .title(HttpStatus.BAD_REQUEST.getReasonPhrase())
            .details(e.getMessage())
            .action(BAD_REQUEST_ACTION).build();

    logApiError(apiError);
    return apiError;
}

Authorization Denied Exception

  • An exception is thrown by the method security authorization manager.
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(AuthorizationDeniedException.class)
public void handleAuthorizationDeniedException(AuthorizationDeniedException e) {}

Generic Exception (of an unknown source)

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public ApiErrorResponse handleGenericException(Exception e) {
    log.error("Internal Error: ", e);
    return ApiErrorResponse.builder()
            .title(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
            .action("Retry again or contact server administration").build();
}

Finished Global Exception Handler

import com.ml.gamingor.shared.infrastructure.configuration.exception.response.ApiErrorResponse;
import com.ml.gamingor.shared.domain.exception.GamingorApiException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.ValidationException;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.Set;
import java.util.stream.Collectors;

@RestControllerAdvice
@Slf4j
public class GlobalWebExceptionHandler {

    private static final String BAD_REQUEST_ACTION = "Retry request with correct parameters";

    @ExceptionHandler(GamingorApiException.class)
    public ResponseEntity<ApiErrorResponse> handleGamingorApiException(GamingorApiException e) {
        val apiError = ApiErrorResponse.builder()
                .title(e.getTitle())
                .details(e.getDetails())
                .action(e.getAction()).build();

        logApiError(apiError);
        return ResponseEntity.status(e.getHttpStatus()).body(apiError);
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ConversionFailedException.class)
    public ApiErrorResponse handleConversionFailedException(ConversionFailedException e) {
        val apiError = ApiErrorResponse.builder()
                .title(HttpStatus.BAD_REQUEST.getReasonPhrase())
                .details(e.getMessage())
                .action(BAD_REQUEST_ACTION).build();

        logApiError(apiError);
        return apiError;
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ConstraintViolationException.class)
    public ApiErrorResponse handleConstraintViolationException(ConstraintViolationException e) {
        Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
        //noinspection OptionalGetWithoutIsPresent
        val apiError = ApiErrorResponse.builder()
                .title(HttpStatus.BAD_REQUEST.getReasonPhrase())
                .details(violations.stream().findFirst().get().getMessage())
                .action(BAD_REQUEST_ACTION).build();

        logApiError(apiError);
        return apiError;
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        val fieldErrors = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getField).collect(Collectors.joining());

        val apiError = ApiErrorResponse.builder()
                .title(HttpStatus.BAD_REQUEST.getReasonPhrase())
                .details(fieldErrors)
                .action(BAD_REQUEST_ACTION).build();

        logApiError(apiError);
        return apiError;
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(ValidationException.class)
    public ApiErrorResponse handleValidationException(ValidationException e) {
        val apiError =  ApiErrorResponse.builder()
                .title(HttpStatus.BAD_REQUEST.getReasonPhrase())
                .details(e.getMessage())
                .action(BAD_REQUEST_ACTION).build();

        logApiError(apiError);
        return apiError;
    }

    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(AuthorizationDeniedException.class)
    public void handleAuthorizationDeniedException(AuthorizationDeniedException e) {}

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(Exception.class)
    public ApiErrorResponse handleGenericException(Exception e) {
        log.error("Internal Error: ", e);
        return ApiErrorResponse.builder()
                .title(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
                .action("Contact server administration").build();
    }

    private static void logApiError(ApiErrorResponse apiError) {
        log.warn("Api Error: {}", apiError);
    }
}

Register User

User Registration Request

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserRegistrationRequest {

    @NotBlank(message = "Email is mandatory, but was blank.")
    @Size(max = 255, message = "Email cannot exceed 255 chars.")
    private String email;

    @NotBlank(message = "Username is mandatory, but was blank.")
    @Size(max = 30, message = "Username cannot exceed 30 chars.")
    private String username;

    @NotBlank(message = "Password is mandatory, but was blank.")
    @Size(max = 100, message = "Password cannot exceed 100 chars.")
    private String password;
}

User Registration Response

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.*;

import java.time.Instant;
import java.util.UUID;

@Getter
@Setter
@Builder
public class UserRegistrationResponse {
    private UUID id;
    private String email;
    private String username;

    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC")
    private Instant created;

    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC")
    private Instant updated;
}

User Management Service

  1. Check if the user entity already exists.

     if(this.userJpaRepository.existsByUsername(request.getUsername())) {
         throw new ResourceAlreadyExistsException("User", request.getUsername());
     }
    
  2. Fetch the authorities to be assigned to the user entity.

     private Set<AuthorityJpaEntity> fetchAuthorities(Role... roles) {
         val authorities = this.authorityJpaRepository.findByRoleIn(Set.of(roles));
    
         if(authorities.isEmpty() || authorities.size() != roles.length) {
             throw new RuntimeException("App tried to assign non-existing role to the user.");
         }
    
         return authorities;
     }
    
     val authorities = fetchAuthorities(Role.USER);
    
    💡
    I wanted to keep it as simple as possible. By default, I assign the USER role. If you want a more sophisticated mechanism to assign ADMIN or other roles based on some criteria, I ask you politely to implement it independently.
  3. Instantiate the user entity with assigned authorities.

     private static UserJpaEntity instantiateUserEntity(UserRegistrationRequest request, Set<AuthorityJpaEntity> authorities) {
         return UserJpaEntity.builder()
                 .id(UUID.randomUUID())
                 .email(request.getEmail())
                 .username(request.getUsername())
                 .password(BCrypt.hashpw(request.getPassword(), BCrypt.gensalt()))
                 .isLocked(false)
                 .isVerified(true)
                 .authorities(authorities).build();
     }
    
     var user = instantiateUserEntity(request, authorities);
    
    💡
    Yet again, I wanted to keep it as simple as possible. By default, I set the user to be verified. In a real application, you would have an additional email verification mechanism, which would verify the user as a result of email confirmation.
  4. Save the user entity to a database.

     user = this.userJpaRepository.saveAndFlush(user);
    
  5. Map the user entity to the registration response.

     private static UserRegistrationResponse toUserRegistrationResponse(UserJpaEntity user) {
         return UserRegistrationResponse.builder()
                 .id(user.getId())
                 .email(user.getEmail())
                 .username(user.getUsername())
                 .created(user.getCreated())
                 .updated(user.getUpdated()).build();
     }
    
     return toUserRegistrationResponse(user);
    

Finished User Registration Functionality

import com.ml.gamingor.shared.domain.exception.ResourceAlreadyExistsException;
import com.ml.gamingor.shared.infrastructure.configuration.security.Role;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.request.UserRegistrationRequest;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.response.UserRegistrationResponse;
import com.ml.gamingor.user.infrastructure.out.database.jpa.data.entity.AuthorityJpaEntity;
import com.ml.gamingor.user.infrastructure.out.database.jpa.data.entity.UserJpaEntity;
import com.ml.gamingor.user.infrastructure.out.database.jpa.repository.AuthorityJpaRepository;
import com.ml.gamingor.user.infrastructure.out.database.jpa.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Set;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class UserManagementService {

    private final AuthorityJpaRepository authorityJpaRepository;
    private final UserJpaRepository userJpaRepository;

    @Transactional
    public UserRegistrationResponse registerUser(UserRegistrationRequest request) {
        if(this.userJpaRepository.existsByUsername(request.getUsername())) {
            throw new ResourceAlreadyExistsException("User", request.getUsername());
        }

        val authorities = fetchAuthorities(Role.USER);
        var user = instantiateUserEntity(request, authorities);
        user = this.userJpaRepository.saveAndFlush(user);

        return toUserRegistrationResponse(user);
    }

    ////////////////////////////////////////////////////////////////////////
    /// HELPERS

    private Set<AuthorityJpaEntity> fetchAuthorities(Role... roles) {
        val authorities = this.authorityJpaRepository.findByRoleIn(Set.of(roles));

        if(authorities.isEmpty() || authorities.size() != roles.length) {
            throw new RuntimeException("App tried to assign non-existing role to the user.");
        }

        return authorities;
    }

    private static UserJpaEntity instantiateUserEntity(UserRegistrationRequest request, Set<AuthorityJpaEntity> authorities) {
        return UserJpaEntity.builder()
                .id(UUID.randomUUID())
                .email(request.getEmail())
                .username(request.getUsername())
                .password(BCrypt.hashpw(request.getPassword(), BCrypt.gensalt()))
                .isLocked(false)
                .isVerified(true)
                .authorities(authorities).build();
    }

    private static UserRegistrationResponse toUserRegistrationResponse(UserJpaEntity user) {
        return UserRegistrationResponse.builder()
                .id(user.getId())
                .email(user.getEmail())
                .username(user.getUsername())
                .created(user.getCreated())
                .updated(user.getUpdated()).build();
    }
}
💡
@Transactional isn’t required to ensure atomicity, but we still benefit from wrapping persistence operations in one transaction instead of opening two implicit ones (as designed for every JPA query). The default propagation of transaction boundaries is set to REQUIRED, which means use the existing transaction if there is one; otherwise, create a new transaction.

User Management Controller

import com.ml.gamingor.user.functionality.task.UserManagementService;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.response.UserRegistrationResponse;
import com.ml.gamingor.shared.infrastructure.configuration.security.annotations.PermitAll;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.request.UserRegistrationRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/v1/user")
@RequiredArgsConstructor
@Validated
public class UserManagementRestController {

    private final UserManagementService userManagementService;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    @PermitAll
    public UserRegistrationResponse register(@Valid @RequestBody UserRegistrationRequest request) {
        return this.userManagementService.registerUser(request);
    }
}

Generate Refresh Token

User Login Request

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(includeFieldNames = false)
public class UserLoginRequest {

    @NotBlank(message = "Username is mandatory, but was blank.")
    @Size(max = 30, message = "Username cannot exceed 30 chars.")
    private String username;

    @NotBlank(message = "Password is mandatory, but was blank.")
    @Size(max = 100, message = "Password cannot exceed 100 chars.")
    private String password;
}

JWT Token Response

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.*;

import java.time.Instant;

@Getter
@Setter
@Builder
public class JwtTokenResponse {
    private String token;

    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC")
    private Instant expiresAt;
}

User Authentication Service - Refresh Token

  1. Check if the user entity exists.

     val userEntity = this.userJpaRepository.findByUsername(request.getUsername())
             .orElseThrow(() -> new ResourceNotFoundException("User", request.getUsername()));
    
  2. Validate the user password.

     private static void validateIfCorrectPassword(String requestPassword, String entityPassword) {
         if (!BCrypt.checkpw(requestPassword.getBytes(StandardCharsets.UTF_8), entityPassword)) {
             throw new AuthorizationException("Incorrect Password",
                     "Provided password %s was incorrect.".formatted(requestPassword),
                     "Provide correct password.");
         }
     }
    
     validateIfCorrectPassword(request.getPassword(), userEntity.getPassword());
    
  3. If the user is unusable, then revoke all already issued tokens.

     private void revokeTokensIfUserUnusable(UserJpaEntity userEntity) {
         if (!userEntity.isVerified() || userEntity.isLocked()) {
             this.refreshTokenJpaRepository.updateAllRevokedByUserId(userEntity.getId(), true);
             throw new AuthorizationException("Unusable User",
                     "User %s is %s. Revoked all still usable refresh tokens.".formatted(
                             userEntity.getUsername(), !userEntity.isVerified() ? "unverified" : "locked"
                     ), "Contact administration.");
         }
     }
    
     revokeTokensIfUserUnusable(userEntity);
    
    💡
    If your application requires only one session per user, you should revoke all tokens before issuing a new one, even if the user is usable.
  4. Instantiate Refresh Token Entity.

     private static RefreshTokenJpaEntity toRefreshTokenEntity(UserJpaEntity userEntity, long refreshTokenExpiration) {
         val now = Instant.now();
         val expiresAt = now.plusSeconds(refreshTokenExpiration);
    
         return RefreshTokenJpaEntity.builder()
                 .id(UUID.randomUUID())
                 .user(userEntity)
                 .expiresAt(expiresAt)
                 .isRevoked(false)
                 .created(now)
                 .updated(now)
                 .build();
     }
    
     var refreshTokenEntity = toRefreshTokenEntity(userEntity, this.refreshTokenExpiration);
    
  5. Save the refresh token entity to the database.

     refreshTokenEntity = this.refreshTokenJpaRepository.saveAndFlush(refreshTokenEntity);
    
  6. Map to the token claims.

     private static JwtClaimsSet toRefreshTokenClaims(String username, RefreshTokenJpaEntity refreshTokenEntity) {
         return JwtClaimsSet.builder()
                 .id(refreshTokenEntity.getId().toString())
                 .subject(username)
                 .issuedAt(refreshTokenEntity.getCreated())
                 .expiresAt(refreshTokenEntity.getExpiresAt())
                 .build();
     }
    
     val tokenClaims = toRefreshTokenClaims(userEntity.getUsername(), refreshTokenEntity);
    
  7. Encode token claims.

     val encodedToken = this.jwtEncoder.encode(JwtEncoderParameters.from(tokenClaims)).getTokenValue();
    
  8. Return populated JWT Token Response.

     return JwtTokenResponse.builder()
             .token(encodedToken)
             .expiresAt(tokenClaims.getExpiresAt()).build();
    

Finished Refresh Token Generation Functionality

  • We use noRollbackFor for our custom exception to persist revocation of the user tokens if he/she is locked.
@Service
@RequiredArgsConstructor
public class UserAuthenticationService {

    @Value("${security.jwt.expiration.seconds.refresh}")
    private Long refreshTokenExpiration;

    ...

    private final UserJpaRepository userJpaRepository;
    private final RefreshTokenJpaRepository refreshTokenJpaRepository;

    private final JwtEncoder jwtEncoder;

    @Transactional(noRollbackFor = AuthorizationException.class)
    public JwtTokenResponse generateRefreshToken(UserLoginRequest request) {
        val userEntity = this.userJpaRepository.findByUsername(request.getUsername())
                .orElseThrow(() -> new ResourceNotFoundException("User", request.getUsername()));

        validateIfCorrectPassword(request.getPassword(), userEntity.getPassword());
        revokeTokensIfUserUnusable(userEntity);

        var refreshTokenEntity = toRefreshTokenEntity(userEntity, this.refreshTokenExpiration);
        refreshTokenEntity = this.refreshTokenJpaRepository.saveAndFlush(refreshTokenEntity);

        val tokenClaims = toRefreshTokenClaims(userEntity.getUsername(), refreshTokenEntity);
        val encodedToken = this.jwtEncoder.encode(JwtEncoderParameters.from(tokenClaims)).getTokenValue();

        return JwtTokenResponse.builder()
                .token(encodedToken)
                .expiresAt(tokenClaims.getExpiresAt()).build();
    }

    ...

    private static void validateIfCorrectPassword(String requestPassword, String entityPassword) {
        if (!BCrypt.checkpw(requestPassword.getBytes(StandardCharsets.UTF_8), entityPassword)) {
            throw new AuthorizationException("Incorrect Password",
                    "Provided password %s was incorrect.".formatted(requestPassword),
                    "Provide correct password.");
        }
    }

    private void revokeTokensIfUserUnusable(UserJpaEntity userEntity) {
        if (!userEntity.isVerified() || userEntity.isLocked()) {
            this.refreshTokenJpaRepository.updateAllRevokedByUserId(userEntity.getId(), true);
            throw new AuthorizationException("Unusable User",
                    "User %s is %s. Revoked all still usable refresh tokens.".formatted(
                            userEntity.getUsername(), !userEntity.isVerified() ? "unverified" : "locked"
                    ), "Contact administration.");
        }
    }

    private static RefreshTokenJpaEntity toRefreshTokenEntity(UserJpaEntity userEntity, long refreshTokenExpiration) {
        val now = Instant.now();
        val expiresAt = now.plusSeconds(refreshTokenExpiration);

        return RefreshTokenJpaEntity.builder()
                .id(UUID.randomUUID())
                .user(userEntity)
                .expiresAt(expiresAt)
                .isRevoked(false)
                .created(now)
                .updated(now)
                .build();
    }

    private static JwtClaimsSet toRefreshTokenClaims(String username, RefreshTokenJpaEntity refreshTokenEntity) {
        return JwtClaimsSet.builder()
                .id(refreshTokenEntity.getId().toString())
                .subject(username)
                .issuedAt(refreshTokenEntity.getCreated())
                .expiresAt(refreshTokenEntity.getExpiresAt())
                .build();
    }

    ...
}
💡
Because of the persistent nature of the refresh tokens, you can add some checks to discover probably malicious actions, like generating many tokens within a short period. Also, you can integrate the HaveIBeenPawned check (when issuing a new refresh token) with your user’s email to confirm no user authentication data has been leaked (not only by your application but even by outside services). Just remember to consider last month or something like that - notifying the user every time about a data leak that happened 5 years ago won’t do any good, but rather become annoying.

User Authentication Service - Access Token

  1. Check if the refresh token ID exists. If so, then extract.

     private static UUID extractTokenId(Map<String, ?> authClaims) {
         if (!authClaims.containsKey("jti")) {
             throw new AuthorizationException(
                     "Malformed Refresh Token", "Provided bearer token does not include required id.",
                     "Provide valid refresh token."
             );
         }
         return UUID.fromString(String.valueOf(authClaims.get("jti")));
     }
    
     val tokenId = extractTokenId(token.getToken().getClaims());
    
  2. Fetch the required data projection for access token generation.

     val refreshTokenProjection = this.refreshTokenJpaRepository.findProjectedById(tokenId)
             .orElseThrow(() -> new ResourceNotFoundException("Refresh Token", tokenId.toString()));
    
  3. Check if the token was revoked.

     private static void validateIfRevokedToken(RefreshTokenJpaRepository.AccessTokenGenerationProjection refreshTokenView) {
         if (refreshTokenView.getIsRevoked()) {
             throw new AuthorizationException("Revoked Refresh Token",
                     "Provided refresh token %s has been already revoked.".formatted(refreshTokenView.getId()),
                     "Generate new refresh token."
             );
         }
     }
    
     validateIfRevokedToken(refreshTokenProjection);
    
  4. Fetch the user from the data projection.

     val userProjection = refreshTokenProjection.getUser();
    
  5. Validate if the provided token claims have the appropriate user assigned.

     private static void validateIfCorrectUsername(String subject, String username, UUID tokenId) {
         if (!username.equals(subject)) {
             throw new AuthorizationException("Incorrect Subject",
                     "Provided refresh token %s has incorrect subject embedded into it.".formatted(tokenId),
                     "Generate new refresh token."
             );
         }
     }
    
     validateIfCorrectUsername(token.getName(), userProjection.getUsername(), tokenId);
    
  6. Revoke the provided token if the user is locked.

     private void revokeTokenIfUserLocked(
             RefreshTokenJpaRepository.AccessTokenGenerationProjection.UserView userView,
             UUID tokenId
     ) {
         if (userView.getIsLocked()) {
             this.refreshTokenJpaRepository.updateRevokedById(tokenId, true);
             throw new AuthorizationException("Locked User",
                     "Provided refresh token %s got revoked, because user %s is locked."
                             .formatted(tokenId, userView.getUsername()), "Contact administration."
             );
         }
     }
    
     revokeTokenIfUserLocked(userProjection, tokenId);
    
  7. Map to the token claims.

     private static JwtClaimsSet toAccessTokenClaims(
             RefreshTokenJpaRepository.AccessTokenGenerationProjection.UserView userView,
             long accessTokenExpiration
     ) {
         val now = Instant.now();
         val expiresAt = now.plusSeconds(accessTokenExpiration);
    
         val scope = userView.getAuthorities().stream()
                 .map(RefreshTokenJpaRepository.AccessTokenGenerationProjection.AuthorityView::getRole)
                 .map(Enum::toString)
                 .collect(Collectors.joining(" "));
    
         return JwtClaimsSet.builder()
                 .issuedAt(now)
                 .expiresAt(expiresAt)
                 .claim("scope", scope)
                 .subject(userView.getUsername())
                 .build();
     }
    
     val tokenClaims = toAccessTokenClaims(userProjection, this.accessTokenExpiration);
    
  8. Encode token claims.

     val encodedToken = this.jwtEncoder.encode(JwtEncoderParameters.from(tokenClaims)).getTokenValue();
    
  9. Return populated JWT token response.

     return JwtTokenResponse.builder()
             .token(encodedToken)
             .expiresAt(tokenClaims.getExpiresAt()).build();
    

Finished Access Token Generation Functionality

  • We use noRollbackFor for our custom exception to persist revocation of the provided token if the user is locked.
@Service
@RequiredArgsConstructor
public class UserAuthenticationService {
    ...

    @Value("${security.jwt.expiration.seconds.access}")
    private Long accessTokenExpiration;

    private final RefreshTokenJpaRepository refreshTokenJpaRepository;

    private final JwtEncoder jwtEncoder;

    ...

    @Transactional(noRollbackFor = AuthorizationException.class)
    public JwtTokenResponse generateAccessToken(JwtAuthenticationToken token) {
        val tokenId = extractTokenId(token.getToken().getClaims());
        val refreshTokenProjection = this.refreshTokenJpaRepository.findProjectedById(tokenId)
                .orElseThrow(() -> new ResourceNotFoundException("Refresh Token", tokenId.toString()));
        validateIfRevokedToken(refreshTokenProjection);

        val userProjection = refreshTokenProjection.getUser();
        validateIfCorrectUsername(token.getName(), userProjection.getUsername(), tokenId);
        revokeTokenIfUserLocked(userProjection, tokenId);

        val tokenClaims = toAccessTokenClaims(userProjection, this.accessTokenExpiration);
        val encodedToken = this.jwtEncoder.encode(JwtEncoderParameters.from(tokenClaims)).getTokenValue();

        return JwtTokenResponse.builder()
                .token(encodedToken)
                .expiresAt(tokenClaims.getExpiresAt()).build();
    }

    ...

    private static UUID extractTokenId(Map<String, ?> authClaims) {
        if (!authClaims.containsKey("jti")) {
            throw new AuthorizationException(
                    "Malformed Refresh Token", "Provided bearer token does not include required id.",
                    "Provide valid refresh token."
            );
        }
        return UUID.fromString(String.valueOf(authClaims.get("jti")));
    }

    private static void validateIfRevokedToken(RefreshTokenJpaRepository.AccessTokenGenerationProjection refreshTokenView) {
        if (refreshTokenView.getIsRevoked()) {
            throw new AuthorizationException("Revoked Refresh Token",
                    "Provided refresh token %s has been already revoked.".formatted(refreshTokenView.getId()),
                    "Generate new refresh token."
            );
        }
    }

    private static void validateIfCorrectUsername(String subject, String username, UUID tokenId) {
        if (!username.equals(subject)) {
            throw new AuthorizationException("Incorrect Subject",
                    "Provided refresh token %s has incorrect subject embedded into it.".formatted(tokenId),
                    "Generate new refresh token."
            );
        }
    }

    private void revokeTokenIfUserLocked(
            RefreshTokenJpaRepository.AccessTokenGenerationProjection.UserView userView,
            UUID tokenId
    ) {
        if (userView.getIsLocked()) {
            this.refreshTokenJpaRepository.updateRevokedById(tokenId, true);
            throw new AuthorizationException("Locked User",
                    "Provided refresh token %s got revoked, because user %s is locked."
                            .formatted(tokenId, userView.getUsername()), "Contact administration."
            );
        }
    }

    private static JwtClaimsSet toAccessTokenClaims(
            RefreshTokenJpaRepository.AccessTokenGenerationProjection.UserView userView,
            long accessTokenExpiration
    ) {
        val now = Instant.now();
        val expiresAt = now.plusSeconds(accessTokenExpiration);

        val scope = userView.getAuthorities().stream()
                .map(RefreshTokenJpaRepository.AccessTokenGenerationProjection.AuthorityView::getRole)
                .map(Enum::toString)
                .collect(Collectors.joining(" "));

        return JwtClaimsSet.builder()
                .issuedAt(now)
                .expiresAt(expiresAt)
                .claim("scope", scope)
                .subject(userView.getUsername())
                .build();
    }
}

Finished User Authentication Service

  • As I’ve mentioned before, do not implement business logic like that. I’ve created it in a single file only for your convenience.
import com.ml.gamingor.shared.domain.exception.ResourceNotFoundException;
import com.ml.gamingor.user.domain.exception.AuthorizationException;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.request.UserLoginRequest;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.response.JwtTokenResponse;
import com.ml.gamingor.user.infrastructure.out.database.jpa.data.entity.RefreshTokenJpaEntity;
import com.ml.gamingor.user.infrastructure.out.database.jpa.data.entity.UserJpaEntity;
import com.ml.gamingor.user.infrastructure.out.database.jpa.repository.RefreshTokenJpaRepository;
import com.ml.gamingor.user.infrastructure.out.database.jpa.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.val;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class UserAuthenticationService {

    @Value("${security.jwt.expiration.seconds.refresh}")
    private Long refreshTokenExpiration;

    @Value("${security.jwt.expiration.seconds.access}")
    private Long accessTokenExpiration;

    private final UserJpaRepository userJpaRepository;
    private final RefreshTokenJpaRepository refreshTokenJpaRepository;

    private final JwtEncoder jwtEncoder;

    @Transactional(noRollbackFor = AuthorizationException.class)
    public JwtTokenResponse generateRefreshToken(UserLoginRequest request) {
        val userEntity = this.userJpaRepository.findByUsername(request.getUsername())
                .orElseThrow(() -> new ResourceNotFoundException("User", request.getUsername()));

        validateIfCorrectPassword(request.getPassword(), userEntity.getPassword());
        revokeTokensIfUserUnusable(userEntity);

        var refreshTokenEntity = toRefreshTokenEntity(userEntity, this.refreshTokenExpiration);
        refreshTokenEntity = this.refreshTokenJpaRepository.saveAndFlush(refreshTokenEntity);

        val tokenClaims = toRefreshTokenClaims(userEntity.getUsername(), refreshTokenEntity);
        val encodedToken = this.jwtEncoder.encode(JwtEncoderParameters.from(tokenClaims)).getTokenValue();

        return JwtTokenResponse.builder()
                .token(encodedToken)
                .expiresAt(tokenClaims.getExpiresAt()).build();
    }

    @Transactional(noRollbackFor = AuthorizationException.class)
    public JwtTokenResponse generateAccessToken(JwtAuthenticationToken token) {
        val tokenId = extractTokenId(token.getToken().getClaims());
        val refreshTokenProjection = this.refreshTokenJpaRepository.findProjectedById(tokenId)
                .orElseThrow(() -> new ResourceNotFoundException("Refresh Token", tokenId.toString()));
        validateIfRevokedToken(refreshTokenProjection);

        val userProjection = refreshTokenProjection.getUser();
        validateIfCorrectUsername(token.getName(), userProjection.getUsername(), tokenId);
        revokeTokenIfUserLocked(userProjection, tokenId);

        val tokenClaims = toAccessTokenClaims(userProjection, this.accessTokenExpiration);
        val encodedToken = this.jwtEncoder.encode(JwtEncoderParameters.from(tokenClaims)).getTokenValue();

        return JwtTokenResponse.builder()
                .token(encodedToken)
                .expiresAt(tokenClaims.getExpiresAt()).build();
    }

    /// REFRESH TOKEN HELPERS
    private static void validateIfCorrectPassword(String requestPassword, String entityPassword) {
        if (!BCrypt.checkpw(requestPassword.getBytes(StandardCharsets.UTF_8), entityPassword)) {
            throw new AuthorizationException("Incorrect Password",
                    "Provided password %s was incorrect.".formatted(requestPassword),
                    "Provide correct password.");
        }
    }

    private void revokeTokensIfUserUnusable(UserJpaEntity userEntity) {
        if (!userEntity.isVerified() || userEntity.isLocked()) {
            this.refreshTokenJpaRepository.updateAllRevokedByUserId(userEntity.getId(), true);
            throw new AuthorizationException("Unusable User",
                    "User %s is %s. Revoked all still usable refresh tokens.".formatted(
                            userEntity.getUsername(), !userEntity.isVerified() ? "unverified" : "locked"
                    ), "Contact administration.");
        }
    }

    private static RefreshTokenJpaEntity toRefreshTokenEntity(UserJpaEntity userEntity, long refreshTokenExpiration) {
        val now = Instant.now();
        val expiresAt = now.plusSeconds(refreshTokenExpiration);

        return RefreshTokenJpaEntity.builder()
                .id(UUID.randomUUID())
                .user(userEntity)
                .expiresAt(expiresAt)
                .isRevoked(false)
                .created(now)
                .updated(now)
                .build();
    }

    private static JwtClaimsSet toRefreshTokenClaims(String username, RefreshTokenJpaEntity refreshTokenEntity) {
        return JwtClaimsSet.builder()
                .id(refreshTokenEntity.getId().toString())
                .subject(username)
                .issuedAt(refreshTokenEntity.getCreated())
                .expiresAt(refreshTokenEntity.getExpiresAt())
                .build();
    }

    /// ACCESS TOKEN HELPERS
    private static UUID extractTokenId(Map<String, ?> authClaims) {
        if (!authClaims.containsKey("jti")) {
            throw new AuthorizationException(
                    "Malformed Refresh Token", "Provided bearer token does not include required id.",
                    "Provide valid refresh token."
            );
        }
        return UUID.fromString(String.valueOf(authClaims.get("jti")));
    }

    private static void validateIfRevokedToken(RefreshTokenJpaRepository.AccessTokenGenerationProjection refreshTokenView) {
        if (refreshTokenView.getIsRevoked()) {
            throw new AuthorizationException("Revoked Refresh Token",
                    "Provided refresh token %s has been already revoked.".formatted(refreshTokenView.getId()),
                    "Generate new refresh token."
            );
        }
    }

    private static void validateIfCorrectUsername(String subject, String username, UUID tokenId) {
        if (!username.equals(subject)) {
            throw new AuthorizationException("Incorrect Subject",
                    "Provided refresh token %s has incorrect subject embedded into it.".formatted(tokenId),
                    "Generate new refresh token."
            );
        }
    }

    private void revokeTokenIfUserLocked(
            RefreshTokenJpaRepository.AccessTokenGenerationProjection.UserView userView,
            UUID tokenId
    ) {
        if (userView.getIsLocked()) {
            this.refreshTokenJpaRepository.updateRevokedById(tokenId, true);
            throw new AuthorizationException("Locked User",
                    "Provided refresh token %s got revoked, because user %s is locked."
                            .formatted(tokenId, userView.getUsername()), "Contact administration."
            );
        }
    }

    private static JwtClaimsSet toAccessTokenClaims(
            RefreshTokenJpaRepository.AccessTokenGenerationProjection.UserView userView,
            long accessTokenExpiration
    ) {
        val now = Instant.now();
        val expiresAt = now.plusSeconds(accessTokenExpiration);

        val scope = userView.getAuthorities().stream()
                .map(RefreshTokenJpaRepository.AccessTokenGenerationProjection.AuthorityView::getRole)
                .map(Enum::toString)
                .collect(Collectors.joining(" "));

        return JwtClaimsSet.builder()
                .issuedAt(now)
                .expiresAt(expiresAt)
                .claim("scope", scope)
                .subject(userView.getUsername())
                .build();
    }
}

User Authentication Controller

import com.ml.gamingor.user.functionality.task.UserAuthenticationService;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.request.UserLoginRequest;
import com.ml.gamingor.shared.infrastructure.configuration.security.annotations.PermitAll;
import com.ml.gamingor.shared.infrastructure.configuration.security.annotations.PermitOnlyAuthenticated;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.response.JwtTokenResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/v1/auth")
@RequiredArgsConstructor
@Validated
public class UserAuthenticationRestController {

    private final UserAuthenticationService userAuthenticationService;

    @PostMapping("/login")
    @ResponseStatus(HttpStatus.OK)
    @PermitAll
    public JwtTokenResponse login(@Valid @RequestBody UserLoginRequest request) {
        return this.userAuthenticationService.generateRefreshToken(request);
    }

    @PostMapping("/token")
    @ResponseStatus(HttpStatus.OK)
    @PermitOnlyAuthenticated
    public JwtTokenResponse token(JwtAuthenticationToken refreshToken) {
        return this.userAuthenticationService.generateAccessToken(refreshToken);
    }
}

Spring Integration Tests

💡
In this section, I won’t explain the code step by step. I’ve made everything very descriptive and self-explanatory.

Authorization Stub Endpoints

  • These simple endpoints will serve us as stubs for authorization integration tests (of course, in a real app, you would test endpoints associated with some concrete functionality that needs to be secured).
import com.ml.gamingor.shared.infrastructure.configuration.security.annotations.PermitAuthenticatedWithRole;
import com.ml.gamingor.shared.infrastructure.configuration.security.annotations.PermitAuthenticatedWithRoles;
import com.ml.gamingor.shared.infrastructure.configuration.security.Role;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/v1/authz/token/test")
@Slf4j
public class UserAuthorizationStubRestController {

    @GetMapping("/admin")
    @PermitAuthenticatedWithRole(Role.Const.ADMIN)
    public ResponseEntity<Void> adminTokenStub(JwtAuthenticationToken accessToken) {
        log.info(
                "Token with {} role test was successful for user {}.",
                Role.ADMIN, accessToken.getName()
        );
        return ResponseEntity.status(HttpStatus.ACCEPTED).build();
    }

    @GetMapping("/multiple")
    @PermitAuthenticatedWithRoles(roles = {Role.Quoted.USER, Role.Quoted.ADMIN})
    public ResponseEntity<Void> multipleTokensStub(JwtAuthenticationToken accessToken) {
        log.info(
                "Token with {} & {} roles test was successful for user {}.",
                Role.USER, Role.ADMIN, accessToken.getName()
        );
        return ResponseEntity.status(HttpStatus.ACCEPTED).build();
    }

    @GetMapping("/unsecured")
    public ResponseEntity<Void> unsecuredTokenStub() {
        log.warn("Someone forgot to put annotation on endpoint, so all requests gonna be denied!");
        return ResponseEntity.status(HttpStatus.ACCEPTED).build();
    }
}

Integration Tests Resource File

# application-integration.yaml
spring:
  datasource:
    url: jdbc:h2:mem:db;MODE=PostgreSQL;
    username: username
    password: password
    driver-class-name: org.h2.Driver

  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate.ddl-auto: create-drop
    show-sql: true
    properties.hibernate.format_sql: true

security:
  jwt:
    base64.secret: 'DummySecretWithPerfect64BytesSizeForHS256AlgorithmWithoutPadding'

Security Integration Tests Utility

  • Allows us to prepare the Granted Authority role for integration tests more easily.
import com.ml.gamingor.shared.infrastructure.configuration.security.Role;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

public class ControllerSecurityTestUtils {
    public static SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor prepareJwtRole(Role role) {
        return jwt().authorities(new SimpleGrantedAuthority("ROLE_%s".formatted(role.toString())));
    }
}

JUnit Utility

  • Simple abbreviation.
import org.junit.jupiter.params.provider.Arguments;

public class JUnitUtils {

    public static Arguments args(Object... args) {
        return Arguments.of(args);
    }
}

Application Context Initialization Test

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest
@ActiveProfiles("integration")
@DisplayName("TEST | Application Full Context Initialization")
class ApplicationContextInitializationTest {
    @Test
    @DisplayName("| 1. Application's context initialization should finish successfully.")
    void contextLoads() {}
}

User Registration Integration Tests

import com.fasterxml.jackson.databind.ObjectMapper;
import com.ml.gamingor.shared.infrastructure.configuration.application.ApplicationConfiguration;
import com.ml.gamingor.shared.infrastructure.configuration.security.MethodSecurityConfig;
import com.ml.gamingor.shared.infrastructure.configuration.security.WebSecurityConfig;
import com.ml.gamingor.shared.infrastructure.configuration.security.Role;
import com.ml.gamingor.user.functionality.task.UserManagementService;
import com.ml.gamingor.user.infrastructure.in.web.rest.controller.UserManagementRestController;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.request.UserRegistrationRequest;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.response.UserRegistrationResponse;
import lombok.SneakyThrows;
import lombok.val;
import net.bytebuddy.utility.RandomString;
import org.apache.commons.lang3.StringUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.stream.Stream;

import static com.ml.gamingor.shared.JUnitUtils.args;
import static com.ml.gamingor.integration.controller.shared.ControllerSecurityTestUtils.prepareJwtRole;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;


@WebMvcTest(UserManagementRestController.class)
@Import({
        UserManagementService.class,
        WebSecurityConfig.class,
        MethodSecurityConfig.class,
})
@ActiveProfiles("integration")
@DisplayName("TEST | User Management Rest Controller |")
@SuppressWarnings("UnqualifiedFieldAccess")
class UserManagementRestControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    private UserManagementService service;

    private static final ObjectMapper MAPPER = new ApplicationConfiguration().objectMapper();

    private static final String ENDPOINT_USER = "/v1/user";

    private static final String EMAIL_STUB = "email.grsdgf@gmail.com";
    private static final String USERNAME_STUB = "user_ofesgseg";
    private static final String PASSWORD_STUB = "password_nfseogn01!";

    @Nested
    @DisplayName("Register Endpoint |")
    class RegisterEndpointTests {

        @Nested
        @DisplayName("Success")
        class SuccessTests {

            @DisplayName("|")
            @ParameterizedTest(name =
                "{index}. Given valid user registration request and role [{0}], user should be created."
            )
            @EnumSource(Role.class)
            @NullSource
            @SneakyThrows
            void registerUserShouldSucceed(Role role) {
                when(service.registerUser(any(UserRegistrationRequest.class)))
                        .thenReturn(UserRegistrationResponse.builder().build());

                mockMvc.perform(
                                post(ENDPOINT_USER)
                                        .with(role == null ? anonymous() : prepareJwtRole(role))
                                        .content(MAPPER.writeValueAsString(validUserRegistrationRequest().build()))
                                        .contentType(MediaType.APPLICATION_JSON)
                        ).andExpect(status().isCreated())
                        .andExpect(content().contentType(MediaType.APPLICATION_JSON));
            }
        }

        @Nested
        @DisplayName("Failure")
        class FailureTests {

            @DisplayName("|")
            @ParameterizedTest(name =
                "{index}. Given invalid user registration request with [{1}], request should fail with 400 status code."
            )
            @MethodSource("argumentsForRegisterUserShouldFail")
            @SneakyThrows
            void registerUserShouldFail(UserRegistrationRequest request, String ignoreDescription) {
                mockMvc.perform(
                                post(ENDPOINT_USER)
                                        .content(MAPPER.writeValueAsString(request))
                                        .contentType(MediaType.APPLICATION_JSON)
                        ).andExpect(status().isBadRequest())
                        .andExpect(content().contentType(MediaType.APPLICATION_JSON));
            }

            private static Stream<Arguments> argumentsForRegisterUserShouldFail() {
                val invalidEmailStream = Stream.of(
                        args(validUserRegistrationRequest().email(RandomString.make(256)).build(),
                                "too long email"),
                        args(validUserRegistrationRequest().email(StringUtils.EMPTY).build(),
                                "empty email"),
                        args(validUserRegistrationRequest().email(null).build(),
                                "null email")
                );

                val invalidUsernameStream = Stream.of(
                        args(validUserRegistrationRequest().username(RandomString.make(31)).build(),
                                "too long username"),
                        args(validUserRegistrationRequest().username(StringUtils.EMPTY).build(),
                                "empty username"),
                        args(validUserRegistrationRequest().username(null).build(),
                                "null username")
                );

                val invalidPasswordStream = Stream.of(
                        args(validUserRegistrationRequest().password(RandomString.make(101)).build(),
                                "too long password"),
                        args(validUserRegistrationRequest().password(StringUtils.EMPTY).build(),
                                "empty password"),
                        args(validUserRegistrationRequest().password(null).build(),
                                "null password")
                );

                return Stream.of(invalidEmailStream, invalidUsernameStream, invalidPasswordStream)
                        .reduce(Stream::concat).get();
            }
        }

        private static UserRegistrationRequest.UserRegistrationRequestBuilder validUserRegistrationRequest() {
            return UserRegistrationRequest.builder()
                    .email(EMAIL_STUB)
                    .username(USERNAME_STUB)
                    .password(PASSWORD_STUB);
        }
    }
}

User Authentication Integration Tests

import com.fasterxml.jackson.databind.ObjectMapper;
import com.ml.gamingor.shared.infrastructure.configuration.application.ApplicationConfiguration;
import com.ml.gamingor.shared.infrastructure.configuration.security.MethodSecurityConfig;
import com.ml.gamingor.shared.infrastructure.configuration.security.WebSecurityConfig;
import com.ml.gamingor.shared.infrastructure.configuration.security.Role;
import com.ml.gamingor.user.functionality.task.UserAuthenticationService;
import com.ml.gamingor.user.infrastructure.in.web.rest.controller.UserAuthenticationRestController;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.request.UserLoginRequest;
import com.ml.gamingor.user.infrastructure.in.web.rest.data.dto.response.JwtTokenResponse;
import lombok.SneakyThrows;
import lombok.val;
import net.bytebuddy.utility.RandomString;
import org.apache.commons.lang3.StringUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.stream.Stream;

import static com.ml.gamingor.integration.controller.shared.ControllerSecurityTestUtils.prepareJwtRole;
import static com.ml.gamingor.shared.JUnitUtils.args;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserAuthenticationRestController.class)
@Import({
        UserAuthenticationService.class,
        WebSecurityConfig.class,
        MethodSecurityConfig.class,
})
@ActiveProfiles("integration")
@DisplayName("TEST | User Authentication Rest Controller |")
@SuppressWarnings("UnqualifiedFieldAccess")
class UserAuthenticationRestControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    private UserAuthenticationService service;

    private static final ObjectMapper MAPPER = new ApplicationConfiguration().objectMapper();

    private static final String ENDPOINT_AUTH = "/v1/auth";

    private static final String ENDPOINT_LOGIN = ENDPOINT_AUTH + "/login";
    private static final String ENDPOINT_TOKEN = ENDPOINT_AUTH + "/token";

    @Nested
    @DisplayName("Login Endpoint |")
    class LoginEndpointTests {

        private static final String USERNAME_STUB = "user_fnsejgnse";
        private static final String PASSWORD_STUB = "password_fnsefesg!";

        @Nested
        @DisplayName("Success")
        class SuccessTests {

            @DisplayName("|")
            @ParameterizedTest(name =
                    "{index}. Given valid user login request and role [{0}], refresh token should be generated."
            )
            @EnumSource(Role.class)
            @NullSource
            @SneakyThrows
            void userFetchRefreshTokenShouldSucceed(Role role) {
                when(service.generateRefreshToken(any(UserLoginRequest.class)))
                        .thenReturn(JwtTokenResponse.builder().build());

                mockMvc.perform(
                                post(ENDPOINT_LOGIN)
                                        .with(role == null ? anonymous() : prepareJwtRole(role))
                                        .content(MAPPER.writeValueAsString(validUserLoginRequest().build()))
                                        .contentType(MediaType.APPLICATION_JSON)
                        ).andExpect(status().isOk())
                        .andExpect(content().contentType(MediaType.APPLICATION_JSON));
            }
        }

        @Nested
        @DisplayName("Failure")
        class FailureTests {

            @DisplayName("|")
            @ParameterizedTest(name =
                    "{index}. Given invalid user login request with [{1}], request should fail with 400 status code."
            )
            @MethodSource("argumentsForUserFetchRefreshTokenShouldFail")
            @SneakyThrows
            void userFetchRefreshTokenShouldFail(UserLoginRequest request, String ignoreDescription) {
                mockMvc.perform(
                                post(ENDPOINT_LOGIN)
                                        .content(MAPPER.writeValueAsString(request))
                                        .contentType(MediaType.APPLICATION_JSON)
                        ).andExpect(status().isBadRequest())
                        .andExpect(content().contentType(MediaType.APPLICATION_JSON));
            }

            private static Stream<Arguments> argumentsForUserFetchRefreshTokenShouldFail() {
                val invalidUsernameStream = Stream.of(
                        args(validUserLoginRequest().username(RandomString.make(31)).build(),
                                "too long username"),
                        args(validUserLoginRequest().username(StringUtils.EMPTY).build(),
                                "empty username"),
                        args(validUserLoginRequest().username(null).build(),
                                "null username")
                );

                val invalidPasswordStream = Stream.of(
                        args(validUserLoginRequest().password(RandomString.make(101)).build(),
                                "too long password"),
                        args(validUserLoginRequest().password(StringUtils.EMPTY).build(),
                                "empty password"),
                        args(validUserLoginRequest().password(null).build(),
                                "null password")
                );

                return Stream.concat(invalidUsernameStream, invalidPasswordStream);
            }
        }

        private static UserLoginRequest.UserLoginRequestBuilder validUserLoginRequest() {
            return UserLoginRequest.builder()
                    .username(USERNAME_STUB)
                    .password(PASSWORD_STUB);
        }
    }

    @Nested
    @DisplayName("Token Endpoint |")
    class TokenEndpointTests {

        @Nested
        @DisplayName("Success")
        class SuccessTests {

            @DisplayName("|")
            @ParameterizedTest(name =
                    "{index}. Given valid refresh token with assigned role [{0}], access token should be generated."
            )
            @EnumSource(Role.class)
            @SneakyThrows
            void authenticatedUserFetchAccessTokenShouldSucceed(Role role) {
                when(service.generateAccessToken(any(JwtAuthenticationToken.class)))
                        .thenReturn(JwtTokenResponse.builder().build());

                mockMvc.perform(post(ENDPOINT_TOKEN).with(prepareJwtRole(role)))
                        .andExpect(status().isOk())
                        .andExpect(content().contentType(MediaType.APPLICATION_JSON));
            }
        }

        @Nested
        @DisplayName("Failure")
        class FailureTests {

            @DisplayName("| 1. Given missing refresh token, access token should not be generated.")
            @Test
            @SneakyThrows
            void unauthenticatedUserFetchAccessTokenShouldFail() {
                mockMvc.perform(post(ENDPOINT_TOKEN).with(anonymous()))
                        .andExpect(status().isUnauthorized());
            }
        }
    }
}

User Authorization Stubs Integration Tests

import com.ml.gamingor.shared.infrastructure.configuration.security.MethodSecurityConfig;
import com.ml.gamingor.shared.infrastructure.configuration.security.WebSecurityConfig;
import com.ml.gamingor.shared.infrastructure.configuration.security.Role;
import com.ml.gamingor.user.infrastructure.in.web.rest.controller.UserAuthorizationStubRestController;
import lombok.SneakyThrows;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import java.util.stream.Stream;

import static com.ml.gamingor.integration.controller.shared.ControllerSecurityTestUtils.prepareJwtRole;
import static com.ml.gamingor.shared.JUnitUtils.args;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(UserAuthorizationStubRestController.class)
@Import({
        WebSecurityConfig.class,
        MethodSecurityConfig.class,
})
@ActiveProfiles("integration")
@DisplayName("TEST | User Authorization Stub Rest Controller |")
@SuppressWarnings("UnqualifiedFieldAccess")
public class UserAuthorizationStubRestControllerTest {

    @Autowired
    MockMvc mockMvc;

    private static final String ENDPOINT_AUTHZ = "/v1/authz/token/test";

    private static final String ENDPOINT_AUTHZ_ADMIN = ENDPOINT_AUTHZ + "/admin";
    private static final String ENDPOINT_AUTHZ_MULTIPLE_ROLES = ENDPOINT_AUTHZ + "/multiple";
    private static final String ENDPOINT_AUTHZ_UNSECURED = ENDPOINT_AUTHZ + "/unsecured";

    @Nested
    @DisplayName("Secured Endpoints |")
    class SecuredEndpointsTests {

        @Nested
        @DisplayName("Success")
        class SuccessTests {

            @DisplayName("|")
            @ParameterizedTest(name =
                    "{index}. Given secured endpoint [{0}], access with role [{1}] should be successful."
            )
            @MethodSource("argumentsForSecuredEndpointsSuccessfulAuthorization")
            @SneakyThrows
            void authorizationShouldSucceed(String endpoint, Role role) {
                mockMvc.perform(
                        get(endpoint).with(prepareJwtRole(role))
                ).andExpect(status().isAccepted());
            }

            private static Stream<Arguments> argumentsForSecuredEndpointsSuccessfulAuthorization() {
                return Stream.of(
                        args(ENDPOINT_AUTHZ_ADMIN, Role.ADMIN),
                        args(ENDPOINT_AUTHZ_MULTIPLE_ROLES, Role.USER),
                        args(ENDPOINT_AUTHZ_MULTIPLE_ROLES, Role.ADMIN)
                );
            }
        }

        @Nested
        @DisplayName("Failure")
        class FailureTests {

            @DisplayName("|")
            @ParameterizedTest(name =
                    "{index}. Given secured endpoint [{0}], access with role [{1}] should be denied."
            )
            @MethodSource("argumentsForSecuredEndpointsFailedAuthorization")
            @SneakyThrows
            void authorizationShouldFail(String endpoint, Role role) {
                mockMvc.perform(
                        get(endpoint).with(role == null ? anonymous() : prepareJwtRole(role))
                ).andExpect(status().isUnauthorized());
            }

            private static Stream<Arguments> argumentsForSecuredEndpointsFailedAuthorization() {
                return Stream.of(
                        args(ENDPOINT_AUTHZ_ADMIN, null),
                        args(ENDPOINT_AUTHZ_ADMIN, Role.USER),
                        args(ENDPOINT_AUTHZ_MULTIPLE_ROLES, null)
                );
            }
        }
    }

    @Nested
    @DisplayName("Unsecured Endpoint |")
    class UnsecuredEndpointTests {

        @Nested
        @DisplayName("Failure")
        class FailureTests {

            @DisplayName("|")
            @ParameterizedTest(name =
                    "{index}. Given unsecured endpoint, access with role [{0}] should be denied."
            )
            @EnumSource(Role.class)
            @NullSource
            @SneakyThrows
            void authorizationShouldFail(Role role) {
                mockMvc.perform(
                        get(ENDPOINT_AUTHZ_UNSECURED).with(role == null ? anonymous() : prepareJwtRole(role))
                ).andExpect(status().isUnauthorized());
            }
        }
    }
}

P.S. [Optional]

  • If you are still eager to improve your application, here we go.

Functional Tests

  • I won’t provide all source files (only Maven); instead, I will link them.

Maven

  • Add these to your application’s Maven configuration.
<dependency>
    <groupId>org.snakeyaml</groupId>
    <artifactId>snakeyaml-engine</artifactId>
    <version>2.2</version>
</dependency>
<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <version>5.5.0</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.17.0</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-text</artifactId>
    <version>1.13.0</version>
</dependency>

Packaging App & Running Tests Instruction

Resource Files

  • Docker configuration with prepared SQL data files.

  • Environment-related .yaml configuration files.

Yaml Config Loader

  • Utility for loading .yaml configuration files from the resource folder file to the Java object.

Rest Assured Environment Configuration

  • Uses Yaml Config Loader to populate itself.

Rest Assured Runner Configuration

  • Configuration runs before every test class annotated with @ExtendWith(RestAssuredRunner.class).

Rest Assured Functional Tests


Sources

5
Subscribe to my newsletter

Read articles from Mateusz Łokietek directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mateusz Łokietek
Mateusz Łokietek

Not a genius, but gets the job done. Self-taught developer with a passion for programming since middle school.