Two-factor authentication for Spring Webflux APIs

Multi-factor authentication became a common practice for many cases, especially for enterprise ones, or those that deal with sensitive data (like finance apps). Moreover, MFA is enforced (especially in the EU) by law in a growing number of industries, and if you are working on the app, that by some requirement, has to enable two-factor auth in some way, don’t hesitate to check this post.

In this article I will show how to write a two-factor authentication for reactive API, built with Spring Webflux. This app uses TOTP (one-time codes, generated by app on the user device – like Google Authenticator) as the second security factor, alongside with email and password pairs.

How two-factor authentication does work?

Technically, two-factor authentication (or multi-factor authentication) stands for a security process, where users have to provide 2 or more factors to verify themselves. That means, that usually user supplies alongside a password also an another factor. It can be one-time password, hardware tokens, biometric factors (like fingerprint) etc.

Basically, such practice requires several steps:

  1. User enters email (username) and password
  2. Alongside credentials, user submits one-time code, generated by an authenticator app
  3. App authenticates email (username), password and verifies one-time code, using user’s secret key, issued during signup process

That means, that usage of authetnicator apps (like Google Authenticator, Microsoft Authenticator, FreeOTP etc) brings a number of advantages, compare to an usage of SMS for code delivery. They are not affected by SIM attacks and work without cell/internet connection.

An example application

Throught this post we will complete a small simple REST API, which uses two-factor authentication tecniques. It requires users to provide both email-password pair and a short code, generated by an app. You can use any compatible app to generate TOTP; I use Google Authenticator for Android. The source code is available in this github repository. The app requires JDK 11, Maven and MongoDB (to store user profiles). Take a look on the project structure:

Figure 1. The structure of the sample application

I will not go through each component, rather we will concentrate only on AuthService (and implementation), TokenManager (and implementation) and TotpManager (and implementation) – these parts are responsible for authentication flows. Each of them provides following functionality:

  • AuthService – is a component, that stores all business logic, related to authentication/authorization, including signup, login and token validation
  • TokenManager – this component abstracts a code to generate and validate JWT tokens. This makes main business logic implementation-independent from concrete JWT libraries. In this post I use Nimbus JOSE-JWT
  • TotpManager – another abstraction to isolate implementation from base logic. It is used to generate user’s secret and to assert supplied short codes. I do this with this TOTP Java library, but there are other choices as well.

As you can note, I will focus only auth components. We will start from user creation process (signup), which requires also the secret’s generation and issue of token. Then we will do login flow, which involves also an assertion of short code, supplied by the user.

Update: this post covers only service layer (e.g. business logic). I recommend you to check this post to know more about handler layer implementation (HTTP). It uses same codebase.

Implement a signup flow

In this section we will complete a signup process, that involves following steps:

  • Get signup request from client
  • Check that user does not exists already
  • Hash password
  • Generate a secret key
  • Store user in database
  • Issue JWT
  • Return a response with user ID, secret key and token

I separated main business logic (AuthServiceImpl) from token generation and secret key generation.

General steps

The main component AuthServiceImpl accepts SignupRequest and returns SignupResponse. Behind the scenes, it is responsible for whole signup logic. First, take a look on its implementation:

@Override
public Mono<SignupResponse> signup(SignupRequest request) {

    // generating a new user entity params
    // step 1
    String email = request.getEmail().trim().toLowerCase();
    String password = request.getPassword();
    String salt = BCrypt.gensalt();
    String hash = BCrypt.hashpw(password, salt);
    String secret = totpManager.generateSecret();

    User user = new User(null, email, hash, salt, secret);

    // preparing a Mono
    Mono<SignupResponse> response = repository.findByEmail(email)
            .defaultIfEmpty(user) // step 2
            .flatMap(result -> {
                // assert, that user does not exist
                // step 3
                if (result.getUserId() == null) {
                    // step 4
                    return repository.save(result).flatMap(result2 -> {
                        // prepare token
                        // step 5
                        String userId = result2.getUserId();
                        String token = tokenManager.issueToken(userId);
                        SignupResponse signupResponse = new SignupResponse();
                        signupResponse.setUserId(userId);
                        signupResponse.setSecretKey(secret);
                        signupResponse.setToken(token);
                        signupResponse.setSuccess(true);
                        return Mono.just(signupResponse);
                    });
                } else {
                    // step 6
                    // scenario - user already exists
                    SignupResponse signupResponse = new SignupResponse();
                    signupResponse.setSuccess(false);
                    return Mono.just(signupResponse);
                }
            });
    return response;
}

Now, let go step by step through my implementation. Basically, we can two possible scenarios with signup process: user is new and we sign it up or it is already presented in database, so we have to reject it.

Consider these steps:

  1. We create a new user entity from the request data and generate a secret
  2. Provide the new entity as default, if user does not exist
  3. Check, the repository’s call result
  4. Save user in the database and obtain a userId
  5. Issue a JWT
  6. Return a rejecting response, if user does already exist

