DEV Community

Cover image for Spring Security with JWT
Matheus Bernardes Spilari
Matheus Bernardes Spilari

Posted on • Updated on

Spring Security with JWT

In this article, we will explore how to integrate Spring Security with JWT to build a solid security layer for your application. We will go through each step, from basic configuration to implementing a custom authentication filter, ensuring you have the necessary tools to protect your APIs efficiently and at scale.

Configuration

At the Spring Initializr we're gonna build a project with Java 21, Maven, Jar and these dependencies:

  • Spring Data JPA
  • Spring Web
  • Lombok
  • Spring Security
  • PostgreSQL Driver
  • OAuth2 Resource Server

Set up the PostgreSQL database

With Docker you're going to create a PostgreSql database with Docker-compose.
Create a docker-compose.yaml file at the root of you project.

services:
  postgre:
    image: postgres:latest
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_DB=database
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD=admin
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Enter fullscreen mode Exit fullscreen mode

Run the command to start the container.

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Set up application.properties file

This file is the configuration for the spring boot application.


spring.datasource.url=jdbc:postgresql://localhost:5432/database
spring.datasource.username=admin
spring.datasource.password=admin

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

jwt.public.key=classpath:public.key
jwt.private.key=classpath:private.key
Enter fullscreen mode Exit fullscreen mode

The jwt.public.key and jwt.private.key are keys that we are going to create further.

Generate the private and public keys

NEVER commit those keys to your github

Run at the console to generate the private key at the resources directory

cd src/main/resources
openssl genrsa > private.key
Enter fullscreen mode Exit fullscreen mode

After, create the public key linked to the private key.

openssl rsa -in private.key -pubout -out public.key 
Enter fullscreen mode Exit fullscreen mode

Code

Create a SecurityConfig file

Closer to the main function create a directory configs and inside that a SecurityConfig.java file.

import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Value("${jwt.public.key}")
    private RSAPublicKey publicKey;

    @Value("${jwt.private.key}")
    private RSAPrivateKey privateKey;

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.POST, "/signin").permitAll()
                        .requestMatchers(HttpMethod.POST, "/login").permitAll()
                        .anyRequest().authenticated())
                .oauth2ResourceServer(config -> config.jwt(jwt -> jwt.decoder(jwtDecoder())));

        return http.build();
    }

    @Bean
    BCryptPasswordEncoder bPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    JwtEncoder jwtEncoder() {
        var jwk = new RSAKey.Builder(this.publicKey).privateKey(this.privateKey).build();

        var jwks = new ImmutableJWKSet<>(new JWKSet(jwk));

        return new NimbusJwtEncoder(jwks);
    }

    @Bean
    JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withPublicKey(publicKey).build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation

  • @EnableWebScurity: When you use @EnableWebSecurity, it automatically triggers Spring Security's configuration for securing web applications. This configuration includes setting up filters, securing endpoints, and applying various security rules.

  • @EnableMethodSecurity: is an annotation in Spring Security that enables method-level security in your Spring application. It allows you to apply security rules directly at the method level using annotations like @PreAuthorize, @PostAuthorize, @Secured, and @RolesAllowed.

  • privateKey and publicKey: are the RSA public and private keys used for signing and verifying JWTs. The @Value annotation injects the keys from the properties file(application.properties) into these fields.

  • CSRF: Disables CSRF (Cross-Site Request Forgery) protection, which is often disabled in stateless REST APIs where JWT is used for authentication.

  • authorizeHttpRequests: Configures URL-based authorization rules.

    • requestMatchers(HttpMethod.POST, "/signin").permitAll(): Allows unauthenticated access to the /signin and /login endpoints, meaning anyone can access these routes without being logged in.
    • anyRequest().authenticated(): Requires authentication for all other requests.
  • oauth2ResourceServer: Configures the application as an OAuth 2.0 resource server that uses JWT for authentication.

    • config.jwt(jwt -> jwt.decoder(jwtDecoder())): Specifies the JWT decoder bean (jwtDecoder) that will be used to decode and validate the JWT tokens.
  • BCryptPasswordEncoder: This bean defines a password encoder that uses the BCrypt hashing algorithm to encode passwords. BCrypt is a popular choice for securely storing passwords due to its adaptive nature, making it resistant to brute-force attacks.

  • JwtEncoder: This bean is responsible for encoding (signing) JWT tokens.

    • RSAKey.Builder: Creates a new RSA key using the provided public and private RSA keys.
    • ImmutableJWKSet<>(new JWKSet(jwk)): Wraps the RSA key in a JSON Web Key Set (JWKSet), making it immutable.
    • NimbusJwtEncoder(jwks): Uses the Nimbus library to create a JWT encoder that will sign tokens with the RSA private key.
  • JwtDecoder: This bean is responsible for decoding (verifying) JWT tokens.

    • NimbusJwtDecoder.withPublicKey(publicKey).build(): Creates a JWT decoder using the RSA public key, which is used to verify the signature of JWT tokens.

Entity

