Login system with JWT token and email reset password

mspilari

Matheus Bernardes Spilari

Posted on November 18, 2024

Login system with JWT token and email reset password

Introduction

The Spring Login Application is a secure and robust user management system built using Spring Boot. This project demonstrates modern approaches to implementing authentication, authorization, and user account functionalities. Key features include user registration, secure password handling with BCrypt, email-based password reset, and JWT (JSON Web Token) authentication. Designed with extensibility and scalability in mind, this application serves as an excellent foundation for projects requiring user management and role-based access control.

By leveraging Spring's powerful tools such as Spring Security, Spring Data JPA, and JavaMailSender, this project ensures best practices in security, maintainability, and ease of integration. Whether you're building a small web application or a large enterprise system, this project provides a practical, well-structured starting point for managing user accounts securely.


Configuration

Pom.xml dependencies

<dependencies>
        <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-resource-server</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-validation</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

Enter fullscreen mode Exit fullscreen mode

Docker

To run the PostgreSQL database, create a docker-compose.yaml file:

services:
  postgres:
    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:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

application.properties

spring.application.name=login_app

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

spring.mail.host=sandbox.smtp.mailtrap.io
spring.mail.port=2525


spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.default-encoding=UTF-8


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

spring.config.import=classpath:env.properties

jwt.public.key=classpath:public.key
jwt.private.key=classpath:private.key

Enter fullscreen mode Exit fullscreen mode

env.properties

spring.mail.username=<Get in your mailtrap account>
spring.mail.password=<Get in your mailtrap account>
Enter fullscreen mode Exit fullscreen mode

How to create an asymmetric keys ?

See in this post how to generate asymmetric keys


Project Structure

login_app/
├── .mvn/                       # Maven folder (Maven configurations)
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── dev/
│   │   │       └── mspilari/
│   │   │           └── login_app/
│   │   │               ├── configs/           # Security, authentication, and other configurations
│   │   │               ├── domains/           # Main application domains
│   │   │               │   ├── email/         # Email-related logic
│   │   │               │   └── user/          # User-related logic
│   │   │               ├── exceptions/        # Custom exceptions and error handling
│   │   │               └── utils/             # Utilities and helpers
│   │   └── resources/                         # Resources (e.g., configuration files)
│   └── test/                                  # Application tests
├── target/                                    # Build folder generated by Maven
├── .gitattributes                             # Git attributes configuration
├── .gitignore                                 # Git ignore file
├── docker-compose.yaml                        # Docker Compose configuration
├── HELP.md                                    # Project help documentation
├── mvnw                                       # Maven Wrapper script for Linux
├── mvnw.cmd                                   # Maven Wrapper script for Windows
└── pom.xml                                    # Maven configuration file
Enter fullscreen mode Exit fullscreen mode

Features

  • User registration with email and password validation
  • Login with JWT authentication
  • Password recovery with email link delivery
  • Password reset via link with temporary token
  • Field validation and error handling

Code

Config directory

BCryptPasswordConfig.java

package dev.mspilari.login_app.configs;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
public class BCryptPasswordConfig {

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

Enter fullscreen mode Exit fullscreen mode

Code Breakdown

  1. @Configuration

    • This annotation tells Spring that the class contains bean definitions.
    • Classes annotated with @Configuration are processed during application startup, and any methods annotated with @Bean will have their return values added to the Spring application context as managed beans.
  2. @Bean

    • The @Bean annotation on the bPasswordEncoder() method indicates that this method returns an object that should be registered as a bean in the Spring application context.
    • This allows the BCryptPasswordEncoder object to be injected wherever it's needed in the application.
  3. BCryptPasswordEncoder

    • This is a utility class provided by Spring Security for encoding passwords.
    • It uses the BCrypt hashing algorithm, which is considered a strong and secure way to hash passwords. The algorithm automatically adds a "salt" to the password before hashing, making it resistant to dictionary attacks and rainbow table attacks.
  4. Method bPasswordEncoder()

    • When this method is called by the Spring framework, it creates a new instance of BCryptPasswordEncoder and makes it available in the application context.
    • Other classes in the application can then autowire this bean to encode or match passwords.

JwtConfig.java

package dev.mspilari.login_app.configs;

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.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 com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;

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

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

