loading...

Beefing Up Your Spring Security with Two-Factor Authentication

cr0wst profile image Steve Crow Originally published at smcrow.net on ・10 min read

Two-factor authentication adds an extra layer of security to your web application by asking users to provide a second form of identification. Common second factors include:

  • Authenticator codes
  • Biometrics
  • Email or Text Message codes

Let’s explore how you can add two-factor authentication to an existing web application by utilizing Nexmo.

Before you begin

In order to follow along with the tutorial you will need the following:

Get the Code

Clone the getting-started branch.

git clone https://github.com/cr0wst/demo-twofactor.git -b getting-started
cd demo-twofactor

Let’s See What We’re Working With

The example application is built using Spring Boot. If you have Gradle installed on your system, you should be able to execute the bootRun task to start the application.

If not, no worries; the repository contains a Gradle wrapper which will still allow you to execute tasks.

./gradlew bootRun

This will download any dependencies, compile the application, and start the embedded server.

Once the server has been started, you should be able to navigate to http://localhost:8080 to see the sample application.

There are three pages:

  • The home page - accessible by everybody.
  • The login page - allows users to enter a username and password (default is demo/demo).
  • The secret page - accessible only by users with the Role.USERS role.

Adding Two-Factor Authentication

When users log in, our only acceptance criteria is that they have provided a username and a password. What if this information was stolen? What is something that we could use which is physically located near the user?

There is something that I guarantee almost 90% of you, and our users, have within arm’s reach. A mobile phone.

Here’s how it’s going to work:

  1. The user will log in to our application as they normally do.
  2. They will be prompted to enter a four-digit verification code.
  3. Simultaneously, a four-digit verification code will be sent to the phone number on their account. If they don’t have a phone number on their account, we will allow them to bypass the two-factor authentication.
  4. The code that they enter will be checked to make sure it is the same one that we sent them.

We are going to utilize the Nexmo Verify API to generate the code and to check and see if the code they entered is valid.

Creating a New Role

The first step will be to create a new role. This role will be used to hold the authenticated user in a purgatory state until we have verified their identity.

Add the PRE_VERIFICATION_USER role to the Role enum.

// src/main/net/smcrow/demo/twofactor/user/Role.java
public enum Role implements GrantedAuthority {
    USER, PRE_VERIFICATION_USER;
    // ...
}

In order for it to be applied as the default role, we need to update the getAuthorities() method of the StandardUserDetails class.

// src/main/net/smcrow/demo/twofactor/user/StandardUserDetails.java
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    Set<GrantedAuthority> authorities = new HashSet<>();
    authorities.add(Role.PRE_VERIFICATION_USER);
    return authorities;
}

Handling Verification Information

Nexmo will provide us with a request id that will be used when confirming the code provided by the user. There are a variety of ways we can store this information. In this tutorial, we will be persisting it into a database.

Storing Verification Information

First, create a Verification class in the verify package.

// src/main/net/smcrow/demo/twofactor/verify/Verification.java
@Entity
public class Verification {
    @Id
    @Column(unique = true, nullable = false)
    private String phone;

    @Column(nullable = false)
    private String requestId;

    @Column(nullable = false)
    private Date expirationDate;

    @PersistenceConstructor
    public Verification() {
        // Empty constructor for JPA
    }

    public Verification(String phone, String requestId, Date expirationDate) {
        this.phone = phone;
        this.requestId = requestId;
        this.expirationDate = expirationDate;
    }

    // ... Getters and Setters
}

Notice, we are also storing the expirationDate. By default, the Verify API requests are only valid for five minutes. They will get deleted from the table when either:

  • The user has successfully verified their identity.
  • They are expired.

We will take advantage of Spring scheduler to clean them up periodically.

Working with the Verification Information

Create the VerificationRepository interface in the verify package.

// src/mainnet/smcrow/demo/twofactor/verify/VerificationRepository.java
@Repository
public interface VerificationRepository extends JpaRepository<Verification, String> {
    Optional<Verification> findByPhone(String phone);

    void deleteByExpirationDateBefore(Date date);
}

Deleting Expired Requests

In the twofactor package, create the following configuration class.

