Why?
This is yet-another-spring-boot-jwt-tutorial. It has 2 main motives:
- To understand and document my understanding about spring security.
- The existing articles mostly use jjwt which has a vulnerable release (as of now). Thus, I decided to use an alternative library, along with springdoc openapi 3 swagger docs.
Probably you could scavenge the internet, and use LLMs to put up such an implementation. I just created a cookbook kind of, you may use it as a guide.
Let's start...
How?
This is not at all a beginner's guide, and I would assume the reader knows the basics of spring, IOC container, gradle, spring data JPA, how to build basic CRUD in spring boot.
As a starting point, you may follow this guide.
Initialize
This is a simple CRUD application with just 3 routes: /register
, /login
and /profile
(protected route). I hope this is pretty much self-explanatory. We will be using the H2 database, which is an in-memory database. It is easy to deal with during development, but not ideal for production. There's also an inbuilt web UI for H2 database.
Go to Spring boot initializr.
You will need the following dependencies:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.auth0:java-jwt:4.4.0'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
The above alien language is groovy, which is used to configure Gradle build system (similar to xml in maven). The dependencies block is straightforward and pretty self-explanatory (one of the reasons why I prefer Gradle).
Now, extract and import the gradle/maven project into your preferred editor. If any error occurs, make sure about spring and Java version.
Two dependencies (com.auth0:java-jwt
and org.springdoc:springdoc-openapi-starter-webmvc-ui
) aren't available in spring starter.
Get them from here and here.
Model
We will first create our model, then controller, then service, in the meantime, we will also learn about the required things as we go along...
- Create a package called
controller
. - Create
UserDao.java
:
package org.devabir.jwtexample.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.util.Date;
@Entity
@Table(name = "user_")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDao {
@Id
private String email;
@Column(nullable = false, name = "hashed_password")
private String hashedPassword;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
private final Date createdAt = new Date();
@UpdateTimestamp
@Column(name = "updated_at")
private Date updatedAt;
}
This is for interacting with the database. Most db has user
as reserved keyword, hence, we are calling it user_
.
Getters, setters, and constructors are all auto-generated by the annotations from lombok.
If you are new to this, you should spend some time and setup lombok in your ide.
- Create
UserRequestDto.java
:
package org.devabir.jwtexample.model;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserRequestDto {
@NotBlank(message = "Email is mandatory.")
// This email regex works. Trust me :)
@Email(message = "Please enter a valid email.", regexp = "^[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$")
private String email;
@NotBlank(message = "Password is mandatory.")
private String password;
}
```
It also has **validation logic**. The email regex is sourced from some online searches, and it will work every time (trust me :)).
4. Create `UserResponseDto.java`:
```java
package org.devabir.jwtexample.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserResponseDto {
private String email;
private Date createdAt;
private Date updatedAt;
}
```
Separate Response object, so that we **don't accidentally leak out confidential attributes** like password, also it includes generated fields like created and updated timestamp.
5. Create `TokenResponse.java`:
```java
package org.devabir.jwtexample.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TokenResponse {
private String accessToken;
private UserResponseDto user;
}
```
This class will be specifically used to respond on a `/login` request. We will not only send the user info, but also the **JWT access token**. In later sections, we will see how to set up the JWT part.
This is all about the models, now let's set up the repository for interacting with the database.
### JPA Repository
Create a package called `repository`, then inside that create `UserRepository.java`:
```java
package org.devabir.jwtexample.repository;
import org.devabir.jwtexample.model.UserDao;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<UserDao, String> {
}
```
### Controller
We have only one controller. Create a package `controller`, inside that a file `AuthController.java`:
```java
package org.devabir.jwtexample.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid;
import org.devabir.jwtexample.model.TokenResponse;
import org.devabir.jwtexample.model.UserRequestDto;
import org.devabir.jwtexample.model.UserResponseDto;
import org.devabir.jwtexample.service.AuthService;
import org.devabir.jwtexample.service.JwtService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
private final JwtService jwtService;
public AuthController(AuthService authService, JwtService jwtService) {
this.authService = authService;
this.jwtService = jwtService;
}
@PostMapping("/register")
public ResponseEntity<UserResponseDto> register(@Valid @RequestBody UserRequestDto userRequestDto) {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(this.authService.register(userRequestDto));
}
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@Valid @RequestBody UserRequestDto userRequestDto) {
UserResponseDto user = authService.login(userRequestDto);
final String accessToken = jwtService.buildToken(user.getEmail());
return ResponseEntity.ok(new TokenResponse(accessToken, user));
}
@Operation(security = {@SecurityRequirement(name = "bearer-key")})
@GetMapping("/profile")
public ResponseEntity<UserResponseDto> profile() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
final String email = userDetails.getUsername();
return ResponseEntity.ok(this.authService.profile(email));
}
}
```
- This controller has 2 dependencies, `AuthService` and `JwtService`. We will eventually implement them.
- This controller will give us a high-level overview of the API we are going to create.
- `@Valid` annotation will validate the Bean with the constraints like `@NotBlank`, defined earlier, and throw an appropriate error. Spring's default exception handler [ProblemDetailsExceptionHandler](https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/ProblemDetailsExceptionHandler.java), will respond with an appropriate JSON.
- `@Operation(security = {@SecurityRequirement(name = "bearer-key")})` annotation is specific to springdoc, we will come to it at last. Just remember that it helps us to identify which routes require authentication in the generated docs.
- In English, this roughly translates to: _"If the user is authenticated, Spring should already have the authenticated user's data, which we are simply accessing."_:
```java
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
final String email = userDetails.getUsername();
```
Keep this part in mind, we will again use these when setting up security configs.
### Service
1. Create a package called `service`.
2. Create `UserService.java`:
```java
package org.devabir.jwtexample.service;
import org.devabir.jwtexample.model.UserDao;
import org.devabir.jwtexample.model.UserResponseDto;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@Service
public class UserService {
public UserResponseDto toDto(UserDao userDao) {
UserResponseDto result = new UserResponseDto();
BeanUtils.copyProperties(userDao, result, "hashedPassword");
return result;
}
}
```
- This file only **converts the DAO to DTO**, thus adhering to separation-of-concerns.
- DTO and DAO have many common fields, thus we are copying the Bean properties. There are other methods like model-mapper, builder or constructor from lombok. This seemed short and simple enough.
```java
public static void copyProperties(
Object source,
Object target,
String... ignoreProperties) throws BeansException {}
```
This will work, **even if you just omit the last parameter**, I just included that to be explicit.
3. Create `JwtService.java`:
```java
package org.devabir.jwtexample.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class JwtService {
private final long jwtExpiration;
private final Algorithm signingAlgorithm;
public JwtService(
@Value("${security.jwt.secret-key}") String secretKey,
@Value("${security.jwt.expiration-time}") long jwtExpiration
) {
this.jwtExpiration = jwtExpiration;
this.signingAlgorithm = Algorithm.HMAC256(secretKey);
}
public String extractEmail(String token) {
JWTVerifier jwtVerifier = JWT.require(signingAlgorithm).build();
DecodedJWT jwt = jwtVerifier.verify(token);
return jwt.getSubject();
}
public boolean isTokenValid(String token, String email) {
try {
JWTVerifier verifier = JWT.require(signingAlgorithm)
.withSubject(email)
.build();
verifier.verify(token);
} catch (JWTVerificationException exception) {
return false;
}
return true;
}
public String buildToken(String email) {
return JWT.create()
.withSubject(email)
.withIssuedAt(new Date())
.withExpiresAt(new Date(System.currentTimeMillis() + jwtExpiration))
.sign(signingAlgorithm);
}
}
```
- This code actually uses the [Auth0's JWT library](https://github.com/auth0/java-jwt).
- `@Value("${security.jwt.secret-key}")` imports values from the `application.properties` file.
- This is mostly self-explanatory, we are setting user's email as the subject and signing algorithm as `HMAC256`.
- One thing to keep in mind, `jwtVerifier.verify(token);` could throw verification error.
4. At last `AuthService.java`:
```java
package org.devabir.jwtexample.service;
import org.devabir.jwtexample.model.UserDao;
import org.devabir.jwtexample.model.UserRequestDto;
import org.devabir.jwtexample.model.UserResponseDto;
import org.devabir.jwtexample.repository.UserRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@Service
public class AuthService {
private final UserService userService;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
public AuthService(UserService userService, UserRepository userRepository, PasswordEncoder passwordEncoder, AuthenticationManager authenticationManager) {
this.userService = userService;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.authenticationManager = authenticationManager;
}
public UserResponseDto register(UserRequestDto userRequestDto) {
final String email = userRequestDto.getEmail();
if (this.userRepository.findById(email).isPresent())
throw new ResponseStatusException(HttpStatus.CONFLICT, "Email " + email + " is already taken.");
UserDao userDao = new UserDao();
BeanUtils.copyProperties(userRequestDto, userDao, "password");
userDao.setHashedPassword(this.passwordEncoder.encode(userRequestDto.getPassword()));
userDao = this.userRepository.save(userDao);
return this.userService.toDto(userDao);
}
public UserResponseDto login(UserRequestDto userRequestDto) {
final String email = userRequestDto.getEmail();
UserDao userDao = userRepository.
findById(email)
.orElseThrow(
() -> new UsernameNotFoundException("User " + email + " not found.")
);
this.authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
userRequestDto.getEmail(),
userRequestDto.getPassword()
)
);
return userService.toDto(userDao);
}
public UserResponseDto profile(String email) {
UserDao userDao = userRepository
.findById(email)
.orElseThrow(() -> new UsernameNotFoundException("User " + email + " not found."));
return userService.toDto(userDao);
}
}
```
- We need to define 2 beans (`PasswordEncoder` and `AuthenticationManager`). We will define them in upcoming
sections.
- ```java
this.authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
userRequestDto.getEmail(),
userRequestDto.getPassword()
)
);
```
This will **authenticate and save the user data** into Spring's context.
### Config
1. Create a package called `config`.
2. Create `AppConfig.java`:
```java
package org.devabir.jwtexample.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.devabir.jwtexample.model.UserDao;
import org.devabir.jwtexample.repository.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@Configuration
public class AppConfig {
private final UserRepository userRepository;
public AppConfig(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService());
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
UserDetailsService userDetailsService() {
return new UserDetailsService() {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// NOTE: no username, we just use emails internally.
final String email = username;
Optional<UserDao> optionalUserDao = userRepository.findById(email);
if (optionalUserDao.isEmpty()) {
throw new UsernameNotFoundException("User " + email + " not found.");
}
UserDao user = optionalUserDao.get();
return new UserDetails() {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
@Override
public String getPassword() {
return user.getHashedPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
};
}
};
}
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.components(
new Components()
.addSecuritySchemes(
"bearer-key",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
)
);
}
}
```
- This is probably the most convoluted part of our current app. Here, our business logic interfaces with spring boot framework. We will go from top to bottom.
- The beans for `AuthenticationManager` and `PasswordEncoder` were required by the `AuthService`, we are defining it here. These will be injected into `AuthService`.
- Spring framework can work with many `AuthenticationProvider`, like: `DaoAuthenticationProvider`, `LdapAuthenticationProvider`. Also, we can implement **complex rules like different authentication providers for different routes.**
- We are defining a `DaoAuthenticationProvider`, which is very common (it uses a `UserDetailsService` to retrieve user details from the database and compare credentials).
- This `UserDetailsService` is very spring-specific way of defining users. We need to define `loadUserByUsername(...)` **to retrieve the user from Database**. We can also define some authorities for role-based access control purposes. Here username is just the user's email.
- How we will define **the `UserDetails` is up to us** and spring will remember that, we can access that in a protected route.
- `customOpenAPI()` this is used to enhance the generated swagger docs, by **adding authentication functionality**. We will use this in demo later. [Source](https://springdoc.org/#how-do-i-add-authorization-header-in-requests).
3. Create `SecurityConfig.java`:
```java
package org.devabir.jwtexample.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final AuthenticationProvider authenticationProvider;
private final JwtAuthFilter jwtAuthFilter;
public SecurityConfig(AuthenticationProvider authenticationProvider, JwtAuthFilter jwtAuthFilter) {
this.authenticationProvider = authenticationProvider;
this.jwtAuthFilter = jwtAuthFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.csrf(csrf -> csrf.disable());
httpSecurity.headers(h -> h.frameOptions(fo -> fo.disable()));
httpSecurity.authorizeHttpRequests(
authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers("/auth/profile")
.authenticated()
.anyRequest()
.permitAll()
);
httpSecurity.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
httpSecurity.authenticationProvider(authenticationProvider);
httpSecurity.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOrigins(List.of("http:localhost:8080"));
corsConfiguration.setAllowedMethods(List.of("GET", "POST"));
corsConfiguration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
}
```
- This uses our defined `AuthenticationProvider`.
- `CorsConfigurationSource` is for setting up **CORS configuration.** These are set up on the server side. [Read this for getting an idea](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS).
- **Each request at first goes through a number of filters. This is called `SecurityFilterChain`.** We are configuring that:
- **Disable CSRF** because it's a REST API and we aren't working with session cookies.
- **Disable X-Frame-Options**, mainly for h2-console web UI. **This might not be a good idea in production.**
- **`/auth/profile` will be a protected route.** The rest are public.
- **No session cookie**, thus `SessionCreationPolicy.STATELESS`.
- `httpSecurity.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);` **we are adding the JWT filter (to extract email from JWT token), before Spring's Auth filter.**
### Properties
Finally, this is the last file.
Create `application.properties` within resources folder:
```properties
spring.application.name=jwtexample
server.error.include-stacktrace=never
server.error.include-exception=false
server.error.include-message=always
spring.datasource.url=jdbc:h2:mem:userdb
spring.datasource.driverClassName=org.h2.Driver
spring.h2.console.enabled=true
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
security.jwt.secret-key=3e0ba6026587dc722876146dd83b2222
# 1h in millisecond
security.jwt.expiration-time=3600000
spring.output.ansi.enabled=ALWAYS
```
- The `security.jwt.secret-key` needs to be of **32 chars** (`HMAC256` requires 256-bit key (32 * 8 = 256 bits)).
- The `spring.output.ansi.enabled=ALWAYS` always gives a colored output :).
- `server.error` properties are used to conceal some confidential info. while presenting user with an error response. **This is still not full proof and may leak info in validation errors, or sql errors.**
- We are using the [H2 database](https://www.h2database.com/html/main.html), which is an in-memory database. It is easy to deal with during development, **but not ideal for production.** There's also an inbuilt web UI for H2 database.
## Demo
- Run the application in your IDE, or in terminal run `./gradlew bootRun` (gradle), `./mvnw spring-boot:run` (maven).
- Go to: http://localhost:8080/swagger-ui.html
- Explore the h2 database at: http://localhost:8080/h2-console. **Make sure to enter proper database name (`userdb`).**
- Do register **(expand and press on Try it out)**, then login and at last access the profile. Use padlock sign to copy and paste the JWT access token, you get after logging in.
- Also, check for the validation errors.
- Below are some screenshots:
![Swagger](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4daaat0rucab7anjb3hf.png)
![Register](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qavipbwdca8gyo5hmg1o.png)
![Register response](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/stu1ikdd1jrir1yvkbak.png)
![Login response](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a55ltog070sc43r9x8nn.png)
![Set access token](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hz60cnbansnhpgn2irjy.png)
![Profile](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/93ap0er29gloiw8ettd6.png)
![H2 login](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xmw9kbakgna51dqcyoik.png)
![H2 sql query](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b6sm9fmacv5yh043f3kq.png)
If you have any issue following, here's the [source code repo](https://github.com/dev-abir/jwtexample).
Thanks a lot for reading
Stay safe,
Have a nice day.
Top comments (0)