    @Bean
    public 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
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withPublicKey(this.publicKey).build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Code Breakdown

1. Class-Level Annotations

@Configuration
Enter fullscreen mode Exit fullscreen mode
  • Indicates this is a Spring configuration class where beans (Spring-managed components) are defined.
  • The beans defined here will be available in the Spring Application Context for dependency injection.

2. Injecting RSA Keys from Configuration

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

@Value("${jwt.private.key}")
private RSAPrivateKey privateKey;
Enter fullscreen mode Exit fullscreen mode
  • @Value is used to inject the public key and private key from the application's properties file (e.g., application.yml or application.properties).
  • These keys are expected to be in the properties as:
  jwt.public.key=<your-public-key>
  jwt.private.key=<your-private-key>
Enter fullscreen mode Exit fullscreen mode

3. JWT Encoder Bean

@Bean
public JwtEncoder jwtEncoder() {
    var jwk = new RSAKey.Builder(this.publicKey).privateKey(this.privateKey).build();
    var jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
    return new NimbusJwtEncoder(jwks);
}
Enter fullscreen mode Exit fullscreen mode
  • Purpose: Creates a bean for encoding (generating) JWT tokens.
  • Steps:
    1. Build RSA Key:
      • RSAKey.Builder creates a JWK (JSON Web Key) representation of the public/private RSA key pair.
    2. Create JWK Set:
      • ImmutableJWKSet stores the key in a set. This set is used by Nimbus JOSE libraries for signing tokens.
    3. NimbusJwtEncoder:
      • This encoder uses the ImmutableJWKSet to encode and sign tokens using the private key.

4. JWT Decoder Bean

@Bean
public JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(this.publicKey).build();
}
Enter fullscreen mode Exit fullscreen mode
  • Purpose: Creates a bean for decoding and verifying JWT tokens.
  • Steps:
    1. Public Key Verification:
      • NimbusJwtDecoder.withPublicKey() is configured with the RSA public key. It verifies the signature of tokens.
    2. Build Decoder:
      • The build() method creates the decoder instance.

How JWT Encoding and Decoding Work

  1. JWT Encoding (Token Generation):

    • The JwtEncoder bean is used to create a signed JWT token. This token typically contains user information (e.g., username, roles, etc.) as claims and is signed using the RSA private key.
    • Example:
     String token = jwtEncoder.encode(JwtClaimsSet.builder().subject("user").build()).getTokenValue();
    
  2. JWT Decoding (Token Verification):

    • The JwtDecoder bean is used to decode and verify the token using the RSA public key. This ensures the token:
      • Was issued by the server (signature verification).
      • Has not been tampered with.
    • Example:
     Jwt jwt = jwtDecoder.decode(token);
    

SecurityConfig.java

package dev.mspilari.login_app.configs;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private JwtConfig jwtConfig;

    public SecurityConfig(JwtConfig jwtConfig) {
        this.jwtConfig = jwtConfig;
    }

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

        return http.build();
    }
}
Enter fullscreen mode Exit fullscreen mode

1. Class-Level Annotations

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
Enter fullscreen mode Exit fullscreen mode
  • @Configuration: Marks this class as a Spring configuration that defines beans.
  • @EnableWebSecurity: Enables Spring Security's web security features.
  • @EnableMethodSecurity: Activates method-level security annotations like @PreAuthorize or @Secured. This allows you to control access to specific methods in your application based on roles, permissions, or conditions.

2. SecurityFilterChain Bean

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
Enter fullscreen mode Exit fullscreen mode
  • Defines the security filter chain for the application. A filter chain is a sequence of security filters applied to incoming HTTP requests.

3. CSRF Protection

.csrf(csrf -> csrf.disable())
Enter fullscreen mode Exit fullscreen mode
  • CSRF (Cross-Site Request Forgery) protection is disabled.
    • CSRF protection is often unnecessary for stateless APIs, as tokens (like JWT) already provide a way to prevent unauthorized requests.
    • Disabling it simplifies the security configuration for this JWT-based API.

