Spring Security Two-Factor Auth (2FA) with JWT Token

Creating REST APIs is a better approach for building one-time token-based authentication combined with Jwt tokens in Spring Security.

Historically, passwords have been bad. They are leaked constantly, and with modern hardware, it is very easy to crack even the most securely encrypted passwords. Two-factor authentication (2FA) is a comparatively secure authentication mechanism that minimizes the damages in the event of large-scale password leaks.

1. What is 2FA Authentication?

Two-factor authentication (2FA) is a security mechanism that requires two distinct forms of identification to verify a user’s identity when logging into an account. The idea is to add an extra layer of protection beyond just a password. When using 2FA, even if an attacker obtains a user’s password, they cannot access the account without the second form of authentication.

The most common types of 2FA are:

  • SMS-based 2FA: A one-time password (OTP) is sent via SMS to the user’s registered mobile number. The user must enter this code to complete the login process.
  • Authenticator Apps: Apps like Google Authenticator, Authy, or Microsoft Authenticator generate time-based one-time passwords (TOTP). These apps display a new code every 30 seconds, which the user enters during login.
  • Email-Based 2FA: Similar to SMS, a code is sent to the user’s email. This is less secure than SMS or app-based options because email accounts may be more vulnerable to attacks. We will discuss this solution in this article.
  • Hardware Tokens: Physical devices, such as YubiKey or Google Titan, generate OTPs or use a protocol like FIDO U2F (Universal 2nd Factor). The user plugs in the hardware device or taps it to authenticate.
  • Biometric 2FA: This includes fingerprint, facial recognition, or voice recognition, which are typically used as secondary factors on smartphones or other devices.

//IMAGE

Please note that 2FA is not fully tamper-proof. For example, losing the device (e.g., phone) used for 2FA can lock users out, requiring additional steps to recover access. Similarly, SMS-based 2FA is vulnerable to SIM-swapping attacks, where attackers can intercept SMS codes. Also, Email-based 2FA can be compromised because email accounts are vulnerable to attacks.

2. 2FA Authentication with Spring Security

Spring Security doesn’t provide direct, out-of-the-box support for two-factor authentication (2FA) in a single, all-in-one configuration. However, it does offer the necessary tools to implement 2FA in various ways.

Since Spring Security 6.4, Spring supports the basic OTT login functionality with little scope for customizations. Also, the present OTT logic feature is tightly coupled with the rest of the Spring authentication mechanism, and there is no clarity on how to use it with external application clients.

The built-in OTT authentication feature in Spring security could be improved further to support 2FA, so it is worth checking the latest documentation.

3. Designing a 2FA Authentication Feature

Creating REST APIs is a better approach for building 2FA-based authentication combined with JWT tokens. Any client (web, mobile, or third-party) can use the REST APIs to submit the primary credentials (generally a username/password combination), which will send a secured token to a trusted channel such as email or phone.

Further, these clients can use another REST API to verify the token, which will return a JWT token for further accessing the application as an authenticated user.

As part of the solution, we will develop the following APIs to support the 2FA login:

API EndpointDescription
POST /sign-upCreates a new user account in the system.
POST /loginAuthenticate the user credentials
POST /verify-otpVerifies one-time token and returns JWT corresponding to the user
PUT /refresh-tokenReturns a new access_token
GET /userReturns the details of the logged-in user
DELETE /userDeletes the user-account
GET /jokeReturns a funny joke. This is only for testing purposes.

4. Implementation

Let’s hit the keyboard and write the required component that will take part in the 2FA authentication feature.

4.1. Maven

First thing first. Start by adding the following dependencies to the Spring Boot application. This demo uses Spring Boot 3, which transitively enforces and imports Spring Security 6 into the application.

  • spring-boot-starter-security: will import in the necessary spring security dependencies.
  • spring-boot-starter-mail: will help in sending the tokens via email.
  • io.jsonwebtoken:jjwt: will help create and validate JWT tokens.
  • springdoc-openapi-starter-webmvc-ui: generates API documentation and helps quickly verify the functionality.
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.3.4</version>
  <relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
  <java.version>21</java.version>
