Home | Send Feedback

Spring Security second-factor authentication with TOTP

Published: June 21, 2019  •  Updated: April 05, 2020  •  java, spring

In this blog post, we are going to implement an authentication system with Spring Security that uses username and password and TOTP (Time-based One-Time Password) as the second factor.

We implement this system as a Spring Boot application. The application uses jOOQ to access the user information that is stored in a file-based H2 database.

The client is an Angular web application written in TypeScript and uses the UI library PrimeNG. But the focus for this blog post is the Java code, and I don't discuss the client code. The solution I present here should work with any client-side framework. If you are interested in the client code, you find it on GitHub: https://github.com/ralscha/springsecuritytotp/tree/master/client

This is my second attempt to implement a TOTP solution. A reader of the initial blog post pointed out a severe security flaw. If you are wondering why certain things are implemented as they are, check out this Github issue. There you find more details about why certain parts are implemented in the way they are.

TOTP

TOTP (Time-based One-Time Password) is a mechanism that is added as the second factor to a username/password authentication flow, to increase security.

TOTP is an algorithm based on the HOTP (HMAC-based One-time Password) but uses a time-based component instead of a counter.

TOTP and HOTP depend on a secret that two parties share. The secret is a randomly generated token that is usually displayed in Base32 to the user. During the sign-up process, the server generates the secret, stores it into the database, and shows it to the user. The user then types or copies the secret into an authenticator app that supports TOTP.

There are many TOTP apps available for mobile devices, for desktops and for browsers. I use the Google Authenticator on an Android device.

Demo application

You find the source for the demo application on GitHub:
https://github.com/ralscha/springsecuritytotp

Here is the online demo: https://totp-omed.hplar.ch/

The server directory contains the Spring Boot application and can be started from a shell with ./mvnw spring-boot:run. The Angular application is located in the client folder, and you can start it with ng serve.

The first time you start the server, it creates the database and inserts the following three users.

Username Password Secret
admin admin W4AU5VIXXCPZ3S6T
user user LRVLAZ4WVFOU3JBF
lazy lazy

The demo application supports users with and without 2nd-factor authentication (2FA).

Install a TOTP authenticator app, create a new entry with the given secret.

You can either scan the QR code or enter the secret manually. The QR code is also "clickable" because the image is wrapped in a <a> tag with a otpauth:// href. A click on such a link should open an installed authenticator app.

Base

The server application is a regular Spring Boot application, created with Spring Initializr. I added security, jooq, flyway, and web as dependencies. Open the pom.xml to see all the dependencies.

The application uses jOOQ to access the database and Flyway for database migrations. The setup in this application follows the description in my blog post about jOOQ


I also added aerogear-otp as an additional dependency to the project.

    <dependency>
      <groupId>org.jboss.aerogear</groupId>
      <artifactId>aerogear-otp-java</artifactId>
      <version>1.0.0</version>
    </dependency>

pom.xml

The library provides methods for verifying TOTP codes and for generating secrets. Because I had a few additional requirements, I wrote my own TOTP verifier, based on the aerogear-top code.


Database

The demo application uses this table to store user information.

CREATE TABLE app_user (
    id            BIGINT NOT NULL AUTO_INCREMENT,
    username      VARCHAR(255) NOT NULL,
    password_hash VARCHAR(255),
    secret        VARCHAR(16),
    enabled       BOOLEAN not null,
    additional_security BOOLEAN not null,
    PRIMARY KEY(id),
    UNIQUE(username)
);



V0001__initial.sql

username and password_hash are used for the traditional username/password login (first factor).

secret is required for the second-factor authentication with TOTP. This is the code that the client and server have to share. You see how the demo application exchanges this secret in the sign-up process.

additional_security is an important flag used during the sign-in workflow. Initially, this flag is false. When a user enters the wrong TOTP code, this flag will be set to true, and an additional security verification is required. You learn more about this flag in the sign-in section.

enabled is used for the registration process. The sign-up workflow consists of two steps. The user is inserted into the database during the first step. But at this time, the application doesn't know if the user finishes the registration process. So the handler that inserts the user sets this flag to false. Because this flag is false, the user can't log in yet. When the user finishes the sign-up process successfully, the handler changes the value of this field to true.

Spring Security