4. Authorization Rules

.authorizeHttpRequests(auth -> auth
        .requestMatchers(HttpMethod.POST, "/user/register").permitAll()
        .requestMatchers(HttpMethod.POST, "/user/login").permitAll()
        .requestMatchers(HttpMethod.POST, "/user/redeem-password").permitAll()
        .requestMatchers(HttpMethod.POST, "/user/reset-password").permitAll()
        .anyRequest().authenticated())
Enter fullscreen mode Exit fullscreen mode
  • Configures which endpoints require authentication:
    • Permit All:
    • POST requests to endpoints like /user/register, /user/login, /user/redeem-password, and /user/reset-password are open to everyone (no authentication required).
    • These endpoints are likely used for user registration, login, and password recovery/reset, which are typically accessible without logging in.
    • Authenticate Other Requests:
    • All other endpoints (anyRequest) require authentication.

5. JWT Validation

.oauth2ResourceServer(config -> config
        .jwt(jwt -> jwt.decoder(jwtConfig.jwtDecoder())));
Enter fullscreen mode Exit fullscreen mode
  • Configures the application as an OAuth 2.0 resource server that validates requests using JWT tokens.
  • JWT Decoder:
    • The JwtDecoder bean (provided by JwtConfig) is used to verify incoming JWT tokens for requests to secure endpoints.

How This Works

  1. CSRF Disabled: Since this is an API relying on stateless JWT authentication, disabling CSRF is common practice.
  2. Authorization Rules:
    • Unauthenticated users can only access the explicitly permitted endpoints (e.g., /user/register or /user/login).
    • Any other request requires a valid JWT token.
  3. JWT Validation:
    • Spring Security automatically extracts the Authorization header from incoming requests.
    • If the header contains a valid JWT token, the request is authenticated, and the user context is established.
    • If the token is invalid or missing, the request is rejected.

Domains directory

Email directory

Services directory
package dev.mspilari.login_app.domains.email.services;

import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

@Service
public class EmailService {

    private JavaMailSender mailSender;

    public EmailService(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }

    public void sendEmail(String email, String subject, String body) {
        SimpleMailMessage mail = new SimpleMailMessage();
        mail.setTo(email);
        mail.setFrom("app_login@email.com");
        mail.setSubject(subject);
        mail.setText(body);

        mailSender.send(mail);
    }
}
Enter fullscreen mode Exit fullscreen mode

User directory

Controllers directory
package dev.mspilari.login_app.domains.user.controllers;

import java.util.Map;

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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import dev.mspilari.login_app.domains.user.dto.UserDto;
import dev.mspilari.login_app.domains.user.dto.UserRedeemPasswordDto;
import dev.mspilari.login_app.domains.user.dto.UserResetPasswordDto;
import dev.mspilari.login_app.domains.user.services.UserService;
import jakarta.validation.Valid;

@RestController
@RequestMapping("user")
public class UserController {

    private UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(@RequestBody @Valid UserDto userDto) {
        var token = userService.login(userDto.email(), userDto.password());
        return ResponseEntity.ok().body(Map.of("token", token));
    }

    @PostMapping("/register")
    public ResponseEntity<Map<String, String>> register(@RequestBody @Valid UserDto userDto) {
        userService.createUser(userDto.email(), userDto.password());

        return ResponseEntity.ok().body(Map.of("message", "User created successfully"));
    }

    @PostMapping("/redeem-password")
    public ResponseEntity<Map<String, String>> redeemPassword(@RequestBody @Valid UserRedeemPasswordDto userDto) {
        userService.redeemPassword(userDto.email());

        return ResponseEntity.ok().body(Map.of("message", "Send the redeem password link to your email"));
    }

    @PostMapping("/reset-password")
    public ResponseEntity<Map<String, String>> resetPassword(@RequestBody @Valid UserResetPasswordDto userDto) {
        userService.resetPassword(userDto.token(), userDto.password());

        return ResponseEntity.ok().body(Map.of("message", "Credentials updated"));
    }

}
Enter fullscreen mode Exit fullscreen mode

DTO directory

UserDto.java

package dev.mspilari.login_app.domains.user.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

