Skip to content

Commit

Permalink
feat(MFA): Added support for custom verification checks
Browse files Browse the repository at this point in the history
Added support for custom MFA verification checks, such as receiving MFA
codes using e-mail and SMS. These usually are valid for a longer period,
so the default TOTP validator cannot be used.

The current version of rest-secure did not allow such checks, since if
it detected a `verificationCode` and the user's `isMfaConfigured()`
returned true, it would throw a bad credentials exception.

Since these checks are usually in some part dependent on the implementing
application, I've chosen not to implement the checks in the library,
but made it easy to extend the default flow by adding support for custom
checks.
  • Loading branch information
arjanvlek committed Dec 17, 2021
1 parent eaac542 commit 3c55bef
Show file tree
Hide file tree
Showing 9 changed files with 345 additions and 8 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [10.0.0] - 2021-12-17
- Added: Support for custom MFA checks, e.g. to also accept codes from email and SMS.

## [9.0.1] - 2021-11-18
- Fixed: #20 The label of the 2FA QR code should get the issuer added as well to work properly with all authenticator apps.

Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,18 @@ class CustomSecurity {
}
```

### Adding custom MFA verification checks (e.g. email, SMS)
The `MfaAuthenticationProvider` supports custom authentication checks in addition to the default TOTP validator.
If, for example, some portion of users receive their code through other means, it may be needed to add a custom verification check.

Note that the default TOTP check *always* gets added to the chain.
If it is not already supplied in the list of custom checks, it will be added after the last custom verification check.

A verification check should behave as following:
- If the user is successfully authenticated using the given verification code, return `true`. The user will be logged in and no other checks will be performed.
- If the check is not applicable to this user (e.g. this user does not receive codes using SMS), return `false`. The next check will then be executed.
- If the user has supplied incorrect credentials, the check must throw a subclass of `AuthenticationException` to abort the chain of checks and return a login error.

### Remember me (single sign on)
- Register a `RememberMeServices` bean, this will be picked up automatically and used in the login filter

Expand Down
12 changes: 11 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<spring-boot.version>2.5.6</spring-boot.version>
<spring-boot.version>2.5.7</spring-boot.version>
<totp-spring-boot-starter.version>1.7.1</totp-spring-boot-starter.version>

<dependency-check-maven-plugin.version>6.5.0</dependency-check-maven-plugin.version>
Expand Down Expand Up @@ -122,6 +122,16 @@

<dependencyManagement>
<dependencies>
<!-- Whilst Spring Boot does not use Log4j by default (uses Logback instead), it still contains the log4j-api which is reported by OWASP as being vulnerable to Log4Shell (CVE-2021-44228). -->
<!-- By upgrading log4j2.version to 2.16.0, the log4j-api gets patched and we ensure that applications using this project will not be using an outdated version of log4j. -->
<!-- This dependency override can likely be removed after Spring Boot updates to 2.6.2 / 2.5.8 (due Dec. 23, 2021. See https://spring.io/blog/2021/12/10/log4j2-vulnerability-and-spring-boot) -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-bom</artifactId>
<version>2.16.0</version>
<scope>import</scope>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package nl._42.restsecure.autoconfigure.authentication.mfa;

import java.util.ArrayList;
import java.util.List;

import nl._42.restsecure.autoconfigure.authentication.RegisteredUser;
import nl._42.restsecure.autoconfigure.authentication.UserDetailsAdapter;
import nl._42.restsecure.autoconfigure.errorhandling.DefaultLoginAuthenticationExceptionHandler;

import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
Expand All @@ -20,8 +21,15 @@ public class MfaAuthenticationProvider extends DaoAuthenticationProvider {
public static final String SERVER_MFA_CODE_REQUIRED_ERROR = "SERVER.MFA_CODE_REQUIRED_ERROR";
public static final String DETAILS_MFA_SETUP_REQUIRED = "DETAILS.MFA_SETUP_REQUIRED";

private boolean customVerificationStepsRegistered = false;
private List<MfaVerificationCheck> verificationChecks;
private MfaValidationService mfaValidationService;

public void setVerificationChecks(List<MfaVerificationCheck> verificationChecks) {
this.customVerificationStepsRegistered = true;
this.verificationChecks = verificationChecks;
}

public void setMfaValidationService(MfaValidationService mfaValidationService) {
this.mfaValidationService = mfaValidationService;
}
Expand All @@ -30,6 +38,15 @@ public void setMfaValidationService(MfaValidationService mfaValidationService) {
protected void doAfterPropertiesSet() {
super.doAfterPropertiesSet();
Assert.notNull(this.mfaValidationService, "A MfaValidationService must be set");
if (!this.customVerificationStepsRegistered) {
this.verificationChecks = List.of(new MfaTotpVerificationCheck(mfaValidationService));
} else {
Assert.isTrue(this.verificationChecks != null && !this.verificationChecks.isEmpty(), "At least one verification check must be provided");
if (verificationChecks.stream().noneMatch(check -> check instanceof MfaTotpVerificationCheck)) {
verificationChecks = new ArrayList<>(verificationChecks); // Ensure we are a mutable list.
verificationChecks.add(new MfaTotpVerificationCheck(mfaValidationService));
}
}
}

@Override
Expand All @@ -45,9 +62,18 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernameP
if (mfaAuthenticationToken.getVerificationCode() == null || mfaAuthenticationToken.getVerificationCode().equals("")) {
throw new InsufficientAuthenticationException(SERVER_MFA_CODE_REQUIRED_ERROR);
}
// If invalid code supplied, authentication has failed.
if (!mfaValidationService.verifyMfaCode(((UserDetailsAdapter<?>) userDetails).getUser().getMfaSecretKey(), mfaAuthenticationToken.getVerificationCode())) {
throw new BadCredentialsException(DefaultLoginAuthenticationExceptionHandler.SERVER_LOGIN_FAILED_ERROR);

boolean verificationSucceeded = false;

for (MfaVerificationCheck verificationCheck : verificationChecks) {
if (verificationCheck.validate(userDetailsAdapter.getUser(), mfaAuthenticationToken)) {
verificationSucceeded = true;
break;
}
}

if (!verificationSucceeded) {
throw new IllegalStateException("At least one verification check must either have succeeded or thrown an AuthenticationException. Check the verifications passed to .setVerificationChecks() for any unmatched scenarios.");
}
// If mfa is mandatory for this user, but not setup, indicate it must be setup first.
} else if (userDetailsAdapter.isMfaMandatory()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package nl._42.restsecure.autoconfigure.authentication.mfa;

import nl._42.restsecure.autoconfigure.authentication.RegisteredUser;
import nl._42.restsecure.autoconfigure.errorhandling.DefaultLoginAuthenticationExceptionHandler;

import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;

public class MfaTotpVerificationCheck implements MfaVerificationCheck {

private final MfaValidationService mfaValidationService;

public MfaTotpVerificationCheck(MfaValidationService mfaValidationService) {
this.mfaValidationService = mfaValidationService;
}

@Override
public boolean validate(RegisteredUser user, MfaAuthenticationToken authenticationToken) throws AuthenticationException {
// If no pre-authorized code assigned, validate the code supplied against the currently-valid TOTP code.
if (!mfaValidationService.verifyMfaCode(user.getMfaSecretKey(), authenticationToken.getVerificationCode())) {
throw new BadCredentialsException(DefaultLoginAuthenticationExceptionHandler.SERVER_LOGIN_FAILED_ERROR);
}

return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package nl._42.restsecure.autoconfigure.authentication.mfa;

import nl._42.restsecure.autoconfigure.authentication.RegisteredUser;

import org.springframework.security.core.AuthenticationException;

public interface MfaVerificationCheck {

/**
* Validates the MFA Authentication credentials for the given RegisteredUser.
* If the credentials are valid, return true. The user will be logged in and no further checks will take place.
* If this check is not applicable for this user, return false. The next check will then be tried.
* If the credentials are not valid (but this check *is* applicable for this user), throw an AuthenticationException.
* @param user User that is trying to log in.
* @param authenticationToken Supplied authentication credentials (username, password, MFA token)
* @return Returns true if this authentication is valid
* @throws AuthenticationException if the supplied credentials are not valid
*/
boolean validate(RegisteredUser user, MfaAuthenticationToken authenticationToken) throws AuthenticationException;
}
Loading

0 comments on commit 3c55bef

Please sign in to comment.