// src/main/net/smcrow/demo/twofactor/ScheduleConfiguration.java
@Configuration
@EnableScheduling
public class ScheduleConfiguration {
    @Autowired
    private VerificationRepository verificationRepository;

    @Scheduled(fixedDelay = 1000)
    @Transactional
    public void purgeExpiredVerifications() {
        verificationRepository.deleteByExpirationDateBefore(new Date());
    }
}

This will set up a scheduled command to be executed every second that will query for any expired Verification entities and delete them.

Setting Up the Nexmo Client

We will be using the nexmo-java client for interacting with Nexmo.

Declare the Dependency

First, declare the following dependency in the build.gradle file.

dependencies {
    // .. other dependencies
    compile('com.nexmo:client:3.3.0')
}

Provide Information

Now, define the following information in the application.properties file.

# Add your nexmo credentials
nexmo.api.key=your-api-key
nexmo.api.secret=your-api-secret

Define the Beans

Next, we are going to define the NexmoClient and VerifyClient as beans. This will allow Spring to inject them as dependencies into our NexmoVerificationService.

Add the following definitions to the TwoFactorApplication class.

// src/main/net/smcrow/demo/twofactor/TwofactorApplication.java
@Bean
public NexmoClient nexmoClient(Environment environment) {
    AuthMethod auth = new TokenAuthMethod(
            environment.getProperty("nexmo.api.key"),
            environment.getProperty("nexmo.api.secret")
    );
    return new NexmoClient(auth);
}

@Bean
public VerifyClient nexmoVerifyClient(NexmoClient nexmoClient) {
    return nexmoClient.getVerifyClient();
}

Create the NexmoVerificationService

We are going to create a service that will allow us to instruct the client to make requests.

Add the NexmoVerificationService to the verify package.

// src/main/net/smcrow/demo/twofactor/verify/NexmoVerificationService.java
@Service
public class NexmoVerificationService {
    private static final String APPLICATION_BRAND = "2FA Demo";
    private static final int EXPIRATION_INTERVALS = Calendar.MINUTE;
    private static final int EXPIRATION_INCREMENT = 5;
    @Autowired
    private VerificationRepository verificationRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private VerifyClient verifyClient;

    public Verification requestVerification(String phone) throws VerificationRequestFailedException {
        Optional<Verification> matches = verificationRepository.findByPhone(phone);
        if (matches.isPresent()) {
            return matches.get();
        }

        return generateAndSaveNewVerification(phone);
    }

    public boolean verify(String phone, String code) throws VerificationRequestFailedException {
        try {
            Verification verification = retrieveVerification(phone);
            if (verifyClient.check(verification.getRequestId(), code).getStatus() == 0) {
                verificationRepository.delete(phone);
                return true;
            }

            return false;
        } catch (VerificationNotFoundException e) {
            requestVerification(phone);
            return false;
        } catch (IOException | NexmoClientException e) {
            throw new VerificationRequestFailedException(e);
        }
    }

    private Verification retrieveVerification(String phone) throws VerificationNotFoundException {
        Optional<Verification> matches = verificationRepository.findByPhone(phone);
        if (matches.isPresent()) {
            return matches.get();
        }

        throw new VerificationNotFoundException();
    }

    private Verification generateAndSaveNewVerification(String phone) throws VerificationRequestFailedException {
        try {
            VerifyResult result = verifyClient.verify(phone, APPLICATION_BRAND);
            if (StringUtils.isBlank(result.getErrorText())) {
                String requestId = result.getRequestId();
                Calendar now = Calendar.getInstance();
                now.add(EXPIRATION_INTERVALS, EXPIRATION_INCREMENT);

                Verification verification = new Verification(phone, requestId, now.getTime());
                return verificationRepository.save(verification);
            }
        } catch (IOException | NexmoClientException e) {
            throw new VerificationRequestFailedException(e);
        }

        throw new VerificationRequestFailedException();
    }
}

There are two main methods in this class:

  • requestVerficiation which is used to, well, request verification.
  • verify which is used to verify the provided code provided by the user.

The requestVerification Method

The method first checks to see if we already have a pending verification request for the user’s phone number. This allows us to serve the same request id to the user if they attempt to log in to the application again.

If there isn’t any prior verification, then a new verification code is requested and saved to the database. If for some reason, we are unable to assign them a new code a VerificationRequestFailedException is thrown.