public record UserDto(@Email @NotBlank String email, @Min(4) @NotBlank String password) {

}
Enter fullscreen mode Exit fullscreen mode

UserRedeemPasswordDto.java

package dev.mspilari.login_app.domains.user.dto;

import jakarta.validation.constraints.Email;

public record UserRedeemPasswordDto(@Email String email) {

}
Enter fullscreen mode Exit fullscreen mode

UserResetPasswordDto.java

package dev.mspilari.login_app.domains.user.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

public record UserResetPasswordDto(@NotBlank String token, @NotBlank @Min(4) String password) {

}
Enter fullscreen mode Exit fullscreen mode

Entity directory

UserEntity.java

package dev.mspilari.login_app.domains.user.entity;

import java.time.Instant;
import java.util.UUID;

import dev.mspilari.login_app.domains.user.enums.Role;
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;

@Entity
@Table(name = "tb_users")
public class UserEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private String email;
    private String password;

    private String resetToken;
    private Instant resetTokenExpiration;

    @Enumerated(EnumType.STRING)
    private Role role;

    public UserEntity(String email, String password, Role role) {
        this.email = email;
        this.password = password;
        this.role = role;
    }

    public UserEntity() {
    }

    public UserEntity withResetToken(String resetToken, Instant resetTokenAdditionalTime) {
        this.resetToken = resetToken;
        this.resetTokenExpiration = resetTokenAdditionalTime;
        return this;
    }

    public UUID getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public Role getRole() {
        return role;
    }

    public void setRole(Role role) {
        this.role = role;
    }

    public Instant getResetTokenExpiration() {
        return resetTokenExpiration;
    }

    public void setResetTokenExpiration(Instant resetTokenExpiration) {
        this.resetTokenExpiration = resetTokenExpiration;
    }

    public String getResetToken() {
        return resetToken;
    }

    public void setResetToken(String resetToken) {
        this.resetToken = resetToken;
    }

}
Enter fullscreen mode Exit fullscreen mode

Enums directory

Role.java

package dev.mspilari.login_app.domains.user.enums;

public enum Role {
    ADMIN,
    CLIENT
}
Enter fullscreen mode Exit fullscreen mode

Repositories directory

UserRepository.java

package dev.mspilari.login_app.domains.user.repositories;

import java.util.Optional;
import java.util.UUID;

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

import dev.mspilari.login_app.domains.user.entity.UserEntity;

@Repository
public interface UserRepository extends JpaRepository<UserEntity, UUID> {
    Optional<UserEntity> findByEmail(String email);

    Optional<UserEntity> findByResetToken(String resetToken);
}
Enter fullscreen mode Exit fullscreen mode

Services directory

UserService.java

package dev.mspilari.login_app.domains.user.services;

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

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

import dev.mspilari.login_app.domains.email.services.EmailService;
import dev.mspilari.login_app.domains.user.entity.UserEntity;
import dev.mspilari.login_app.domains.user.enums.Role;
import dev.mspilari.login_app.domains.user.repositories.UserRepository;
import dev.mspilari.login_app.utils.JwtActions;

@Service
public class UserService {

    @Value("${token.expiration.seconds:300}")
    private Long tokenExpirationSeconds;

    private final UserRepository userRepository;

    private final BCryptPasswordEncoder passwordEncoder;

    private final JwtActions jwtActions;

    private final EmailService emailService;