</properties>

<dependencies>
  <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.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
  </dependency>
  <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.12.6</version>
  </dependency>
  <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.3.0-jre</version>
  </dependency>

  <!-- Other dependencies -->
</dependencies>

4.2. JPA Entity

Once a user signs, we will store its credentials in the database, so we need to create a User entity first. Apart from email/password, we are adding additional fields to support the 2FA:

  • isEmailVerified: A boolan flag signifying that user email has been verified after the initial signup. The verification is complete when the user verifies the secret token/code sent to his/her email.
  • isActive: A boolean flag to denote that the user has an active account in the application.
  • createdAt: Timestamp when the user was activated.
@Entity
@Table(name = "users")
@Data
public class User implements Serializable {

  private static final long serialVersionUID = -3492266417550204992L;

  @Id
  @GeneratedValue(strategy = GenerationType.AUTO)
  @Column(name = "id", nullable = false, unique = true)
  private UUID id;

  @Column(name = "email_id", nullable = false, unique = true)
  private String emailId;

  @Column(name = "password", nullable = false)
  private String password;

  @Column(name = "email_verified", nullable = false)
  private boolean isEmailVerified;

  @Column(name = "is_active", nullable = false)
  private boolean isActive;

  @Column(name = "created_at", nullable = false)
  private LocalDateTime createdAt;

  @PrePersist
  void setUp() {
    this.createdAt = LocalDateTime.now(ZoneId.of("+00:00"));
  }
}

The User entity will be saved into the database using the UserRepository which is simple JpaRepository implementation with two additional methods for email verification and search.

@Repository
public interface UserRepository extends JpaRepository<User, UUID> {

  boolean existsByEmailId(final String emailId);
  Optional<User> findByEmailId(final String emailId);
}

4.3. Model

After creating the entity, we need to create some DTO objects that will carry the information in requests and responses between the application and the client applications.

Start by adding SignupRequestDto and LoginRequestDto objects. Both have two fields: email and password. We are creating them separately to add more fields to the flows if needed.

@Getter
@Builder
@Jacksonized
public class LoginRequestDto {    // Same as SignupRequestDto

  @Email
  @NotBlank
  private final String emailId;

  @NotBlank
  private final String password;
}

After the signup or login, user will get the secured token in email which he will submit to the token verification screen. This will be done via OtpVerificationRequestDto.

@Getter
@Builder
@Jacksonized
public class OtpVerificationRequestDto {

  private final String emailId;
  private final Integer oneTimePassword;
  private final OtpContext context;
}

After the token verification, the application will return the access token and the refresh token in the response. Let’s call it LoginSuccessDto.

@Getter
@Builder
@Jacksonized
public class LoginSuccessDto {

  private final String accessToken;
  private final String refreshToken;
}

4.4. OPT Contexts

Next, create an enum that will define all the contexts when a token is sent to the user’s content medium. We are doing it on three occasions: signup, login, or account deletion.

public enum OtpContext {

  SIGN_UP, LOGIN, ACCOUNT_DELETION;
}

5. Spring Security Configuration

Let us look at the Spring security configuration, which configures the SecurityFilterChain and does the following:

  • CORS and CSRF: Enables CORS and disables CSRF protection for the stateless API.
  • Session Management: Sets the session policy to stateless (SessionCreationPolicy.STATELESS) since JWT is used for authentication.
  • Route Authorization: Permits requests to endpoints specified in ApiPathExclusion which includes public URLs and Swagger documentation URLs.
  • JWT Filter: Adds a custom JwtAuthenticationFilter before Spring’s built-in UsernamePasswordAuthenticationFilter, allowing JWT tokens to be validated for each request.