Add this exception to the verify package.

// src/main/net/smcrow/demo/twofactor/verify/VerificationRequestFailedException.java
public class VerificationRequestFailedException extends Throwable {
    public VerificationRequestFailedException() {
        this("Failed to verify request.");
    }

    public VerificationRequestFailedException(String message) {
        super(message);
    }

    public VerificationRequestFailedException(Throwable cause) {
        super(cause);
    }
}

The verify Method

The verify method sends the request id and code to Nexmo for verification. Nexmo returns a status of zero if the verification was successful. On successful verification, the Verification entity is removed from the database, and true is returned.

If we were unable to find the Verification entity, maybe it expired, we request a new one and return false. If there are any issues verifying, we throw a VerificationRequestFailedException.

The retrieveVerification method will throw a VerificationNotFoundException if the Verification wasn’t found.

Add this exception to the verify package.

// src/main/net/smcrow/demo/twofactor/verify/VerificationNotFoundException.java

public class VerificationNotFoundException extends Throwable {
    public VerificationNotFoundException() {
        this("Failed to find verification.");
    }

    public VerificationNotFoundException(String message) {
        super(message);
    }
}

Using the NexmoVerificationService

We’re going to use the service for both sending a code and verifying the code. Sending a code is done after a successful authentication.

Triggering the Request for Verification

Let’s implement a custom AuthenticationSuccessHandler which will be called after the user has successfully authenticated.

Add the TwoFactorAuthenticationSuccessHandler to the verify package.

// src/main/net/smcrow/demo/twofactor/verify/TwoFactorAuthenticationSuccessHandler.java
@Component
public class TwoFactorAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private static final String VERIFICATION_URL = "/verify";
    private static final String INDEX_URL = "/";

    @Autowired
    private NexmoVerificationService verificationService;

    @Autowired
    private UserRepository userRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        String phone = ((StandardUserDetails) authentication.getPrincipal()).getUser().getPhone();
        if (phone == null || !requestAndRegisterVerification(phone)) {
            bypassVerification(request, response, authentication);
            return;
        }

        new DefaultRedirectStrategy().sendRedirect(request, response, VERIFICATION_URL);
    }

    private boolean requestAndRegisterVerification(String phone) {
        try {
            return verificationService.requestVerification(phone) != null;
        } catch (VerificationRequestFailedException e) {
            return false;
        }
    }

    private void bypassVerification(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        verificationService.updateAuthentication(authentication);
        new DefaultRedirectStrategy().sendRedirect(request, response, INDEX_URL);
    }
}

When a user has successfully authenticated, we check to see if they have a phone number.

If they have a phone number, a code is sent to their device. If they don’t have a phone number, or we are unable to send a code, we allow them to bypass verification.

The bypassVerification method relies on the updateAuthentication method of the NexmoVerificationService.

Add this to the NexmoVerificationService:

// src/main/net/smcrow/demo/twofactor/verify/NexmoVerificationService.java
public void updateAuthentication(Authentication authentication) {
    Role role = retrieveRoleFromDatabase(authentication.getName());
    List<GrantedAuthority> authorities = new ArrayList<>();
    authorities.add(role);

    Authentication newAuthentication = new UsernamePasswordAuthenticationToken(
            authentication.getPrincipal(),
            authentication.getCredentials(),
            authorities
    );

    SecurityContextHolder.getContext().setAuthentication(newAuthentication);
}

private Role retrieveRoleFromDatabase(String username) {
    Optional<User> match = userRepository.findByUsername(username);
    if (match.isPresent()) {
        return match.get().getRole();
    }

    throw new UsernameNotFoundException("Username not found.");
}

This method is used to assign the role defined in the database to the current user and removes the PRE_VERIFICATION_USER role.

Prompting The User for a Code

Once the user has been sent a code, they are forwarded to the verification page. Let’s work on creating that page next.

Create a new HTML file called verify.html in the resources/templates directory.

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorator="default">
<head>
    <meta charset="UTF-8" />
    <title>Two Factor Authorization Demo</title>
