Skip to content

Commit

Permalink
feat: email-based authentication codes for MFA/2FA
Browse files Browse the repository at this point in the history
Signed-off-by: Morrten Svanaes <[email protected]>
  • Loading branch information
netroms committed Nov 4, 2024
1 parent e12819d commit 8512ce0
Show file tree
Hide file tree
Showing 6 changed files with 58 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ public static char[] generateSecureRandomCode(int codeSize) {
return generateRandomAlphanumericCode(codeSize, sr);
}

/**
* Generates a string of random numeric characters.
*
* @param length the number of characters in the code.
* @return the code.
*/
public static char[] generateSecureRandomNumber(int length) {
char[] digits = new char[length];
SecureRandom sr = SecureRandomHolder.GENERATOR;
for (int i = 0; i < length; i++) {
digits[i] = (char) ('0' + sr.nextInt(10));
}
return digits;
}

/**
* Generates a random secure token.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ private boolean validateCode(String code, TwoFactorType type, String userSecret)
if (TwoFactorType.TOTP.equals(type)) {
return TwoFactoryAuthenticationUtils.verifyTOTP(code, userSecret);
} else if (TwoFactorType.EMAIL.equals(type)) {
return code.equalsIgnoreCase(userSecret);
return StringUtils.equals(code, userSecret);
}
throw new IllegalStateException("Unknown two-factor type: " + type);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1055,14 +1055,14 @@ private record Email2FACode(String code, String encodedCode) {}

@Nonnull
private static Email2FACode getEmail2FACode() {
String code = new String(CodeGenerator.generateSecureRandomCode(6));
String code = new String(CodeGenerator.generateSecureRandomNumber(6));
String encodedCode = code + "|" + (System.currentTimeMillis() + TWOFA_EMAIL_CODE_EXPIRY_MILLIS);
return new Email2FACode(code, encodedCode);
}

@Nonnull
private static Email2FACode getEmail2FAForApprovalCode() {
String code = new String(CodeGenerator.generateSecureRandomCode(6));
String code = new String(CodeGenerator.generateSecureRandomNumber(6));
String encodedCode =
TWO_FACTOR_CODE_APPROVAL_PREFIX
+ code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ void skipSharingFieldsExcludeCorrectFieldsTest() {

// then only matching exclusions should have been applied
// and fields starting with 'user' should still be present
assertEquals(57, result.size()); // all user properties
assertEquals(58, result.size()); // all user properties
assertTrue(
result.stream()
.map(FieldPath::getName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.util.Calendar;
import org.hisp.dhis.common.CodeGenerator;
import org.hisp.dhis.http.HttpStatus;
import org.hisp.dhis.security.twofa.TwoFactorType;
import org.hisp.dhis.setting.SystemSettingsService;
import org.hisp.dhis.test.webapi.AuthenticationApiTestBase;
import org.hisp.dhis.test.webapi.json.domain.JsonLoginResponse;
Expand Down Expand Up @@ -112,6 +114,8 @@ void testLoginWith2FAEnrolmentUser() throws Exception {
User userA = createUserWithAuth("usera", "ALL");
injectSecurityContextUser(userA);

// This will initiate 2FA enrolment for the user, and set the secret in the user object, this
// secret is prefixed with 'APPROVAL_' to indicate that it is not yet approved.
mvc.perform(
get("/api/2fa/qrCode")
.header("Authorization", "Basic dXNlcmE6ZGlzdHJpY3Q=")
Expand All @@ -124,15 +128,17 @@ void testLoginWith2FAEnrolmentUser() throws Exception {
.content(HttpStatus.OK)
.as(JsonLoginResponse.class);

// This means that the user has not yet approved the 2FA enrolment
assertEquals("REQUIRES_TWO_FACTOR_ENROLMENT", wrong2FaCodeResponse.getLoginStatus());
assertNull(wrong2FaCodeResponse.getRedirectUrl());
}

@Test
void testLoginWith2FAEnabledUser() {
void testLoginWithTOTP2FAEnabledUser() {
User admin = userService.getUserByUsername("admin");
String secret = Base32.random();
admin.setSecret(secret);
admin.setTwoFactorType(TwoFactorType.TOTP);
userService.updateUser(admin);

JsonLoginResponse wrong2FaCodeResponse =
Expand All @@ -143,11 +149,41 @@ void testLoginWith2FAEnabledUser() {
assertEquals("INCORRECT_TWO_FACTOR_CODE", wrong2FaCodeResponse.getLoginStatus());
Assertions.assertNull(wrong2FaCodeResponse.getRedirectUrl());

validateTOTP(secret);
validateTOTP2FACode(secret);
}

@Test
void testLoginEmail2FAEnabledUser() {
User admin = userService.getUserByUsername("admin");
String secret = new String(CodeGenerator.generateSecureRandomNumber(6));
admin.setSecret(secret);
admin.setTwoFactorType(TwoFactorType.EMAIL);
userService.updateUser(admin);

JsonLoginResponse wrong2FaCodeResponse =
POST("/auth/login", "{'username':'admin','password':'district'}")
.content(HttpStatus.OK)
.as(JsonLoginResponse.class);

assertEquals("INCORRECT_TWO_FACTOR_CODE", wrong2FaCodeResponse.getLoginStatus());
Assertions.assertNull(wrong2FaCodeResponse.getRedirectUrl());

validateEmail2FACode(secret);
}

private void validateEmail2FACode(String secret) {
JsonLoginResponse ok2FaCodeResponse =
POST(
"/auth/login",
"{'username':'admin','password':'district','twoFactorCode':'%s'}".formatted(secret))
.content(HttpStatus.OK)
.as(JsonLoginResponse.class);
assertEquals("SUCCESS", ok2FaCodeResponse.getLoginStatus());
assertEquals("/dhis-web-dashboard/", ok2FaCodeResponse.getRedirectUrl());
}

// test redirect to login page when not logged in, remember url before login...
private void validateTOTP(String secret) {
private void validateTOTP2FACode(String secret) {
Totp totp = new Totp(secret);
String code = totp.now();
JsonLoginResponse ok2FaCodeResponse =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@ public LoginResponse login(
return LoginResponse.builder().loginStatus(STATUS.INCORRECT_TWO_FACTOR_CODE).build();
} catch (TwoFactorAuthenticationEnrolmentException e) {
return LoginResponse.builder().loginStatus(STATUS.REQUIRES_TWO_FACTOR_ENROLMENT).build();

} catch (CredentialsExpiredException e) {
return LoginResponse.builder().loginStatus(STATUS.PASSWORD_EXPIRED).build();
} catch (LockedException e) {
Expand Down

0 comments on commit 8512ce0

Please sign in to comment.