@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfiguration {

  private final CustomUserDetailService customUserDetailService;
  private final JwtAuthenticationFilter jwtAuthenticationFilter;

  @Bean
  public AuthenticationManager authenticationManager(
    AuthenticationConfiguration configuration) throws Exception {

    return configuration.getAuthenticationManager();
  }

  @Bean
  public UserDetailsService userDetailsService() {
    return customUserDetailService;
  }

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http.cors(Customizer.withDefaults())
      .csrf(AbstractHttpConfigurer::disable)
      .sessionManagement(session ->
        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .authorizeHttpRequests(auth -> auth
        .requestMatchers(Arrays.stream(ApiPathExclusion.values())
          .map(ApiPathExclusion::getPath)
          .toArray(String[]::new)).permitAll()
        .anyRequest().authenticated())
      .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
  }
}

The public URLs have been defined in ApiPathExclusion enum. You can add or remove the paths as per your requirements.

@Getter
@AllArgsConstructor
public enum ApiPathExclusion {

  SWAGGER_API_V2_DOCS("/v2/api-docs"),
  SWAGGER_RESOURCE_CONFIGURATION("/swagger-resources/configuration/ui"),
  SWAGGER_RESOURCES("/swagger-resources"),
  SWAGGER_RESOURCES_SECURITY_CONFIGURATION("/swagger-resources/configuration/security"),
  SWAGGER_UI_HTML("swagger-ui.html"),
  WEBJARS("/webjars/**"),
  SWAGGER_UI("/swagger-ui/**"),
  SWAGGER_API_V3_DOCS("/v3/api-docs/**"),
  SWAGGER_CONFIGURATION("/configuration/**"),
  SWAGGER("/swagger*/**"),
  HEALTH_CHECK("/health-check"),
  ACTUATOR("/actuator/**"),
  LOGIN("/login"),
  SIGN_UP("/sign-up"),
  OTP_VERIFICATION("/verify-otp"),
  REFRESH_TOKEN("/refresh-token");

  private final String path;
}

6. JWT Authentication Filter

Further, JwtAuthenticationFilter authenticates users based on a JWT (JSON Web Token) found in the Authorization header of incoming HTTP requests. It performs the following steps:

  • Extracts the Authorization header from the request and checks if it contains a JWT prefixed with "Bearer ".
  • If the JWT exists, it retrieves the user ID from the token.
  • It then checks if the SecurityContext is empty, meaning the user hasn’t already been authenticated.
  • Loads user details via customUserDetailService and then validates the JWT.
  • If the token is valid, it creates a UsernamePasswordAuthenticationToken to represent the authenticated user and sets it in the SecurityContext.
@Component
@AllArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

  private final JwtUtils jwtUtils;
  private final CustomUserDetailService customUserDetailService;

  @Override
  protected void doFilterInternal(HttpServletRequest request,
    HttpServletResponse response,
    FilterChain filterChain)
    throws ServletException, IOException {

    final var authorizationHeader = request.getHeader("Authorization");

    if (authorizationHeader != null) {
      if (authorizationHeader.startsWith("Bearer ")) {
        final var token = authorizationHeader.substring(7);
        final var userId = jwtUtils.extractUserId(token);

        if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {

          UserDetails userDetails = customUserDetailService.loadUserByUsername(userId.toString());

          if (jwtUtils.validateToken(token, userDetails)) {

            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
              new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            usernamePasswordAuthenticationToken
              .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext()
              .setAuthentication(usernamePasswordAuthenticationToken);
          }
        }
      }
    }
    filterChain.doFilter(request, response);
  }
}

The JwtUtils is a common utility class used in several places and provides methods to create, validate and extract information from JWT token.

import com.howtodoinjava.demo.entity.User;
import com.howtodoinjava.demo.security.configuration.properties.JwtConfigurationProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.AllArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

@Component
@EnableConfigurationProperties(JwtConfigurationProperties.class)
@AllArgsConstructor
public class JwtUtils {

  private final JwtConfigurationProperties jwtConfigurationProperties;