    public UserService(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder, JwtActions jwtActions,
            EmailService emailService) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtActions = jwtActions;
        this.emailService = emailService;
    }

    private Optional<UserEntity> findUserByEmail(String email) {
        return userRepository.findByEmail(email);
    }

    private boolean verifyPassword(String rawPassword, String encodedPassword) {

        return passwordEncoder.matches(rawPassword, encodedPassword);
    }

    private void sendPasswordResetEmail(String email, String token) {
        String subject = "Password Reset Request";
        String resetUrl = "https://seusite.com/reset?token=" + token;
        String body = "Click the link to reset your password: " + resetUrl;

        // Implemente o serviço de e-mail conforme necessário
        emailService.sendEmail(email, subject, body);
    }

    public void createUser(String email, String password) {

        if (findUserByEmail(email).isPresent()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email already exists !");
        }

        var encodedPassword = passwordEncoder.encode(password);

        var newUser = new UserEntity(email, encodedPassword, Role.CLIENT);

        userRepository.save(newUser);
    }

    public String login(String email, String password) {
        var user = findUserByEmail(email).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST,
                "Invalid login credentials"));

        if (!verifyPassword(password, user.getPassword())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid login credentials");
        }

        return jwtActions.jwtCreate(user.getEmail(), user.getRole().toString());

    }

    public void redeemPassword(String email) {
        var user = findUserByEmail(email).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST,
                "Invalid email"));

        var token = UUID.randomUUID().toString();

        user.withResetToken(token, Instant.now().plusSeconds(this.tokenExpirationSeconds));

        userRepository.save(user);

        sendPasswordResetEmail(user.getEmail(), token);
    }

    public void resetPassword(String token, String password) {
        var user = userRepository.findByResetToken(token)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST,
                        "User not found"));

        if (user.getResetTokenExpiration().isBefore(Instant.now())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Token expired");
        }

        user.setPassword(passwordEncoder.encode(password));

        user.withResetToken(null, null);

        userRepository.save(user);
    }

}
Enter fullscreen mode Exit fullscreen mode

Exceptions directory

GlobalException.java

package dev.mspilari.login_app.exceptions;

import java.util.HashMap;
import java.util.Map;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.server.ResponseStatusException;

@ControllerAdvice
public class GlobalException {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, String>> defaultExceptionHandler(Exception e) {

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Map.of("error", e.getMessage()));
    }

    @ExceptionHandler(ResponseStatusException.class)
    public ResponseEntity<Map<String, String>> responseStatusExceptionHandler(ResponseStatusException e) {
        var error = Map.of("errorMessage", e.getReason(), "errorStatusCode", e.getStatusCode().toString());

        return ResponseEntity.status(e.getStatusCode()).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> validationErrorHandler(MethodArgumentNotValidException e) {
        var errors = new HashMap<String, String>();

        for (FieldError error : e.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }

        return ResponseEntity.status(e.getStatusCode()).body(errors);
    }

}
Enter fullscreen mode Exit fullscreen mode

Utils directory

JwtActions.java

package dev.mspilari.login_app.utils;

import java.time.Instant;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;

import dev.mspilari.login_app.configs.JwtConfig;

@Service
public class JwtActions {

    @Value("${jwt.expiration:300}")
    private Long jwtExpiration;

    private final JwtConfig jwtConfig;

    public JwtActions(JwtConfig jwtConfig) {
        this.jwtConfig = jwtConfig;
    }

    public String jwtCreate(String email, String role) {
        var now = Instant.now();

        var claims = JwtClaimsSet.builder()
                .issuer("login_app")
                .subject(email)
                .issuedAt(now)
                .expiresAt(now.plusSeconds(jwtExpiration))
                .claim("scope", role)
                .build();

        return jwtConfig.jwtEncoder().encode(JwtEncoderParameters.from(claims)).getTokenValue();

    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this project, we successfully implemented a secure and feature-rich user authentication system using Spring Boot. Beyond the core functionalities like user registration, login, and JWT-based authentication, the application also incorporates a password recovery system. Users can reset their passwords through an email link, ensuring a smooth and secure recovery process.

To facilitate email-based password recovery, we integrated Spring Email with Mailtrap, a safe and efficient email testing service. This allows the application to send password reset links with temporary tokens while ensuring that emails are sent securely and tested in a controlled environment. This setup demonstrates how to handle sensitive workflows like password recovery without exposing real users to potential issues during development and testing.

The combination of secure authentication practices, robust password management, and seamless email integration makes this application a reliable foundation for any modern web system. Developers can adapt these practices to suit their specific requirements, ensuring both scalability and user trust. By leveraging best practices and tools like Spring Security and Mailtrap, we have demonstrated how to build secure, user-focused applications with ease.


📍 Reference

💻 Project Repository

👋 Talk to me

💖 💪 🙅 🚩
mspilari
Matheus Bernardes Spilari

Posted on November 18, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related