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
Visit the Spring Initializr website.
Select
Maven
as your Project Type and Java as your languageChoose your desired version of Spring Boot. This tutorial uses 3.2.2.
Provide the Project Metadata for your project.
Choose
Jar
as the packaging option.Select the version of Java you are using. This tutorial uses 21.
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 theirUserDetails
. It includes the user's username as the subject, current time as the issue time, and sets the expiration time based on thejwtExpirationMs
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 passedUserDetails
, and also checks if the token has expired.Extract Claims: Various methods such as
extractAllClaims
,extractExpiration
, andextractUserName
help in retrieving specific claims from the token like subject (username) and expiration.Secret Key: It uses the
jwtSecretKey
declared inapplication.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 theUSER
role.GET /api/v1/test/admins
is annotated with@PreAuthorize("hasRole('ADMIN')")
, restricting access to this endpoint to only allow authenticated users with theADMIN
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 BookController
that 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": "[email protected]",
"password": "password"
}
POST http://localhost:8080/api/v1/signin
Content-Type: application/json
{
"email": "[email protected]",
"password": "password"
}
Or log in with our admin user:
POST http://localhost:8080/api/v1/signin
Content-Type: application/json
{
"email": "[email protected]",
"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