  public String extractEmail(final String token) {
    return extractClaim(token, Claims::getSubject);
  }

  public UUID extractUserId(final String token) {
    return UUID.fromString((String) extractAllClaims(token).get("user_id"));
  }

  public Date extractExpiration(final String token) {
    return extractClaim(token, Claims::getExpiration);
  }

  public <T> T extractClaim(final String token, final Function<Claims, T> claimsResolver) {
    final Claims claims = extractAllClaims(token);
    return claimsResolver.apply(claims);
  }

  private Claims extractAllClaims(final String token) {
    String secretKey = jwtConfigurationProperties.getJwt().getSecretKey();
    SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));

    return Jwts.parser()
      .verifyWith(key)
      .build()
      .parseSignedClaims(token)
      .getPayload();
  }

  public Boolean isTokenExpired(final String token) {
    return extractExpiration(token).before(new Date());
  }

  public String generateAccessToken(final User user) {
    Map<String, Object> claims = new HashMap<>();
    claims.put("account_creation_timestamp", user.getCreatedAt().toString());
    claims.put("user_id", user.getId());
    claims.put("email_id", user.getEmailId());
    claims.put("email_verified", user.isEmailVerified());
    return createToken(claims, user.getEmailId(), TimeUnit.HOURS.toMillis(1));
  }

  public String generateRefreshToken(final User user) {
    Map<String, Object> claims = new HashMap<>();
    return createToken(claims, user.getEmailId(), TimeUnit.DAYS.toMillis(15));
  }

  private String createToken(final Map<String, Object> claims, final String subject,
    final Long expiration) {
    String secretKey = jwtConfigurationProperties.getJwt().getSecretKey();
    SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));

    return Jwts.builder()
      .claims(claims)
      .subject(subject)
      .issuedAt(new Date(System.currentTimeMillis()))
      .expiration(new Date(System.currentTimeMillis() + expiration))
      .signWith(key)
      .compact();
  }

  public Boolean validateToken(final String token, final UserDetails userDetails) {
    final String username = extractEmail(token);
    return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
  }
}

7. CORS Filter

The CORS filter controls how a server’s resources (like APIs or assets) can be requested from a domain other than the one serving the resources. Browsers enforce the same-origin policy by default, which restricts web pages from requesting from a domain, subdomain, or port different from the one that served the web page.

CORS relaxes these restrictions to permit cross-origin requests and allow different kinds of clients to access the same set of APIs on the server. Feel free to configure the allowed origins and headers.

@Component
@Order(value = Ordered.HIGHEST_PRECEDENCE)
public class CORSFilter implements Filter {

  @Override
  public void init(FilterConfig filterConfig) {
  }

  @Override
  public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
    FilterChain chain) throws IOException, ServletException {

    HttpServletResponse response = (HttpServletResponse) servletResponse;
    HttpServletRequest request = (HttpServletRequest) servletRequest;
    response.setHeader("Access-Control-Allow-Origin", "*");
    response.setHeader("Access-Control-Allow-Methods", "POST, PUT, GET, OPTIONS, DELETE");
    response.setHeader("Access-Control-Max-Age", "3600");
    response.setHeader("Access-Control-Expose-Headers", "Authorization, token, last-visit-date");
    response.setHeader("Access-Control-Allow-Headers", "Authorization, Origin, X-Requested-With, Content-Type, Accept");

    if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
      response.setStatus(HttpServletResponse.SC_OK);
    } else {
      chain.doFilter(servletRequest, servletResponse);
    }
  }

  @Override
  public void destroy() {
  }
}

8. Send and Validate OTP

We have used Google Guava’s LoadingCache class to implement the OTP functionality. The LoadingCache is a semi-persistent class, i.e., it’s not persistent by default and stores entries in memory. A CacheLoader can specify how to retrieve or generate the value, often from an external data source, when needed.

The LoadingCache class provides useful methods for storing and searching the tokens in the map and automatically invalidates them when they expire.