For this application, I wrote my own authentication system, so I disabled the Spring Boot auto-configuration of Spring Security with the following code.

  @Bean
  @Override
  protected AuthenticationManager authenticationManager() throws Exception {
    return authentication -> {
      throw new AuthenticationServiceException("Cannot authenticate " + authentication);
    };
  }

SecurityConfig.java

The application still uses Spring Security for authorization. This code only disables the authentication part of Spring Security.


The application uses Argon2 for password hashing.

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new Argon2PasswordEncoder(16, 32, 8, 1 << 16, 4);
  }

SecurityConfig.java

The Spring Security documentation recommends to tune the parameters so that it takes about 1 second to verify a password on your system.

Note that Argon2PasswordEncoder is a class provided by the Spring Security library, but it depends on Bouncy Castle. Add the following dependency to your project.

    <dependency>
      <groupId>org.bouncycastle</groupId>
      <artifactId>bcprov-jdk15on</artifactId>
      <version>1.68</version>
    </dependency>

pom.xml


This demo leverages the traditional HTTP session with session cookie approach. This is not a requirement for TOTP and you can use other authentication workflows like JWT.

The application configures Spring Security with the following code.

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.csrf(customizer -> customizer.disable()).authorizeRequests(customizer -> {
      customizer
          .antMatchers("/authenticate", "/signin", "/verify-totp",
              "/verify-totp-additional-security", "/signup", "/signup-confirm-secret")
          .permitAll().anyRequest().authenticated();
    }).logout(customizer -> customizer
        .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()));
  }

SecurityConfig.java

I disable CSRF protection in this demo application. Don't do that in a production application if you use session cookies for authentication. Unless you only target modern browsers and use same-site=strict cookies, which is a protection against CSRF attacks. See my blog post, where I demonstrate a solution with same-site cookies.

The code then configures a list of endpoints that don't need authentication. These are all part of the sign-up and sign-in workflow. I describe these endpoints in more detail in the following sections.

Every endpoint that is not listed can't be accessed without authentication.

Lastly, the application configures the logout handler. This handler by defaults sends back a redirect request, but for single-page applications, it is easier when the endpoint just returns an HTTP status code. This is what HttpStatusReturningLogoutSuccessHandler does; it returns by default the status 200. You can change this by passing another code to the constructor.

Sign Up

sign up UI workflow

The sign-up workflow consists of three pages. On the first page, the user enters his username and password, and if he wants to enable two-factor authentication. If he selects the 2FA checkbox, the application displays a random secret as a QR code on the next page (1a). The user has to create a new entry in his authenticator app and enter the given secret. Then he has to verify the registration with the code the authenticator app shows him. The application displays a success message if the verification code is valid (2).


1. Username and Password

sign-up-step1

