JWT Authentication and Role-Based Authorization with Java Spring Boot

Introduction to JSON Web Tokens (JWTs), Authentication, and Authorization

JSON Web Tokens (JWTs) are a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret or a public/private key pair using the RSA or ECDSA algorithms.

Role in Authentication

Authentication is the act of validating that users are who they claim to be using passwords, biometrics, one-time passwords, and more. JWTs can be used to establish the identity of users. When a user logs in, the server generates a JWT that encodes a set of claims. The server then returns this token to the client, and the client sends the token back with each request. The server verifies the token and only allows the request to proceed if the token is valid.

Role in Authorization

Authorization is the process by which individual users or groups of users are granted access to a specific resource or function. JWTs also play a crucial role in authorization, as they enable servers to know what resources a user can have access to. When the JWT includes claims like user roles or permissions, the server can determine whether to grant or deny access to certain operations or resources.

Importance in API Security and Access Control

Securing APIs and managing access control are critical in web applications. JWTs help in securing APIs by allowing the server to process requests without having to look up too much user information since the token is self-contained. The authorization process using JWTs enables role-based access control, ensuring that users only have access to the resources and operations their role permits.

Overall, JWTs are integral to the security architecture of modern web applications, enabling reliable authentication and fine-grained authorization while also being lightweight and easily transmittable over the web.

Project Setup

Overview of Tools and Technologies

In this project, we are going to employ several key tools and technologies:

  • Java Spring Boot: An open-source Java-based framework used to create a microservice. It is easy to create stand-alone, production-grade Spring based applications with minimal effort.

  • H2 Database: A lightweight in-memory database that can be embedded in Java applications or run in the client-server mode. It's perfect for development and testing since it doesn't require any setup like a regular database.

  • JSON Web Tokens (JWT): A standard token format used in authentication and authorizations that allows you to securely transmit data between parties as a JSON object.

Creating Your Spring Boot Project

  1. Select Maven as your Project Type and Java as your language

  2. Choose your desired version of Spring Boot. This tutorial uses 3.2.2.

  3. Provide the Project Metadata for your project.

  4. Choose Jar as the packaging option.

  5. Select the version of Java you are using. This tutorial uses 21.

  6. Click on "Generate" to download the project template.

Once downloaded and extracted, create the following Java Packages in your <ArtifactId> folder (com in this diagram)

<ProjectName>
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       ├── configs
|   |   |       ├── controllers
|   |   |       ├── dto
|   |   |       ├── filters
|   |   |       ├── models
|   |   |       ├── repositories
|   |   |       ├── services
│   │   └── resources
│   └── test
│       └── java
├── .gitignore
├── mvnw
├── mvnw.cmd
├── pom.xml
└── README.md

Adding Necessary Dependencies

To include the necessary dependencies for Spring Security, the H2 database, and JWT, open the pom.xml file and add the following dependencies:

<dependencies>
	<!-- H2 Database -->
	<dependency>
		<groupId>com.h2database</groupId>
		<artifactId>h2</artifactId>
	<version>2.2.220</version>
	</dependency>
	<dependency>
		<groupId>io.github.cdimascio</groupId>
		<artifactId>dotenv-java</artifactId>
		<version>3.0.0</version>
	</dependency>
	<dependency>
		<groupId>org.hibernate.validator</groupId>
		<artifactId>hibernate-validator</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-jpa</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-web</artifactId>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>1.18.30</version>
	</dependency>
	<!-- Spring Security -->
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-security</artifactId>
		<version>3.1.2</version>
	</dependency>
	<dependency>
		<groupId>io.jsonwebtoken</groupId>
		<artifactId>jjwt-jackson</artifactId>
		<version>0.11.5</version>
	</dependency>
	<!-- JWT -->
	<dependency>
		<groupId>io.jsonwebtoken</groupId>
		<artifactId>jjwt-impl</artifactId>
		<version>0.11.5</version>
	</dependency>
	<dependency>
		<groupId>org.apache.commons</groupId>
		<artifactId>commons-lang3</artifactId>
		<version>3.12.0</version>
	</dependency>
	<dependency>
		<groupId>io.jsonwebtoken</groupId>
		<artifactId>jjwt-api</artifactId>
		<version>0.11.5</version>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-devtools</artifactId>
		<version>3.1.2</version>
	</dependency>
