Securing Your Spring Boot App with JWT Authentication
Introduction
This article dives into securing a Spring Boot application using JSON Web Tokens (JWT) for authentication. We'll explore Spring Security, JWT fundamentals, and then implement a secure API with user registration, login, and access control. Our data will be persisted in a PostgreSQL database using Spring Data JPA.
Why Spring Security?
Spring Security is an industry-standard framework for securing Spring applications. It offers comprehensive features for authentication, authorization, and access control. By leveraging Spring Security, we can efficiently manage user access to our API endpoints.
JWT Authentication Explained
JWT is a token-based authentication mechanism. Unlike traditional session-based methods, JWT stores user information in a compact, self-contained token. This token is sent with every request, allowing the server to verify the user's identity without relying on server-side sessions.
Here's a breakdown of JWT's benefits:
- Stateless: Removes the need for session management on the server.
- Secure: Employs digital signatures to prevent tampering.
- Flexible: Can be configured with various claims to store user information.
Persistence Layer
In this article, we will be using PostgreSQL as our database. You can maintain your database in any database management system. For a convenient deployment option, consider cloud-based solutions like Rapidapp, which offers managed PostgreSQL databases, simplifying setup and maintenance.
Create a free database in Rapidapp in seconds here
Step-by-Step Implementation
Dependencies
Be sure you have the following dependencies installed by using your favourite dependency management tool e.g. maven, gradle.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Enabling Spring Web Security
In order to enable Spring Web Security, you need to configure it in your SecurityConfig.java
file as shown below.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private static final String[] AUTH_WHITELIST = {
"/api/v1/auth/login",
"/api/v1/auth/register"
};
private final JwtAuthFilter jwtAuthFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeRequests(authorizeRequests ->
authorizeRequests
.requestMatchers(AUTH_WHITELIST).permitAll()
.anyRequest().authenticated()
)
.sessionManagement(sessionManagement ->
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Line 2: Add @EnableWebSecurity
to the SecurityConfig
class to protect the API endpoints.
Line 6: Allow requests from the /api/v1/auth/login
and /api/v1/auth/register
endpoints without authentication.
Line 16: Disable CSRF protection, since JWT authentication is stateless.
Line 24: Set the session creation policy to STATELESS
to ensure sessions are not maintained.
Line 25: Add the JwtAuthFilter
to the security filter chain before the UsernamePasswordAuthenticationFilter
. We will
explain JwtAuthFilter
class soon.
JWT Auth Filter
In order to enable JWT authentication, you need to configure it in your JwtAuthFilter.java
file as shown below.
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getServletPath().contains("/api/v1/auth")) {
filterChain.doFilter(request, response);
return;
}
final String authorizationHeader = request.getHeader("Authorization");
final String jwtToken;
final String email;
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwtToken = authorizationHeader.substring(7);
email = jwtService.extractEmail(jwtToken);
if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
if (jwtService.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}
}
Line 10: Do not apply JWT auth filter for /api/v1/auth
endpoints.
Line 24: Extract JWT token from the Authorization
header. Its format is Bearer <token>
, that's why it is substring(7)
.
Line 25: Extract email from the JWT token using JwtService
which we will take a look at in the next section.
Line 28-32: Validate the JWT token using JwtService
, load user details using UserDetails
from UserDetailsService
and store
the authentication in SecurityContextHolder
.
Implementing JWTService
This class contains all JWT related functionalities as shown below.
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secret;
public String extractEmail(String jwtToken) {
return extractClaim(jwtToken, Claims::getSubject);
}
public <T> T extractClaim(String jwtToken, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(jwtToken);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String jwtToken) {
return Jwts.parser().verifyWith(getSigningKey()).build().parseSignedClaims(jwtToken).getPayload();
}
private SecretKey getSigningKey() {
byte [] bytes = Decoders.BASE64.decode(secret);
return Keys.hmacShaKeyFor(bytes);
}
public boolean validateToken(String jwtToken, UserDetails userDetails) {
final String email = extractEmail(jwtToken);
return email.equals(userDetails.getUsername()) && !isTokenExpired(jwtToken);
}
private boolean isTokenExpired(String jwtToken) {
return extractExpiration(jwtToken).before(new Date());
}
private Date extractExpiration(String jwtToken) {
return extractClaim(jwtToken, Claims::getExpiration);
}
public String generateToken(User u) {
return createToken(u.getEmail());
}
private String createToken(String email) {
return Jwts.builder()
.subject(email)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10))
.signWith(getSigningKey())
.compact();
}
}
Line 5: This is the secret key used to sign JWT tokens. This should be carefully protected, it is not something that we can share or expose publicly. All the other functions are self-explanatory.
UserDetailsService
UserDetailsService is design for showing spring boot security authentication how to load user details from database as shown below.
@Service
@RequiredArgsConstructor
public class UserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return userRepository.findByEmail(email)
.map(user -> User.builder().username(user.getEmail())
.password(user.getPassword())
.build())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
}
Until this point, we have only focused on JWT authentication. However, how we will generate JWT tokens in the next section? What is its use-case?
Registering User
Before generating JWT token to authenticate the user, we need to register the user. We will use AuthController
to register user.
@RestController
@RequestMapping(path = "api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping(path = "/register")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void register(@RequestBody RegisterRequest registerRequest) {
authService.register(registerRequest);
}
@PostMapping(path = "/login")
public ResponseEntity<String> login(@RequestBody LoginRequest loginRequest) {
return ResponseEntity.ok(authService.login(loginRequest));
}
}
In above controller, we are using AuthService
to register and login user. AuthService
uses UserRepository
to interact
database for user related operations.
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public void register(RegisterRequest registerRequest) {
User u = User.builder()
.email(registerRequest.getEmail())
.password(bCryptPasswordEncoder.encode(registerRequest.getPassword()))
.firstName(registerRequest.getFirstName())
.lastName(registerRequest.getLastName())
.build();
userRepository.save(u);
}
public String login(LoginRequest loginRequest) {
authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword()));
User u = userRepository.findByEmail(loginRequest.getEmail()).orElseThrow(() -> new EntityNotFoundException("User not found"));
return jwtService.generateToken(u);
}
}
Line 10: Register user by using the details provided in the request payload. The bCryptPasswordEncoder
is used to hash
the password before storing it in the database.
Line 21: The login operation is done through authenticationManager
since it knows how to validate username and password.
Restricted Access to UserController
You can see a sample endpoint implementation for user object.
@RestController
@RequestMapping(path = "api/v1")
public class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/users")
public List<User> getUsers() {
return userRepository.findAll();
}
}
Assume you registered a new user with email admin
password ssshhhh
. Then in order to generate a JWT token, you can use the following curl request.
curl -X POST -H "Content-Type: application/json" \
-d '{"email": "admin", "password": "ssshhhh"}' http://localhost:8080/api/v1/auth/login
It will return a JWT token, which you can use to authenticate the user. Store it somewhere.
Now in order to access restricted user endpoint, you can use the following curl request.
curl -X GET -H "Authorization: Bearer <token>" http://localhost:8080/api/v1/users
Conclusion
This hands-on tutorial equipped you with the knowledge to implement JWT Authentication in your Spring Boot application. We explored user registration, login, and access control, leveraging Spring Security and JPA for data persistence. By following these steps and customizing the code examples to your specific needs, you can secure your API endpoints and ensure authorized user access. Remember to prioritize security best practices. Here are some additional points to consider:
- Secret Key Management: Store your JWT secret key securely in environment variables or a dedicated secret management service. Never expose it in your codebase.
- Token Expiration: Set a reasonable expiration time for JWT tokens to prevent unauthorized access due to compromised tokens.
- Error Handling: Implement proper error handling mechanisms for invalid or expired tokens to provide informative feedback to users.
- Advanced Features: Explore advanced JWT features like refresh tokens for longer-lived sessions and role-based access control (RBAC) for granular authorization. With JWT authentication in place, your Spring Boot application is well on its way to becoming a secure and robust platform. Deploy it with confidence, knowing that user access is properly controlled.
You can find the complete source code for this project on GitHub.