@Configuration
@AllArgsConstructor
@EnableConfigurationProperties(OneTimePasswordConfigurationProperties.class)
public class OtpCacheBean {

  private final OneTimePasswordConfigurationProperties oneTimePasswordConfigurationProperties;

  @Bean
  public LoadingCache<String, Integer> loadingCache() {
    final var expirationMinutes = oneTimePasswordConfigurationProperties.getOtp()
      .getExpirationMinutes();
    return CacheBuilder.newBuilder()
      .expireAfterWrite(expirationMinutes, TimeUnit.MINUTES)
      .build(new CacheLoader<>() {
        public Integer load(String key) {
          return 0;
        }
      });
  }
}

The default expiration time can be stored in a properties file and supply it in runtime.

@Data
@ConfigurationProperties(prefix = "com.howtodoinjava.app")
public class OneTimePasswordConfigurationProperties {

  private OTP otp = new OTP();

  @Data
  public class OTP {

    private Integer expirationMinutes;
  }
}
com.howtodoinjava.app.otp.expiration-minutes=5

Next, we write the service methods to store and verify OTP tokens.

  • createAccount(): stores the user details in a database and sends OTP to the user’s email.
  • login(): verifies the user’s credentials and sends OTP to the user’s email.
  • sendOtp(): uses the EmailService to send the actual email.
  • verifyOtp(): verifies the OTP against the email.
@Slf4j
@Service
@AllArgsConstructor
public class UserService {

  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;
  private final LoadingCache<String, Integer> oneTimePasswordCache;
  private final EmailService emailService;
  private final JwtUtils jwtUtils;

  public ResponseEntity<?> createAccount(

    // save user...

    sendOtp(savedUser, "Verify your account");
    return ResponseEntity.ok(getOtpSendMessage());
  }

  public ResponseEntity<?> login(final LoginRequestDto userLoginRequestDto) {
    final User user = userRepository.findByEmailId(userLoginRequestDto.getEmailId())
      .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid login credentials"));

    if (!passwordEncoder.matches(userLoginRequestDto.getPassword(), user.getPassword())) {
      throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid login credentials");
    }

    if (!user.isActive()) {
      throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Account not active");
    }

    sendOtp(user, "2FA: Request to log in to your account");
    return ResponseEntity.ok(getOtpSendMessage());
  }

  public ResponseEntity<LoginSuccessDto> verifyOtp(final OtpVerificationRequestDto otpVerificationRequestDto) {

    User user = userRepository.findByEmailId(otpVerificationRequestDto.getEmailId())
      .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid email-id"));

    Integer storedOneTimePassword = null;
    try {
      storedOneTimePassword = oneTimePasswordCache.get(user.getEmailId());
    } catch (ExecutionException e) {
      log.error("FAILED TO FETCH PAIR FROM OTP CACHE: ", e);
      throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED);
    }

    if (storedOneTimePassword.equals(otpVerificationRequestDto.getOneTimePassword())) {

      if (otpVerificationRequestDto.getContext().equals(OtpContext.SIGN_UP)) {

        user.setEmailVerified(true);
        user = userRepository.save(user);
        return ResponseEntity
          .ok(LoginSuccessDto.builder().accessToken(jwtUtils.generateAccessToken(user))
            .refreshToken(jwtUtils.generateRefreshToken(user)).build());

      } else if (otpVerificationRequestDto.getContext().equals(OtpContext.LOGIN)) {
        return ResponseEntity
          .ok(LoginSuccessDto.builder().accessToken(jwtUtils.generateAccessToken(user))
            .refreshToken(jwtUtils.generateRefreshToken(user)).build());

      } else if (otpVerificationRequestDto.getContext().equals(OtpContext.ACCOUNT_DELETION)) {
        user.setActive(false);
        user = userRepository.save(user);
        return ResponseEntity.ok().build();
      }
      throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    } else {
      throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    }
  }

  private void sendOtp(final User user, final String subject) {
    oneTimePasswordCache.invalidate(user.getEmailId());

    final var otp = new Random().ints(1, 100000, 999999).sum();
    oneTimePasswordCache.put(user.getEmailId(), otp);
    log.info("OTP sent :: {}", otp);

    CompletableFuture.supplyAsync(() -> {
      emailService.sendEmail(user.getEmailId(), subject, "OTP: " + otp);
      return HttpStatus.OK;
    }).thenAccept(status -> {
      System.out.println("Email sent successfully with status: " + status);
    });
  }

  private Map<String, String> getOtpSendMessage() {
    final var response = new HashMap<String, String>();
    response.put("message",
      "OTP sent successfully sent to your registered email-address. verify it using /verify-otp endpoint");
    return response;
  }

  // Other methods ...
}