</dependencies>

Make sure to consult the official documentation for up-to-date version numbers.

Set Properties in `application.resources`

We will need to configure a few properties related to our H2 database, logging, JWT authentication, and authorization. Here are the settings used in our project:

# H2 Database Configuration
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.h2.console.enabled=true
spring.datasource.initialize=true
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Logging
logging.level.root=WARN
logging.level.web=INFO
logging.level.com.example=DEBUG
logging.level.org.springframework.security=DEBUG

spring.jpa.generate-ddl=true

# Generate a secret token.secret.key for our application
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
token.secret.key=<JWT Secret>

# JWT expiration is 1 hour
token.expirationms=3600000

Configuring Spring Security

To secure our web application, we implement Spring Security by defining a SecurityConfig class. This class utilizes a combination of annotations to integrate Spring Security with our project effectively.

Below is an overview of what the SecurityConfig class accomplishes:

  • @EnableWebSecurity: This annotation activates Spring Security’s web security support and provides the Spring MVC integration. It also extends the Spring Security configuration using HTTPSecurity.

  • @EnableMethodSecurity: This enables method-level security based on annotations. It allows us to secure methods in our controllers by specifying roles and conditions for access.

  • @Configuration: The class is marked as a configuration class, and it defines Spring Beans.

  • @RequiredArgsConstructor: This is a Lombok annotation that generates a constructor with required dependencies (in this case, JwtAuthenticationFilter, UserService, and PasswordEncoder).

The SecurityConfig includes the following Beans:

  • AuthenticationProvider: This Bean sets the custom user details service and password encoder. It is necessary for authenticating users based on username and password.

  • AuthenticationManager: This Bean gets the authentication manager from Spring Security's AuthenticationConfiguration which in turns help manage the authentication procedure.

  • SecurityFilterChain: This crucial Bean within the Spring Security filter chain, allows us to configure the rules and conditions for security along different paths in our application. We disable CSRF protection as it is not needed for REST APIs and define stateless session management. Furthermore, we specify URL patterns and the associated security settings, permitting everyone to access the signup and signin endpoints, whereas protecting all other endpoints. Finally, we ensure that the JWT filter is applied before the UsernamePasswordAuthenticationFilter to extract and verify the token.

Here's how we set up the SecurityConfig:

package dev.katyella.oauth.configs;

