Skip to content

Commit

Permalink
feat: email-based authentication codes for MFA/2FA (#19008)
Browse files Browse the repository at this point in the history
* feat: email-based authentication codes for MFA/2FA

Signed-off-by: Morrten Svanaes <msvanaes@dhis2.org>
  • Loading branch information
netroms authored Dec 9, 2024
1 parent b321489 commit 80e3d05
Show file tree
Hide file tree
Showing 53 changed files with 2,610 additions and 1,142 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,28 @@ public static char[] generateSecureRandomCode(int codeSize) {
return generateRandomAlphanumericCode(codeSize, sr);
}

public static byte[] generateSecureRandomBytes(int length) {
SecureRandom sr = SecureRandomHolder.GENERATOR;
byte[] bytes = new byte[length];
sr.nextBytes(bytes);
return bytes;
}

/**
* 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 @@ -199,21 +199,31 @@ public enum ErrorCode {
E3020(
"You must have permissions to create user, or ability to manage at least one user group for the user"),
E3021("Not allowed to disable 2FA for current user"),
E3022("User has two factor authentication enabled, disable 2FA before you create a new QR code"),
E3022("User has 2FA enabled already, disable 2FA before you try to enroll again"),
E3023("Invalid 2FA code"),
E3024("Not allowed to disable 2FA"),
E3025("No current user"),
E3026("Could not generate QR code"),
E3027("No currentUser available"),
E3028("User must have a secret"),
E3029("User must call the /qrCode endpoint first"),
E3028("User must have a 2FA secret"),
E3029("User must start 2FA enrollment first"),
E3030(
"User cannot update their own user's 2FA settings via this API endpoint, must use /2fa/enable or disable API"),
E3031("Two factor authentication is not enabled"),
"User cannot update their own user's 2FA settings via this API endpoint, must use /2fa/enable or /2fa/disable API"),
E3031("User has not enabled 2FA"),
E3032("User `{0}` does not have access to user role"),
E3040("Could not resolve JwsAlgorithm from the JWK. Can not write a valid JWKSet"),
E3041("User `{0}` is not allowed to change a user having the ALL authority"),
E3042("Too many failed disable attempts. Please try again later"),
E3043(
"User does not have a verified email, please verify your email before you try to enable 2FA"),
E3044("TOTP 2FA is not enabled"),
E3045("Email based 2FA is not enabled in the system settings"),
E3046("TOTP 2FA is not enabled in the system settings"),
E3047("User is not in TOTP 2FA enrollment mode"),
E3048("User does not have email 2FA enabled"),
E3049("Sending 2FA code with email failed"),
E3050("2FA code can not be null or empty"),
E3051("2FA code was sent to the user's email"),

/* Metadata Validation */
E4000("Missing required property `{0}`"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2004-2024, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.security.twofa;

import lombok.Getter;

@Getter
public enum TwoFactorType {
NOT_ENABLED,
TOTP_ENABLED,
EMAIL_ENABLED,
ENROLLING_TOTP, // User is in the process of enrolling in TOTP 2FA
ENROLLING_EMAIL; // User is in the process of enrolling in email-based 2FA

public boolean isEnrolling() {
return this == ENROLLING_TOTP || this == ENROLLING_EMAIL;
}

public TwoFactorType getEnabledType() {
if (this == ENROLLING_TOTP) {
return TOTP_ENABLED;
} else if (this == ENROLLING_EMAIL) {
return EMAIL_ENABLED;
} else {
return this;
}
}

public boolean isEnabled() {
return this == TOTP_ENABLED || this == EMAIL_ENABLED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,14 @@ default String getGlobalShellAppName() {
return asString("globalShellAppName", "global-app-shell");
}

default boolean getEmail2FAEnabled() {
return asBoolean("email2FAEnabled", false);
}

default boolean getTOTP2FAEnabled() {
return asBoolean("totp2FAEnabled", true);
}

/**
* @return true if email verification is enforced for all users.
*/
Expand Down
11 changes: 11 additions & 0 deletions dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/SystemUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import javax.annotation.Nonnull;
import org.hisp.dhis.common.CodeGenerator;
import org.hisp.dhis.security.Authorities;
import org.hisp.dhis.security.twofa.TwoFactorType;
import org.springframework.security.core.GrantedAuthority;

/**
Expand Down Expand Up @@ -88,6 +89,11 @@ public boolean isSuper() {
return true;
}

@Override
public String getSecret() {
return "";
}

@Override
public String getUid() {
return "XXXXXSystem";
Expand Down Expand Up @@ -184,6 +190,11 @@ public boolean isTwoFactorEnabled() {
return false;
}

@Override
public TwoFactorType getTwoFactorType() {
return TwoFactorType.NOT_ENABLED;
}

@Override
public boolean isEmailVerified() {
return true;
Expand Down
19 changes: 14 additions & 5 deletions dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
import org.hisp.dhis.schema.annotation.Property;
import org.hisp.dhis.schema.annotation.PropertyRange;
import org.hisp.dhis.security.Authorities;
import org.hisp.dhis.security.twofa.TwoFactorType;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;

Expand All @@ -72,7 +73,6 @@
*/
@JacksonXmlRootElement(localName = "user", namespace = DxfNamespaces.DXF_2_0)
public class User extends BaseIdentifiableObject implements MetadataObject {
public static final int USERNAME_MAX_LENGTH = 255;

/** Globally unique identifier for User. */
private UUID uuid;
Expand All @@ -95,9 +95,10 @@ public class User extends BaseIdentifiableObject implements MetadataObject {
/** Required. Will be stored as a hash. */
private String password;

/** Required. Automatically set in constructor */
private String secret;

private TwoFactorType twoFactorType;

/** Date when password was changed. */
private Date passwordLastUpdated;

Expand Down Expand Up @@ -440,10 +441,9 @@ public void setPassword(String password) {
this.password = password;
}

@JsonProperty
@JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0)
@JsonIgnore
public boolean isTwoFactorEnabled() {
return this.secret != null && !this.secret.isEmpty();
return this.twoFactorType != null && this.twoFactorType.isEnabled();
}

@JsonIgnore
Expand All @@ -455,6 +455,15 @@ public void setSecret(String secret) {
this.secret = secret;
}

@JsonIgnore
public TwoFactorType getTwoFactorType() {
return this.twoFactorType == null ? TwoFactorType.NOT_ENABLED : this.twoFactorType;
}

public void setTwoFactorType(TwoFactorType twoFactorType) {
this.twoFactorType = twoFactorType;
}

@JsonProperty
@JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0)
public boolean isExternalAuth() {
Expand Down
15 changes: 11 additions & 4 deletions dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,19 @@
import org.hisp.dhis.common.IdentifiableObject;
import org.hisp.dhis.common.UidObject;
import org.hisp.dhis.security.Authorities;
import org.hisp.dhis.security.twofa.TwoFactorType;
import org.hisp.dhis.user.UserDetailsImpl.UserDetailsImplBuilder;
import org.springframework.security.core.GrantedAuthority;

public interface UserDetails
extends org.springframework.security.core.userdetails.UserDetails, UidObject {

// TODO MAS: This is a workaround and usually indicated a design flaw, and that we should refactor
// to use UserDetails higher up in the layers.

/**
* Create UserDetails from User
*
* <p>TODO MAS: This is a workaround and usually indicated a design flaw, and that we should
* refactor // to use UserDetails higher up in the layers.
*
* @param user user to convert
* @return UserDetails
*/
Expand Down Expand Up @@ -115,12 +116,14 @@ static UserDetails createUserDetails(
UserDetailsImpl.builder()
.id(user.getId())
.uid(user.getUid())
.code(user.getCode())
.username(user.getUsername())
.password(user.getPassword())
.externalAuth(user.isExternalAuth())
.isTwoFactorEnabled(user.isTwoFactorEnabled())
.twoFactorType(user.getTwoFactorType())
.secret(user.getSecret())
.isEmailVerified(user.isEmailVerified())
.code(user.getCode())
.firstName(user.getFirstName())
.surname(user.getSurname())
.enabled(user.isEnabled())
Expand Down Expand Up @@ -199,6 +202,8 @@ static UserDetails createUserDetails(

boolean isSuper();

String getSecret();

@Override
String getUid();

Expand Down Expand Up @@ -245,6 +250,8 @@ static UserDetails createUserDetails(

boolean isTwoFactorEnabled();

TwoFactorType getTwoFactorType();

boolean isEmailVerified();

boolean hasAnyRestrictions(Collection<String> restrictions);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.hisp.dhis.security.Authorities;
import org.hisp.dhis.security.twofa.TwoFactorType;
import org.springframework.security.core.GrantedAuthority;

@Getter
Expand All @@ -53,6 +54,8 @@ public class UserDetailsImpl implements UserDetails {
private final String password;
private final boolean externalAuth;
private final boolean isTwoFactorEnabled;
private final TwoFactorType twoFactorType;
private final String secret;
private final boolean isEmailVerified;
private final boolean enabled;
private final boolean accountNonExpired;
Expand Down
Loading

0 comments on commit 80e3d05

Please sign in to comment.