</head>
<body>
<div layout:fragment="content" class="container">
    <div class="col-lg-12 alert alert-danger text-center" th:if="${param.error}">There was an error with your login.</div>
    <div class="col-lg-4 offset-lg-4 text-left">
        <form th:action="@{/verify}" method="post">
            <h1>Verify</h1>
            <p>A text message has been sent to your mobile device. Please enter the code below:</p>
            <div class="form-group">
                <label for="code">Verification Code</label>
                <input type="text" class="form-control" id="code" name="code" placeholder="4-Digit Code" />
            </div>
            <button type="submit" class="btn btn-primary">Verify</button>
        </form>
    </div>
</div>
</body>
</html>

We also need a controller to serve the page to the user. Create the VerificationController in the verify package.

// src/main/net/smcrow/demo/twofactor/verify/VerificationController.java
@Controller
public class VerificationController {
    @Autowired
    private NexmoVerificationService verificationService;

    @PreAuthorize("hasRole('PRE_VERIFICATION_USER')")
    @GetMapping("/verify")
    public String index() {
        return "verify";
    }

    @PreAuthorize("hasRole('PRE_VERIFICATION_USER')")
    @PostMapping("/verify")
    public String verify(@RequestParam("code") String code, Authentication authentication) {
        User user = ((StandardUserDetails) authentication.getPrincipal()).getUser();
        try {
            if (verificationService.verify(user.getPhone(), code)) {
                verificationService.updateAuthentication(authentication);
                return "redirect:/";
            }

            return "redirect:verify?error";
        } catch (VerificationRequestFailedException e) {
            // Having issues generating keys let them through.
            verificationService.updateAuthentication(authentication);
            return "redirect:/";
        }
    }
}

This controller serves the verification page via the index method, and it handles the form submission via the verify method.

This page is only accessible to users with the PRE_VERIFICATION_USER role. On successful verification, the updateAuthentication method is, once again, used to replace this role with their persisted one.

Finishing Up the Verification Chain

The final step is to update the AppSecurityConfiguration to use our TwoFactorAuthenticationSuccessHandler.

Modify the AppSecurityConfiguration to wire in our handler and use it via the successHandler method.

// src/main/net/smcrow/demo/twofactor/AppSecurityConfiguration.java
@Autowired
private TwoFactorAuthenticationSuccessHandler twoFactorAuthenticationSuccessHandler;

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
    // Webjar resources
    httpSecurity.authorizeRequests().antMatchers("/webjars/**").permitAll()
    .and().formLogin().loginPage("/login").permitAll()
            .successHandler(twoFactorAuthenticationSuccessHandler)
    .and().logout().permitAll();
}

Try it Out!

You will need to add your phone number to the data.sql file.

We aren’t going to be doing any validation on the phone number, and it needs to be in E.164 format.

INSERT INTO user (username, password, role, phone) VALUES
    ('phone', 'phone', 'USER', 15555555555);

You should now be up and running. Boot up the application, and try to log in. Assuming that your API key, API secret, and seeded phone number are correct; you should receive a text message with a four-digit code.

What Did We Do?

We did a lot of things.

In short, we implemented two-factor authentication to better secure our application. We did this by:

  • Creating a custom AuthenticationSuccessHandler to forward the user to a verification page after serving them a code.
  • Using the nexmo-java library, by wrapping it in a NexmoVerificationService, to send verification codes to our users.
  • Taking advantage of the Spring Scheduler to delete expired verification codes.
  • Building a page for the user to enter their verification code.

Check out the final code from this tutorial on GitHub.

Looking Ahead

There are various ways that two-factor authentication can be implemented. If you’re curious about any of the frameworks and technologies used in the sample code, here’s a rundown:

Don’t forget that you can be a Nexmo contributor to the nexmo-java client.

Posted on Mar 22 '19 by:

cr0wst profile

Steve Crow

@cr0wst

Steve is a lover of Greyhounds, twisty puzzles, and European Board Games. When not talking math to non-math people, and Java to non-Java people, he can be found sipping coffee and hacking on code.

Discussion

markdown guide
 

kindly develop and publish a application using spring that will show :-

  1. on providing email and password in github account it show a form to verify this device . so please develop a github.com type verify your device via sending a code [ token ] into email also explain the working flow and algorithm implemented by github for this functionality. and notify me on acodeschool@gmail.com