import dev.katyella.oauth.filters.JwtAuthenticationFilter;
import dev.katyella.oauth.services.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserService userService;
    private final PasswordEncoder passwordEncoder;

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userService.userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder);
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(HttpMethod.POST, "/api/v1/signup", "/api/v1/signin").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/v1/test/**").permitAll()
                        .anyRequest().authenticated()
                )
                .authenticationProvider(authenticationProvider()).addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

This setup is pivotal for JWT-based authentication, ensuring that each request is properly authenticated and has the correct authority to access various resources.

Implement JWT Authentication

JWT Authentication is a key aspect in securing web applications by validating the identity of users via tokens. Let's dive into how the JwtService class in the provided code snippet helps in implementing JWT authentication:

The JwtService class performs several essential actions:

  • Token Generation: generateToken(UserDetails userDetails) method generates a new token for a user based on their UserDetails. It includes the user's username as the subject, current time as the issue time, and sets the expiration time based on the jwtExpirationMs value.

  • Token Validation: isTokenValid(String token, UserDetails userDetails) method checks if the token is valid by comparing the username in the token with the username in the passed UserDetails, and also checks if the token has expired.

  • Extract Claims: Various methods such as extractAllClaims, extractExpiration, and extractUserName help in retrieving specific claims from the token like subject (username) and expiration.

  • Secret Key: It uses the jwtSecretKey declared in application.properties to decode and sign the tokens.

Here's the entire JwtService code for clarity:

package dev.katyella.oauth.services;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;

@Service
public class JwtService {

    @Value("${token.secret.key}")
    String jwtSecretKey;

    @Value("${token.expirationms}")
    Long jwtExpirationMs;

    public String extractUserName(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String userName = extractUserName(token);
        return (userName.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolvers) {
        final Claims claims = extractAllClaims(token);
        return claimsResolvers.apply(claims);
    }

    private String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts
                .builder()
                .setClaims(extraClaims)
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private Claims extractAllClaims(String token) {
        return Jwts
                .parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Role-Based Authorization

This code snippet is a sample controller which demonstrates how role-based access-control can be implemented using Spring Security's @PreAuthorize annotation. It provides access to different REST endpoints based on the authenticated user's role.

  • GET /api/v1/test/users is secured with @PreAuthorize("hasRole('USER')"), restricting access to this endpoint to only allow authenticated users with the USER role.

  • GET /api/v1/test/admins is annotated with @PreAuthorize("hasRole('ADMIN')"), restricting access to this endpoint to only allow authenticated users with the ADMIN role.

  • GET /api/v1/test/role_dependent programmatically checks the user's role without using @PreAuthorize, offering a more dynamic approach to role-based access-control.

A more robust BookControllerthat interfaces with our database will be implemented in the next step. This BookController will use these principles of role-based authorization to manage access to book-related resources.

@RestController
@RequestMapping("/api/v1/test")
public class TestController {

    @GetMapping("/anon")
    public String anonEndPoint() {
        return "everyone can see this";
    }

    @GetMapping("/users")
    @PreAuthorize("hasRole('USER')")
    public String usersEndPoint() {
        return "ONLY users can see this";
    }

    @GetMapping("/admins")
    @PreAuthorize("hasRole('ADMIN')")
    public String adminsEndPoint() {
        return "ONLY admins can see this";
    }

    @GetMapping("/role_dependent")
    public String roleDependentEndPoint() {
        // Get authenticated user
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        // Check if admin role
        if (authentication.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
            return "Data for admins";
        } else {
            String username = authentication.getName();
            return "Data for user: " + username;
        }
    }
}

Build the Books API

Now that we have set up our project, implemented JWT-based authentication and role-based authorization, let's build the Books API to manage book-related resources. We'll create a BookController to handle CRUD (Create, Read, Update, Delete) operations on books.

@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class BookController {
    @Autowired
    private BookRepository bookRepository;

    @GetMapping("/books")
    public List<Book> getAllBooks() {
        // Get the authenticated user
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String username = authentication.getName();

        // If user is admin, return all books
        if (authentication.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
            return bookRepository.findAll();
        } else {
            // Otherwise, return books created by the user
            return bookRepository.findByCreatedBy(username);
        }
    }

    @GetMapping("/books/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable(value = "id") Long bookId) throws ConfigDataResourceNotFoundException {
        // Get the authenticated user
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String username = authentication.getName();

        Book book = bookRepository.findById(bookId)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Book not found for this id :: " + bookId));

        // Check if the book belongs to the authenticated user or if the user is admin
        if (book.getCreatedBy().equals(username) || authentication.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
            return ResponseEntity.ok().body(book);
        } else {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You are not authorized to access this resource");
        }
    }
    
    // More endpoints for handling creation, updating, deleting...

Test the Application

Now we can start our application, sign up and sign with a new user or sign in with a pre-populated one, and use the JWT returned from that request to access our various endpoints according to the role assigned to our user!

Here's how we can sign up our user:

POST http://localhost:8080/api/v1/signup
Content-Type: application/json

{
    "firstName": "John",
    "lastName": "Doe",
    "email": "john.doe@example.com",
    "password": "password"
}

POST http://localhost:8080/api/v1/signin
Content-Type: application/json

{
    "email": "john.doe@example.com",
    "password": "password"
}

Or log in with our admin user:

POST http://localhost:8080/api/v1/signin
Content-Type: application/json

{
    "email": "admin@admin.com",
    "password": "password"
}

Experiment with different calls with different roles to see how our application restricts what data is available to a user based on their role. Check the /src/test directory in the repo to view some example API calls. You will need to provide the JWT token fetched in the /signin step, and remember, it expires after 1 hour!

Here's an example of accessing the user-restricted endpoint as a user role:

GET http://localhost:8080/api/v1/test/users
Authorization: Bearer {{USER_AUTH_TOKEN}}

Last updated

Need US based Java Developers

Visit Katyella

Katyella LLC http://katyella.com