import org.springframework.security.crypto.password.PasswordEncoder;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "tb_clients")
@Getter
@Setter
@NoArgsConstructor
public class ClientEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "client_id")
    private Long clientId;

    private String name;

    @Column(unique = true)
    private String cpf;

    @Column(unique = true)
    private String email;

    private String password;

    @Column(name = "user_type")
    private String userType = "client";

    public Boolean isLoginCorrect(String password, PasswordEncoder passwordEncoder) {
        return passwordEncoder.matches(password, this.password);
    }
}
Enter fullscreen mode Exit fullscreen mode

Repository

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import example.com.challengePicPay.entities.ClientEntity;

@Repository
public interface ClientRepository extends JpaRepository<ClientEntity, Long> {
    Optional<ClientEntity> findByEmail(String email);

    Optional<ClientEntity> findByCpf(String cpf);

    Optional<ClientEntity> findByEmailOrCpf(String email, String cpf);
}
Enter fullscreen mode Exit fullscreen mode

Services

Client Service

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import example.com.challengePicPay.entities.ClientEntity;
import example.com.challengePicPay.repositories.ClientRepository;

@Service
public class ClientService {

    @Autowired
    private ClientRepository clientRepository;

    @Autowired
    private BCryptPasswordEncoder bPasswordEncoder;

    public ClientEntity createClient(String name, String cpf, String email, String password) {

        var clientExists = this.clientRepository.findByEmailOrCpf(email, cpf);

        if (clientExists.isPresent()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email/Cpf already exists.");
        }

        var newClient = new ClientEntity();

        newClient.setName(name);
        newClient.setCpf(cpf);
        newClient.setEmail(email);
        newClient.setPassword(bPasswordEncoder.encode(password));

        return clientRepository.save(newClient);
    }
}
Enter fullscreen mode Exit fullscreen mode

Token Service

import java.time.Instant;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import example.com.challengePicPay.repositories.ClientRepository;

@Service
public class TokenService {

    @Autowired
    private ClientRepository clientRepository;

    @Autowired
    private JwtEncoder jwtEncoder;

    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    public String login(String email, String password) {

        var client = this.clientRepository.findByEmail(email)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email not found"));

        var isCorrect = client.isLoginCorrect(password, bCryptPasswordEncoder);

        if (!isCorrect) {
            throw new BadCredentialsException("Email/password invalid");
        }

        var now = Instant.now();
        var expiresIn = 300L;

        var claims = JwtClaimsSet.builder()
                .issuer("pic_pay_backend")
                .subject(client.getEmail())
                .issuedAt(now)
                .expiresAt(now.plusSeconds(expiresIn))
                .claim("scope", client.getUserType())
                .build();

        var jwtValue = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();

        return jwtValue;

    }
}
Enter fullscreen mode Exit fullscreen mode

Controllers

Client Controller

package example.com.challengePicPay.controllers;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import example.com.challengePicPay.controllers.dto.NewClientDTO;
import example.com.challengePicPay.entities.ClientEntity;
import example.com.challengePicPay.services.ClientService;

@RestController
public class ClientController {

    @Autowired
    private ClientService clientService;

    @PostMapping("/signin")
    public ResponseEntity<ClientEntity> createNewClient(@RequestBody NewClientDTO client) {
        var newClient = this.clientService.createClient(client.name(), client.cpf(), client.email(), client.password());

        return ResponseEntity.status(HttpStatus.CREATED).body(newClient);
    }

    @GetMapping("/protectedRoute")
    @PreAuthorize("hasAuthority('SCOPE_client')")
    public ResponseEntity<String> protectedRoute(JwtAuthenticationToken token) {
        return ResponseEntity.ok("Authorized");
    }

}
Enter fullscreen mode Exit fullscreen mode

Explanation

  • The /protectedRoute is a private route that can only be accessed with a JWT after logging in.

  • The token must be included in the headers as a Bearer token, for example.

  • You can use the token information later in your application, such as in the service layer.

  • @PreAuthorize: The @PreAuthorize annotation in Spring Security is used to perform authorization checks before a method is invoked. This annotation is typically applied at the method level in a Spring component (like a controller or a service) to restrict access based on the user's roles, permissions, or other security-related conditions.
    The annotation is used to define the condition that must be met for the method to be executed. If the condition evaluates to true, the method proceeds. If it evaluates to false, access is denied,

  • "hasAuthority('SCOPE_client')": It checks if the currently authenticated user or client has the specific authority SCOPE_client. If they do, the method protectedRoute() is executed. If they don't, access is denied.


Token Controller: Here, you can log in to the application, and if successful, it will return a token.

package example.com.challengePicPay.controllers;

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

import example.com.challengePicPay.controllers.dto.LoginDTO;
import example.com.challengePicPay.services.TokenService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@RestController
public class TokenController {

    @Autowired
    private TokenService tokenService;

    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(@RequestBody LoginDTO loginDTO) {
        var token = this.tokenService.login(loginDTO.email(), loginDTO.password());

        return ResponseEntity.ok(Map.of("token", token));
    }

}
Enter fullscreen mode Exit fullscreen mode

📍 Reference


👋 Talk to me

Top comments (2)

Collapse
 
frankaboagye profile image
Frank Aboagye

well-structured and informative.

Collapse
 
mspilari profile image
Matheus Bernardes Spilari

Thanks !