9. Email Service

We have written a very simple email service class with minimal functionality. You can customize it further as per your needs. It uses JavaMailSender API to send emails.

@Service
@AllArgsConstructor
@EnableConfigurationProperties(EmailConfigurationProperties.class)
public class EmailService {

	private final JavaMailSender javaMailSender;
	private final EmailConfigurationProperties emailConfigurationProperties;

	@Async
	public void sendEmail(String toMail, String subject, String messageBody) {

		final var simpleMailMessage = new SimpleMailMessage();
		simpleMailMessage.setFrom(emailConfigurationProperties.getUsername());
		simpleMailMessage.setTo(toMail);
		simpleMailMessage.setSubject(subject);
		simpleMailMessage.setText(messageBody);
		javaMailSender.send(simpleMailMessage);
	}
}

@Data
@ConfigurationProperties(prefix = "spring.mail")
public class EmailConfigurationProperties {

	private String username;
}

The Spring Mail module uses the properties defined in application.properties to connect with the SMPT server. Use your custom SMTP server in production.

spring.mail.host=smtp.gmail.com
[email protected]
spring.mail.password=************
spring.mail.port=587
spring.mail.properties.mail.smtp.starttls.enable=true

10. OpenAI Swagger Annotated REST Controllers

Finally, we will create the REST API controllers that will accept the requests and respond with responses. We have annotated the REST controllers with Swagger annotations. These annotations will generate great-looking API documentation for clients and a quick test facility for everybody.

The AuthenticationController has the methods related to 2FA authentication functionality, i.e., signup, login, and refresh token.

@RestController
@AllArgsConstructor
public class AuthenticationController {

  private final UserService userService;

  @PostMapping(value = "/sign-up", produces = MediaType.APPLICATION_JSON_VALUE)
  @ResponseStatus(value = HttpStatus.OK)
  @Operation(summary = "Creates a user account in the system")
  public ResponseEntity<?> userAccountCreationHandler(
    @RequestBody(required = true) final SignupRequestDto userAccountCreationRequestDto) {

    return userService.createAccount(userAccountCreationRequestDto);
  }

  @PostMapping(value = "/login", produces = MediaType.APPLICATION_JSON_VALUE)
  @ResponseStatus(value = HttpStatus.OK)
  @Operation(summary = "Endpoint to authenticate users credentials")
  public ResponseEntity<?> userLoginHandler(
    @RequestBody(required = true) final LoginRequestDto userLoginRequestDto) {

    return userService.login(userLoginRequestDto);
  }

  @PostMapping(value = "/verify-otp", produces = MediaType.APPLICATION_JSON_VALUE)
  @ResponseStatus(value = HttpStatus.OK)
  @Operation(summary = "verifies OTP and returns JWT corresponding to the user")
  public ResponseEntity<LoginSuccessDto> otpVerificationHandler(
    @RequestBody(required = true) final OtpVerificationRequestDto otpVerificationRequestDto) {

    return userService.verifyOtp(otpVerificationRequestDto);
  }