Note, that here I use jBcrypt library in order to generate secure hashes and salts. This is more acceptable practice, than using SHA functions: please, don’t use them in order to produce hashes, as they are known for vulnerabilities and security issues. I can advice you to check this tutorial on using jBcrypt to get more information.

Generate a secret key

Next, we need to implement a function to generate new secret key. It is abstracted inside TotpManager.generateSecret(). Take a look on the code below:

@Override
public String generateSecret() {
    SecretGenerator generator = new DefaultSecretGenerator();
    return generator.generate();
}

Testing

After the signup logic was implemented, we can validate that everything works as expected. First, let call signup endpoint to create a new user. Result object contains userId, token and secret key, that you need to add to the app generator (Google Authenticator):

Graph 2. Successful signup

However, we should not be allowed to signup twice with same email. Let try this case to assert, that app actually looks for the existed email before creating a new user:

Graph 3. Signup denied due to the fact, that user does already exist in database

The next section deals with a login flow.

Do login

The log in process consists of two main parts: validating email-password credentials and validating of one time code, supplied by user. As in the previous section, I start with presenting required steps. For login it would be:

  • Get login request from client
  • Find user in database
  • Asserting existing password with supplied at request
  • Asserting one time code
  • Return login response with token

JWT generation process is same as in the signup stage.

General steps

Main business logic is implemented in AuthServiceImpl.login. It does the majority of work. First we need to find a user by request email in database, otherwise we provided a default value with null fields. The condition user.getUserId() == null means that user does not exist and signup flow should be aborted.

Next, we need to assert that passwords match. As we stored password hash in the database, we need first to hash a password from request with the stored salt and then asserts both values:

If passwords matched, we need to verify a code using the secret value, which we stored before. The successful result of validation is followed by generation of JWT and creation of LoginResponse object. The final source code for this part is presented to you below:

@Override
public Mono<LoginResponse> login(LoginRequest request) {
    String email = request.getEmail().trim().toLowerCase();
    String password = request.getPassword();
    String code = request.getCode();
    Mono<LoginResponse> response = repository.findByEmail(email)
    // step 1
            .defaultIfEmpty(new User())
            .flatMap(user -> {
                // step 2
                if (user.getUserId() == null) {
                    // no user
                    LoginResponse loginResponse = new LoginResponse();
                    loginResponse.setSuccess(false);
                    return Mono.just(loginResponse);
                } else {
                    // step 3
                    // user exists
                    String salt = user.getSalt();
                    String secret = user.getSecretKey();
                    boolean passwordMatch = BCrypt.hashpw(password, salt).equalsIgnoreCase(user.getHash());
                    if (passwordMatch) {
                        // step 4
                        // password matched
                        boolean codeMatched = totpManager.validateCode(code, secret);
                        if (codeMatched) {
                            // step 5
                            String token = tokenManager.issueToken(user.getUserId());
                            LoginResponse loginResponse = new LoginResponse();
                            loginResponse.setSuccess(true);
                            loginResponse.setToken(token);
                            loginResponse.setUserId(user.getUserId());
                            return Mono.just(loginResponse);
                        } else {
                            LoginResponse loginResponse = new LoginResponse();
                            loginResponse.setSuccess(false);
                            return Mono.just(loginResponse);
                        }
                    } else {
                        LoginResponse loginResponse = new LoginResponse();
                        loginResponse.setSuccess(false);
                        return Mono.just(loginResponse);
                    }
                }
            });
    return response;
}

So, let observe steps:

  1. Provide a default user entity with null fields
  2. Check that user does exist
  3. Produce a hash of password from request and salt, stored in a database
  4. Assert that passwords do match
  5. Validating one-time code and issue JWT

Asserting one time codes

To validate one time codes, generated by apps, we have to provide for TOTP library both code and secret, that we saved as a part of user entity. Take a look on the implementation:

@Override
public boolean validateCode(String code, String secret) {
    TimeProvider timeProvider = new SystemTimeProvider();
    CodeGenerator codeGenerator = new DefaultCodeGenerator();
    CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
    return verifier.isValidCode(secret, code);
}

Testing

Finally, we can do a testing to verify that the login process works as we planned. Let call login endpoint with login request payload, that contains a generated code from Google Authenticator:

Graph 4. Successful login

An another case to check is the wrong password. No matter if we have correct code or not, the process should be terminated on the password assertion stage:

Graph 5. Login denied because of wrong password

That is all for this post. We have created a simple REST API to provide a two-factor authentication with TOTP for Spring Webflux. As I mentioned already, we have omitted everything, in order to concentrate only on auth logic. That means, you can find a full code for this article in this github repository. Feel free to check it! If you have questions, don’t hesitate to contact me.

References

  • Dhiraj Ray Password Encryption and Decryption Using jBCrypt (2017) DZone access here
  • Sanjay Patel Using Nimbus JOSE + JWT in Spring Applications — Why and How (2018) Natural Programmer Blog access here
  • Scott Brady Creating Signed JWTs using Nimbus JOSE + JWT (2019) access here
Share via
Copy link
Powered by Social Snap