The client application sends username, password and the value of the 2FA checkbox to the /signup endpoint. This handler receives the three input values as request parameters and sends back SignupResponse, which is converted into a JSON.

  @PostMapping("/signup")
  public SignupResponse signup(@RequestParam("username") @NotEmpty String username,
      @RequestParam("password") @NotEmpty String password,
      @RequestParam("totp") boolean totp) {

SignupController.java

SignupResponse contains the following fields.

public class SignupResponse {

  enum Status {
    OK, USERNAME_TAKEN, WEAK_PASSWORD
  }

  private final Status status;

  private final String username;

  private final String secret;

SignupResponse.java

The /signup handler first checks with a SQL select statement if there is already a user registered with the same username. If yes, the handler returns the status USERNAME_TAKEN.

    int count = this.dsl.selectCount().from(APP_USER)
        .where(APP_USER.USERNAME.equalIgnoreCase(username)).fetchOne(0, int.class);
    if (count > 0) {
      return new SignupResponse(SignupResponse.Status.USERNAME_TAKEN);
    }

SignupController.java

Next, the handler checks if the password conforms with the configured password policy. This application leverages the passpol library for this purpose. The handler returns the status WEAK_PASSWORD if the given password fails the policy check.

    Status status = this.passwordPolicy.check(password);
    if (status != Status.OK) {
      return new SignupResponse(SignupResponse.Status.WEAK_PASSWORD);
    }

SignupController.java

The handler has to create a secret if the user selected the 2FA checkbox. The method then inserts the user into the database and sends back an OK response with username and secret. Note that the application sets the enabled field to false. The user is not fully registered yet, because he has to validate the TOTP code in the second step. There are other ways to implement that. You could, for example, store the user information in the HTTP session and only insert the user if the validation was successful.

    if (totp) {
      String secret = Base32.random();

      this.dsl
          .insertInto(APP_USER, APP_USER.USERNAME, APP_USER.PASSWORD_HASH,
              APP_USER.ENABLED, APP_USER.SECRET, APP_USER.ADDITIONAL_SECURITY)
          .values(username, this.passwordEncoder.encode(password), false, secret, false)
          .execute();
      return new SignupResponse(SignupResponse.Status.OK, username, secret);
    }

SignupController.java

If the user did not enable 2FA, the handler inserts the user and sets enabled to true. The user can now log in.

    this.dsl
        .insertInto(APP_USER, APP_USER.USERNAME, APP_USER.PASSWORD_HASH, APP_USER.ENABLED,
            APP_USER.SECRET, APP_USER.ADDITIONAL_SECURITY)
        .values(username, this.passwordEncoder.encode(password), true, null, false)
        .execute();
    return new SignupResponse(SignupResponse.Status.OK);

SignupController.java


2. Verification

sign-up-step2

After the user created a new entry in his authenticator app, he enters the current TOTP code into the field. The client sends this code to the /signup-confirm-secret endpoint.

The handler reads the user from the database and verifies the code. If the user exists and the TOTP code is valid, the handler sets the enabled field on the user record to true and responds with true.

  @PostMapping("/signup-confirm-secret")
  public boolean signupConfirmSecret(@RequestParam("username") String username,
      @RequestParam("code") @NotEmpty String code) {

    var record = this.dsl.select(APP_USER.ID, APP_USER.SECRET).from(APP_USER)
        .where(APP_USER.USERNAME.eq(username)).fetchOne();
    if (record != null) {
      String secret = record.get(APP_USER.SECRET);
      Totp totp = new Totp(secret);
      if (totp.verify(code)) {
        this.dsl.update(APP_USER).set(APP_USER.ENABLED, true)
            .where(APP_USER.ID.eq(record.get(APP_USER.ID))).execute();
        return true;
      }
    }

    return false;
  }

SignupController.java

Sign In

sign in UI workflow

The sign-in workflow consists of three pages and the home page, which is only displayed when the login was successful. The user enters his username and password on the first page, and the application sends them to the back end.

If the username and password are correct, the application redirects the user to a second dialog where he has to enter his current TOTP code (1a). Users without 2FA are redirected directly to the home page (1b).

If the TOTP code is correct, the application displays the home screen (2a). If the user enters an incorrect code, the application sets the user into the "additional verification" mode by setting the database field additional_security to true, and displaying a mask where the user has to enter three consecutive TOTP codes (2b).

The purpose of this "additional verification" mode is to prevent brute force attacks. Imaging an attacker knows the username and password of a user. The TOTP code is only a 6 digit number, so there are only 1 million possible codes. The code changes every 30 seconds, this demo application considers codes that are up to 1 minute in the past and up to 1 minute in the future as valid, so at any time, 5 codes are valid. An attacker only has to send multiple requests and try all TOTP codes between 000000 and 999999 until the system lets him in, and the chance that this is happening is quite high.

A legitim user can also enter the "additional verification" mode. Either by mistyping the TOTP code, or when the system clock on his device is not in sync with the clock on the server. This demo application only tolerates a difference of +1 and -1 minute.

After the user enters the three consecutive codes, the server validates them. This validation considers every TOTP code in the period -25 and +25 hours. If they are correct and consecutive, the application resets the flag in the database and let the user into the application.


0. Application Start

sign-in-step-1

The first action the application does when it has been started, is to sends a GET request to the /authenticate endpoint. This endpoint checks if the user is logged in or not. Depending on the server response, the web client either presents the sign-in dialog or the home page.

To check if a user is logged in, the application fetches the authentication object from the security context. This object is of type AppUserAuthentication, and the application inserts the object into the security context after a successful sign-in attempt.

  @GetMapping("/authenticate")
  public AuthenticationFlow authenticate(HttpServletRequest request) {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth instanceof AppUserAuthentication) {
      return AuthenticationFlow.AUTHENTICATED;
    }

    HttpSession httpSession = request.getSession(false);
    if (httpSession != null) {
      httpSession.invalidate();
    }

    return AuthenticationFlow.NOT_AUTHENTICATED;
  }

AuthController.java


1. Username + Password

sign-in-step-2

The signin endpoint receives username and password. First, it fetches the user record from the database.

  @PostMapping("/signin")
  public ResponseEntity<AuthenticationFlow> login(@RequestParam String username,
      @RequestParam String password, HttpSession httpSession) {

    AppUserRecord appUserRecord = this.dsl.selectFrom(APP_USER)
        .where(APP_USER.USERNAME.eq(username)).fetchOne();

AuthController.java

If the user exists, the handler checks if the given password matches the password in the database.

    if (appUserRecord != null) {
      boolean pwMatches = this.passwordEncoder.matches(password,
          appUserRecord.getPasswordHash());
      if (pwMatches && appUserRecord.getEnabled().booleanValue()) {

AuthController.java

If the passwords match, the handler creates an AppUserAuthentication instance, and stores it into the HTTP session when the user has 2FA enabled. If the user is in "additional verification" mode, the handler sends back the string "TOTP_ADDITIONAL_SECURITY" in the response body, otherwise "TOTP".

If the user does not have 2FA enabled, the code puts the authentication object into the security context and sends back the string "AUTHENTICATED". Spring Security handles the authentication object from here on. It stores it into the HTTP session, and it creates a session cookie.

        AppUserDetail detail = new AppUserDetail(appUserRecord);
        AppUserAuthentication userAuthentication = new AppUserAuthentication(detail);
        if (isNotBlank(appUserRecord.getSecret())) {
          httpSession.setAttribute(USER_AUTHENTICATION_OBJECT, userAuthentication);

          if (isUserInAdditionalSecurityMode(detail.getAppUserId())) {
            return ResponseEntity.ok().body(AuthenticationFlow.TOTP_ADDITIONAL_SECURITY);
          }

          return ResponseEntity.ok().body(AuthenticationFlow.TOTP);
        }

        SecurityContextHolder.getContext().setAuthentication(userAuthentication);
        return ResponseEntity.ok().body(AuthenticationFlow.AUTHENTICATED);
      }
    }

AuthController.java

If the user does not exist, the handler runs a fake plaintext password through the password encoder. This is important to hide the fact that the user does not exist. If the method would just return without this step, an attacker can deduce from the difference in response time if a user exists or not.
The handler returns "NOT_AUTHENTICATED" in the body of the response.

    else {
      this.passwordEncoder.matches(password, this.userNotFoundEncodedPassword);
    }

    return ResponseEntity.ok().body(AuthenticationFlow.NOT_AUTHENTICATED);
  }

AuthController.java


2. TOTP code

sign-in-step-3

The /verify-totp receives the TOTP code and checks if an AppUserAuthentication instance is stored in the HTTP session. If there is no such object, the method returns the string NOT_AUTHENTICATED in the response body. That means the user did not sign with username/password.

  @PostMapping("/verify-totp")
  public ResponseEntity<AuthenticationFlow> totp(@RequestParam String code,
      HttpSession httpSession) {
    AppUserAuthentication userAuthentication = (AppUserAuthentication) httpSession
        .getAttribute(USER_AUTHENTICATION_OBJECT);
    if (userAuthentication == null) {
      return ResponseEntity.ok().body(AuthenticationFlow.NOT_AUTHENTICATED);
    }

AuthController.java

Next, the handler has to check if the user is in "additional verification" mode. This is, as explained before, to thwart brute force attacks. If the user is in this mode, the method returns the string "TOTP_ADDITIONAL_SECURITY".

    AppUserDetail detail = (AppUserDetail) userAuthentication.getPrincipal();
    if (isUserInAdditionalSecurityMode(detail.getAppUserId())) {
      return ResponseEntity.ok().body(AuthenticationFlow.TOTP_ADDITIONAL_SECURITY);
    }

AuthController.java

If the user is not in "additional verification" mode, verify the given TOTP code with the secret stored in the database. If the code is valid, the method puts the AppUserAuthentication instance into the security context and sends back "AUTHENTICATED".
If the code is invalid, the application puts the user into "additional verification" mode by setting the additional_security field in the user record to true, and then sends back the response "TOTP_ADDITIONAL_SECURITY".

    String secret = ((AppUserDetail) userAuthentication.getPrincipal()).getSecret();
    if (isNotBlank(secret) && isNotBlank(code)) {
      CustomTotp totp = new CustomTotp(secret);
      if (totp.verify(code, 2, 2).isValid()) {
        SecurityContextHolder.getContext().setAuthentication(userAuthentication);
        return ResponseEntity.ok().body(AuthenticationFlow.AUTHENTICATED);
      }

      setAdditionalSecurityFlag(detail.getAppUserId());
      return ResponseEntity.ok().body(AuthenticationFlow.TOTP_ADDITIONAL_SECURITY);
    }

    return ResponseEntity.ok().body(AuthenticationFlow.NOT_AUTHENTICATED);
  }

AuthController.java

With the 2nd and 3rd argument of the verify() method, you can tweak how many 30-seconds intervals into the past and the future the method should check. Here the method is configured to check 2 intervals into the past and the future.


3. Additional Security Verification

sign-in-step-4

The /verify-totp-additional-security endpoint receives the three TOTP codes as request parameters and checks if an AppUserAuthentication instance is stored in the HTTP session. If not then the user did not sign in with username and password and the method returns "NOT_AUTHENTICATED"

  @PostMapping("/verify-totp-additional-security")
  public ResponseEntity<AuthenticationFlow> verifyTotpAdditionalSecurity(
      @RequestParam String code1, @RequestParam String code2, @RequestParam String code3,
      HttpSession httpSession) {

    AppUserAuthentication userAuthentication = (AppUserAuthentication) httpSession
        .getAttribute(USER_AUTHENTICATION_OBJECT);
    if (userAuthentication == null) {
      return ResponseEntity.ok().body(AuthenticationFlow.NOT_AUTHENTICATED);
    }

AuthController.java

Next, the handler has to check if the three codes are valid and consecutive. He does that with the help of the verify() method of the CustomTotp class. This method expects the codes in a List as the first argument. 2nd and 3rd argument define the number of 30-second intervals the method should check. This example goes back 25 hours and forward 25 hours.

    if (code1.equals(code2) || code1.equals(code3) || code2.equals(code3)) {
      return ResponseEntity.ok().body(AuthenticationFlow.NOT_AUTHENTICATED);
    }

    String secret = ((AppUserDetail) userAuthentication.getPrincipal()).getSecret();
    if (isNotBlank(secret) && isNotBlank(code1) && isNotBlank(code2)
        && isNotBlank(code3)) {
      CustomTotp totp = new CustomTotp(secret);

      // check 25 hours into the past and future.
      long noOf30SecondsIntervals = TimeUnit.HOURS.toSeconds(25) / 30;
      CustomTotp.Result result = totp.verify(List.of(code1, code2, code3),
          noOf30SecondsIntervals, noOf30SecondsIntervals);

AuthController.java

The verify() method returns two values, a boolean (valid) that says if the codes are valid. Valid means the method found the three codes in the given time range, and they are consecutive. If valid, the method also returns an integer that states the number of 30-second intervals the codes are in the past or in the future. The handler stores this value into the session attribute "totp-shift", if it is outside the acceptable range of -2 and 2

If the three codes are valid, the handler sets the additional_security field in the user record back to false, puts the AppUserAuthentication instance into the security context and sends back "AUTHENTICATED.

If the three codes are invalid, the handler just returns "NOT_AUTHENTICATED", without further action.

      if (result.isValid()) {
        if (result.getShift() > 2 || result.getShift() < -2) {
          httpSession.setAttribute("totp-shift", result.getShift());
        }

        AppUserDetail detail = (AppUserDetail) userAuthentication.getPrincipal();
        clearAdditionalSecurityFlag(detail.getAppUserId());
        httpSession.removeAttribute(USER_AUTHENTICATION_OBJECT);

        SecurityContextHolder.getContext().setAuthentication(userAuthentication);
        return ResponseEntity.ok().body(AuthenticationFlow.AUTHENTICATED);
      }
    }

    return ResponseEntity.ok().body(AuthenticationFlow.NOT_AUTHENTICATED);
  }

AuthController.java


4. TOTP time shift

sign-in-step-5

When the user entered the correct three codes, the application logs the user in and displays a message about how big the time difference is between the client and the server clock. To get this information, the client sends a GET request to the /totp-shift endpoint.

This handler checks the session attribute "totp-shift" that the application set in the previous step. If it does exist, the handler creates a human-readable string and sends it back to the client. The handler returns null if there is not "totp-shift" session attribute.


You've reached the end of this tutorial about setting up a Spring Security authentication system with username/password and TOTP as second-factor. If you find a bug or security issue open an issue on GitHub. If you have other questions send me a message.