  @PutMapping(value = "/refresh-token", produces = MediaType.APPLICATION_JSON_VALUE)
  @ResponseStatus(value = HttpStatus.OK)
  @Operation(summary = "Returns new access_token")
  public ResponseEntity<?> tokenRefresherHandler(
    @RequestBody(required = true) final TokenRefreshRequestDto tokenRefreshRequestDto) {

    return userService.refreshToken(tokenRefreshRequestDto);
  }
}

The UserController has methods to retrieve the information of logged in user and delete the user account.

@RestController
@AllArgsConstructor
public class UserController {

  private final UserService userService;
  private final JwtUtils jwtUtils;

  @GetMapping(value = "/user", produces = MediaType.APPLICATION_JSON_VALUE)
  @ResponseStatus(value = HttpStatus.OK)
  @Operation(summary = "Returns logged in users account details")
  public ResponseEntity<?> loggedInUserDetailsRetrievalHandler(
    @Parameter(hidden = true) 
    @RequestHeader(required = true, name = "Authorization") final String header) {

    return userService.getDetails(jwtUtils.extractUserId(header));
  }

  @DeleteMapping(value = "/user", produces = MediaType.APPLICATION_JSON_VALUE)
  @ResponseStatus(value = HttpStatus.OK)
  @Operation(summary = "Deletes a user account")
  public ResponseEntity<?> userAccountDeletionHandler(
    @Parameter(hidden = true) 
    @RequestHeader(required = true, name = "Authorization") final String header) {

    return userService.deleteAccount(jwtUtils.extractUserId(header));
  }
}

11. Demo

Let’s start the application as a Spring boot and test the APIs. Load the API documentation in the browser by navigating to the URL: http://localhost:8080/swagger-ui/index.html

11.1. Signup Flow

Send a request to the API endpoint: /sign-up and verify the response that indicates that an OTP has been sent to the user email.

curl -X 'POST' \
  'http://localhost:8080/sign-up' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "emailId": "[email protected]",
  "password": "password"
}'
{
  "message": "OTP sent successfully sent to your registered email-address. verify it using /verify-otp endpoint"
}

Check your email. There should be an email.

Submit the OTP token via /verify-token API endpoint.

curl -X 'POST' \
  'http://localhost:8080/verify-otp' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "emailId": "[email protected]",
  "oneTimePassword": 813554,
  "context": "SIGN_UP"
}'
{
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJlbWFpbF9pZCI...",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJobYEf..."
}

You can verify the generated token at: https://jwt.io/

11.2. Login Flow

Similar to the signup process, we can use the /login endpoint to verify the user credentials, and it will send an OTP to the email.

curl -X 'POST' \
  'http://localhost:8080/login' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "emailId": "[email protected]",
  "password": "password"
}'
{
  "message": "OTP sent successfully sent to your registered email-address. verify it using /verify-otp endpoint"
}

Copy the received OTP in request and set the context to LOGIN.

curl -X 'POST' \
  'http://localhost:8080/verify-otp' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "emailId": "[email protected]",
  "oneTimePassword": 323911,
  "context": "LOGIN"
}'
{
  "accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJlbWFpbF9...",
  "refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJo..."
}

11.3. Test Authentication Success

After we receive the JWT, we can set it in the “Authorize” section of the documentation so it will sent with each ongoing request to the server.

Next, we will access the /joke endpoint, which is a secured endpoint and returns a joke.

we are able to authenticate the API requests using JWT token after successfully completing the 2FA authentination in this Spring security demo.

12. Summary

In this Spring security 2FA (two-factor authentication) example, we learned to implement the REST APIs supporting the 2FA based on OTPs sent to emails. We created APIs that can be used for signup and login flows involving the genrating the OTPs and validating the OTPs.

We also intregated the Jwt token functionality so the 3rd-part API clients can also use the APi without forcing them to use a defined login screen.

Happy Learning !!

Source Code on Github

Weekly Newsletter

Stay Up-to-Date with Our Weekly Updates. Right into Your Inbox.

Comments

Subscribe
Notify of
0 Comments
Most Voted
Newest Oldest
Inline Feedbacks
View all comments

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.