diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/CodeGenerator.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/CodeGenerator.java index 272a5251243f..6e344cad9206 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/CodeGenerator.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/CodeGenerator.java @@ -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. * diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/feedback/ErrorCode.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/feedback/ErrorCode.java index 105a56793c37..226a73faa874 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/feedback/ErrorCode.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/feedback/ErrorCode.java @@ -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}`"), diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/security/twofa/TwoFactorType.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/security/twofa/TwoFactorType.java new file mode 100644 index 000000000000..211da69ceebe --- /dev/null +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/security/twofa/TwoFactorType.java @@ -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; + } +} diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/setting/SystemSettings.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/setting/SystemSettings.java index 26b2a534cd47..63d79785850c 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/setting/SystemSettings.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/setting/SystemSettings.java @@ -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. */ diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/SystemUser.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/SystemUser.java index b238adac245f..38558d8bbae8 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/SystemUser.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/SystemUser.java @@ -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; /** @@ -88,6 +89,11 @@ public boolean isSuper() { return true; } + @Override + public String getSecret() { + return ""; + } + @Override public String getUid() { return "XXXXXSystem"; @@ -184,6 +190,11 @@ public boolean isTwoFactorEnabled() { return false; } + @Override + public TwoFactorType getTwoFactorType() { + return TwoFactorType.NOT_ENABLED; + } + @Override public boolean isEmailVerified() { return true; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/User.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/User.java index a3fdcd257e62..ca9381d9cd74 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/User.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/User.java @@ -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; @@ -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; @@ -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; @@ -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 @@ -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() { diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java index cca0ccb8ede9..2f96f6a8cf8a 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetails.java @@ -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 * + *

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 */ @@ -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()) @@ -199,6 +202,8 @@ static UserDetails createUserDetails( boolean isSuper(); + String getSecret(); + @Override String getUid(); @@ -245,6 +250,8 @@ static UserDetails createUserDetails( boolean isTwoFactorEnabled(); + TwoFactorType getTwoFactorType(); + boolean isEmailVerified(); boolean hasAnyRestrictions(Collection restrictions); diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java index 522bc1586093..4915e0be138a 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserDetailsImpl.java @@ -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 @@ -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; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserService.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserService.java index a64c41765021..3d2142424460 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserService.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserService.java @@ -46,9 +46,9 @@ import org.hisp.dhis.feedback.ConflictException; import org.hisp.dhis.feedback.ErrorCode; import org.hisp.dhis.feedback.ErrorReport; -import org.hisp.dhis.feedback.ForbiddenException; import org.hisp.dhis.feedback.NotFoundException; import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.springframework.security.core.session.SessionInformation; /** * @author Chau Thu Tran @@ -58,10 +58,6 @@ public interface UserService { String PW_NO_INTERNAL_LOGIN = "--[##no_internal_login##]--"; - String TWO_FACTOR_CODE_APPROVAL_PREFIX = "APPROVAL_"; - - String TWO_FACTOR_AUTH_REQUIRED_RESTRICTION_NAME = "R_ENABLE_2FA"; - String RESTORE_PATH = "/dhis-web-login/index.html#/"; String TBD_NAME = "(TBD)"; @@ -76,16 +72,6 @@ public interface UserService { String RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"; - /** - * If the user's secret starts with the prefix `APPROVAL_`, then return true - * - * @param user The user to check. - * @return A boolean value. - */ - static boolean hasTwoFactorSecretForApproval(User user) { - return user.getSecret().startsWith(TWO_FACTOR_CODE_APPROVAL_PREFIX); - } - /** * Adds a User. * @@ -509,20 +495,6 @@ Map> findNotifiableUsersWithPasswordLastUpdatedBetween( */ UserDetails createUserDetails(User user); - /** - * "If the current user is not the user being modified, and the current user has the authority to - * modify the user, then disable two-factor authentication for the user." - * - *

The first thing we do is get the user object from the database. If the user doesn't exist, - * we throw an exception - * - * @param currentUser The user who is making the request. - * @param userUid The user UID of the user to disable 2FA for. - * @param errors A Consumer object that will be called if there is an error. - */ - void privilegedTwoFactorDisable(User currentUser, String userUid, Consumer errors) - throws ForbiddenException; - /** * Checks if the input user can modify the other input user. * @@ -535,47 +507,6 @@ void privilegedTwoFactorDisable(User currentUser, String userUid, Consumer errors); - /** - * Generate a new two factor (TOTP) secret for the user, but prefix it with a special string so - * that we can tell the difference between a normal secret and an approval secret. - * - * @param user The user object that is being updated. - */ - void generateTwoFactorOtpSecretForApproval(User user); - - /** - * If the user has an OTP secret that starts with the approval prefix, remove the prefix and - * update the user property. - * - * @param user The user object that is being updated. - */ - void approveTwoFactorSecret(User user, UserDetails actingUser); - - /** - * "Disable 2FA authentication for the input user, by setting the secret to null." - * - * @param user The user object that you want to reset the 2FA for. - */ - void resetTwoFactor(User user, UserDetails actingUser); - - /** - * If the user has a secret, and the secret has not been approved, and the code is valid, then - * approve the secret and effectively enable 2FA. - * - * @param user The user object to enable 2FA authentication for. - * @param code The code that the user entered into the app - */ - void enableTwoFa(User user, String code); - - /** - * If the user has 2FA authentication enabled, and the code is valid, then disable 2FA - * authentication - * - * @param user The user object that you want to disable 2FA authentication for. - * @param code The code that the user entered - */ - void disableTwoFa(User user, String code); - /** * Register a failed 2FA disable attempt for the given user account. * @@ -590,7 +521,7 @@ boolean canCurrentUserCanModify( * @param username * @return */ - boolean twoFaDisableIsLocked(String username); + boolean is2FADisableEndpointLocked(String username); /** * Register a successful 2FA disable attempt for the given user account, this will reset the @@ -601,34 +532,41 @@ boolean canCurrentUserCanModify( void registerSuccess2FADisable(String username); /** - * If the user has a role with the 2FA authentication required restriction, return true. + * Get linked user accounts for the given user * - * @param userDetails The user object that is being checked for the role. - * @return A boolean value. + * @param actingUser the acting/current user + * @return list of linked user accounts */ - boolean hasTwoFactorRoleRestriction(UserDetails userDetails); + @Nonnull + List getLinkedUserAccounts(@Nonnull User actingUser); /** - * If the user is not the same as the user to modify, and the user has the proper acl permissions - * to modify the user, then the user can modify the user. + * List all user's sessions * - * @param before The state before the update. - * @param after The state after the update. - * @param userToModify The user object that is being updated. + * @param userUID + * @return */ - void validateTwoFactorUpdate(boolean before, boolean after, User userToModify) - throws ForbiddenException; + List listSessions(String userUID); /** - * Get linked user accounts for the given user + * List all user's sessions * - * @param actingUser the acting/current user - * @return list of linked user accounts + * @param principal + * @return */ - @Nonnull - List getLinkedUserAccounts(@Nonnull User actingUser); + List listSessions(UserDetails principal); + + /** + * Invalidate all sessions for all users WARNING: This does not work when using Redis sessions. + */ + void invalidateAllSessions(); - void invalidateUserSessions(String uid); + /** + * Invalidate all sessions for the given user. + * + * @param username the username of the user account. + */ + void invalidateUserSessions(String username); /** * Register a account recovery attempt for the given user account. @@ -901,7 +839,7 @@ void validateTwoFactorUpdate(boolean before, boolean after, User userToModify) boolean isEmailVerified(User currentUser); - User getUserByVerificationToken(String token); + User getUserByEmailVerificationToken(String token); /** * Method that retrieves all {@link User}s that have an entry for the {@link OrganisationUnit} in diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserStore.java index f286f26e5850..ebeb63a927c7 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserStore.java @@ -225,7 +225,7 @@ Map> findNotifiableUsersWithPasswordLastUpdatedBetween( */ void setActiveLinkedAccounts(@Nonnull String actingUser, @Nonnull String activeUsername); - User getUserByVerificationToken(String token); + User getUserByEmailVerificationToken(String token); User getUserByVerifiedEmail(String email); diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/TwoFactoryAuthenticationUtils.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/TwoFactoryAuthenticationUtils.java deleted file mode 100644 index 3eb95cdec822..000000000000 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/TwoFactoryAuthenticationUtils.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (c) 2004-2022, 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; - -import static org.hisp.dhis.feedback.ErrorCode.E3026; -import static org.hisp.dhis.feedback.ErrorCode.E3028; -import static org.hisp.dhis.user.UserService.TWO_FACTOR_CODE_APPROVAL_PREFIX; - -import com.google.common.base.Strings; -import com.google.zxing.BarcodeFormat; -import com.google.zxing.MultiFormatWriter; -import com.google.zxing.WriterException; -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.BitMatrix; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.function.Consumer; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.ArrayUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.validator.routines.LongValidator; -import org.hisp.dhis.feedback.ErrorCode; -import org.hisp.dhis.user.User; -import org.jboss.aerogear.security.otp.Totp; - -/** - * @author Henning Håkonsen - * @author Morten Svanæs - */ -@Slf4j -public class TwoFactoryAuthenticationUtils { - private TwoFactoryAuthenticationUtils() { - throw new IllegalStateException("Utility class"); - } - - private static final String APP_NAME_PREFIX = "DHIS 2 "; - - /** - * Generate QR code in PNG format based on given qrContent. - * - * @param qrContent content to be used for generating the QR code. - * @param width width of the generated PNG image. - * @param height height of the generated PNG image. - * @return PNG image as byte array. - */ - public static byte[] generateQRCode( - String qrContent, int width, int height, Consumer errorCode) { - try { - BitMatrix bitMatrix = - new MultiFormatWriter() - .encode( - new String(qrContent.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8), - BarcodeFormat.QR_CODE, - width, - height); - - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - MatrixToImageWriter.writeToStream(bitMatrix, "PNG", byteArrayOutputStream); - return byteArrayOutputStream.toByteArray(); - } catch (WriterException | IOException e) { - log.error(e.getMessage(), e); - errorCode.accept(E3026); - return ArrayUtils.EMPTY_BYTE_ARRAY; - } - } - - /** - * Generate QR content based on given appName and {@link User} - * - * @param appName app name to be used for generating QR content. - * @param user {@link User} which the QR Code is generated for. - * @return a String which can be used for generating a QR code by calling method {@link - * TwoFactoryAuthenticationUtils#generateQRCode(String, int, int, Consumer)} - */ - public static String generateQrContent(String appName, User user, Consumer errorCode) { - String secret = user.getSecret(); - - if (Strings.isNullOrEmpty(secret)) { - errorCode.accept(E3028); - } - - secret = removeApprovalPrefix(secret); - - String app = (APP_NAME_PREFIX + StringUtils.stripToEmpty(appName)).replace(" ", "%20"); - - return String.format( - "otpauth://totp/%s:%s?secret=%s&issuer=%s", app, user.getUsername(), secret, app); - } - - /** - * Verifies that the secret for the given user matches the given code. - * - * @param code the code. - * @param secret - * @return true if the user secret matches the given code, false if not. - */ - public static boolean verify(String code, String secret) { - if (Strings.isNullOrEmpty(secret)) { - throw new IllegalArgumentException("User must have a secret"); - } - - if (!LongValidator.getInstance().isValid(code)) { - return false; - } - - secret = removeApprovalPrefix(secret); - - Totp totp = new Totp(secret); - try { - return totp.verify(code); - } catch (NumberFormatException ex) { - return false; - } - } - - private static String removeApprovalPrefix(String secret) { - if (secret.startsWith(TWO_FACTOR_CODE_APPROVAL_PREFIX)) { - secret = secret.substring(TWO_FACTOR_CODE_APPROVAL_PREFIX.length()); - } - return secret; - } -} diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUser.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUser.java index 4b77673bf69e..584508720299 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUser.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/oidc/DhisOidcUser.java @@ -33,6 +33,7 @@ import java.util.Set; import javax.annotation.Nonnull; import org.hisp.dhis.security.Authorities; +import org.hisp.dhis.security.twofa.TwoFactorType; import org.hisp.dhis.user.User; import org.hisp.dhis.user.UserDetails; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -112,6 +113,11 @@ public boolean isSuper() { return user.isSuper(); } + @Override + public String getSecret() { + return user.getSecret(); + } + @Override public String getUid() { return user.getUid(); @@ -214,6 +220,11 @@ public boolean isTwoFactorEnabled() { return user.isTwoFactorEnabled(); } + @Override + public TwoFactorType getTwoFactorType() { + return user.getTwoFactorType(); + } + @Override public boolean isEmailVerified() { return user.isEmailVerified(); diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/spring2fa/TwoFactorAuthenticationProvider.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/spring2fa/TwoFactorAuthenticationProvider.java index 82b438917d0b..4886cb518027 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/spring2fa/TwoFactorAuthenticationProvider.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/spring2fa/TwoFactorAuthenticationProvider.java @@ -27,12 +27,20 @@ */ package org.hisp.dhis.security.spring2fa; +import static org.hisp.dhis.security.twofa.TwoFactorAuthService.TWO_FACTOR_AUTH_REQUIRED_RESTRICTION_NAME; +import static org.hisp.dhis.security.twofa.TwoFactorAuthUtils.isValid2FACode; + +import com.google.common.base.Strings; +import java.util.Set; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.hisp.dhis.feedback.ConflictException; +import org.hisp.dhis.feedback.ErrorCode; import org.hisp.dhis.security.ForwardedIpAwareWebAuthenticationDetails; -import org.hisp.dhis.security.TwoFactoryAuthenticationUtils; -import org.hisp.dhis.user.SystemUser; -import org.hisp.dhis.user.User; +import org.hisp.dhis.security.twofa.TwoFactorAuthService; +import org.hisp.dhis.security.twofa.TwoFactorType; import org.hisp.dhis.user.UserDetails; import org.hisp.dhis.user.UserService; import org.springframework.beans.factory.annotation.Autowired; @@ -57,14 +65,17 @@ @Component public class TwoFactorAuthenticationProvider extends DaoAuthenticationProvider { private UserService userService; + private TwoFactorAuthService twoFactorAuthService; @Autowired public TwoFactorAuthenticationProvider( @Qualifier("userDetailsService") UserDetailsService detailsService, PasswordEncoder passwordEncoder, - @Lazy UserService userService) { + @Lazy UserService userService, + @Lazy TwoFactorAuthService twoFactorAuthService) { this.userService = userService; + this.twoFactorAuthService = twoFactorAuthService; setUserDetailsService(detailsService); setPasswordEncoder(passwordEncoder); } @@ -82,75 +93,71 @@ public Authentication authenticate(Authentication auth) throws AuthenticationExc // If enabled, temporarily block user with too many failed attempts if (userService.isLocked(username)) { - log.debug("Temporary lockout for user: '{}' and IP: {}", username, ip); + log.warn("Temporary lockout for user: '{}'", username); throw new LockedException(String.format("IP is temporarily locked: %s", ip)); } // Calls the UserDetailsService#loadUserByUsername(), to create the UserDetails object, - // after password is validated. + // after the password is validated. Authentication result = super.authenticate(auth); - UserDetails principal = (UserDetails) result.getPrincipal(); + UserDetails userDetails = (UserDetails) result.getPrincipal(); - // Prevents other authentication methods (e.g. OAuth2/LDAP), + // Prevents other authentication methods (e.g., OAuth2/LDAP), // to use password login. - if (principal.isExternalAuth()) { + if (userDetails.isExternalAuth()) { log.info( - "User is using external authentication, password login attempt aborted: '{}'", username); + "User has external authentication enabled, password login attempt aborted: '{}'", + username); throw new BadCredentialsException( "Invalid login method, user is using external authentication"); } - validateTwoFactor(principal, auth.getDetails()); - - return new UsernamePasswordAuthenticationToken( - principal, result.getCredentials(), result.getAuthorities()); - } + // If the user requires 2FA, and it's not enabled, redirect to + // the enrolment page, (via the CustomAuthFailureHandler) + boolean has2FARestrictionOnRole = + userDetails.hasAnyRestrictions(Set.of(TWO_FACTOR_AUTH_REQUIRED_RESTRICTION_NAME)); + if (!userDetails.isTwoFactorEnabled() && has2FARestrictionOnRole) { + throw new TwoFactorAuthenticationEnrolmentException( + "User must setup two-factor authentication first before logging in"); + } - private void validateTwoFactor(UserDetails userDetails, Object details) { - // If the user has 2FA enabled and tries to authenticate with HTTP Basic or OAuth - if (userDetails.isTwoFactorEnabled() - && !(details instanceof TwoFactorWebAuthenticationDetails)) { + boolean isHTTPBasicRequest = !(auth.getDetails() instanceof TwoFactorWebAuthenticationDetails); + if (userDetails.isTwoFactorEnabled() && isHTTPBasicRequest) { + // If the user has 2FA enabled and tries to authenticate with HTTP Basic throw new PreAuthenticatedCredentialsNotFoundException( "User has 2FA enabled, but attempted to authenticate with a non-form based login method: " + userDetails.getUsername()); } - // If the user requires 2FA, and it's not enabled/provisioned, redirect to - // the enrolment page, (via the CustomAuthFailureHandler) - if (userService.hasTwoFactorRoleRestriction(userDetails) && !userDetails.isTwoFactorEnabled()) { - throw new TwoFactorAuthenticationEnrolmentException( - "User must setup two factor authentication"); + if (userDetails.isTwoFactorEnabled() + && auth.getDetails() instanceof TwoFactorWebAuthenticationDetails authDetails) { + validate2FACode(authDetails.getCode(), userDetails); } - if (userDetails.isTwoFactorEnabled()) { - TwoFactorWebAuthenticationDetails authDetails = (TwoFactorWebAuthenticationDetails) details; - if (authDetails == null) { - log.info("Missing authentication details in authentication request"); - throw new PreAuthenticatedCredentialsNotFoundException( - "Missing authentication details in authentication request"); - } - - validateTwoFactorCode( - StringUtils.deleteWhitespace(authDetails.getCode()), userDetails.getUsername()); - } + return new UsernamePasswordAuthenticationToken( + userDetails, result.getCredentials(), result.getAuthorities()); } - private void validateTwoFactorCode(String code, String username) { - User user = userService.getUserByUsername(username); + private void validate2FACode(@CheckForNull String code, @Nonnull UserDetails userDetails) { + TwoFactorType type = userDetails.getTwoFactorType(); - code = StringUtils.deleteWhitespace(code); + // Send 2FA code via Email if the user has email 2FA enabled and the code is empty. + if (TwoFactorType.EMAIL_ENABLED == type && Strings.isNullOrEmpty(code)) { + try { + twoFactorAuthService.sendEmail2FACode(userDetails.getUsername()); + } catch (ConflictException e) { + throw new TwoFactorAuthenticationException(ErrorCode.E3049.getMessage()); + } + throw new TwoFactorAuthenticationException(ErrorCode.E3051.getMessage()); + } - if (!TwoFactoryAuthenticationUtils.verify(code, user.getSecret())) { - log.debug("Two-factor authentication failure for user: '{}'", user.getUsername()); + if (Strings.isNullOrEmpty(code) || StringUtils.deleteWhitespace(code).isEmpty()) { + throw new TwoFactorAuthenticationException(ErrorCode.E3023.getMessage()); + } - if (UserService.hasTwoFactorSecretForApproval(user)) { - userService.resetTwoFactor(user, new SystemUser()); - throw new TwoFactorAuthenticationEnrolmentException("Invalid verification code"); - } else { - throw new TwoFactorAuthenticationException("Invalid verification code"); - } - } else if (UserService.hasTwoFactorSecretForApproval(user)) { - userService.approveTwoFactorSecret(user, new SystemUser()); + if (!isValid2FACode(type, code, userDetails.getSecret())) { + throw new TwoFactorAuthenticationException(ErrorCode.E3023.getMessage()); } + // All good, 2FA code is valid! } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/twofa/TwoFactorAuthService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/twofa/TwoFactorAuthService.java new file mode 100644 index 000000000000..bf0db58a4417 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/twofa/TwoFactorAuthService.java @@ -0,0 +1,343 @@ +/* + * 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 static org.hisp.dhis.common.CodeGenerator.generateSecureRandomBytes; + +import com.google.common.base.Strings; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.common.CodeGenerator; +import org.hisp.dhis.common.NonTransactional; +import org.hisp.dhis.email.EmailResponse; +import org.hisp.dhis.feedback.ConflictException; +import org.hisp.dhis.feedback.ErrorCode; +import org.hisp.dhis.feedback.ErrorReport; +import org.hisp.dhis.feedback.ForbiddenException; +import org.hisp.dhis.feedback.NotFoundException; +import org.hisp.dhis.i18n.I18n; +import org.hisp.dhis.i18n.I18nManager; +import org.hisp.dhis.message.MessageSender; +import org.hisp.dhis.outboundmessage.OutboundMessageResponse; +import org.hisp.dhis.setting.SystemSettingsProvider; +import org.hisp.dhis.system.velocity.VelocityManager; +import org.hisp.dhis.user.CurrentUserUtil; +import org.hisp.dhis.user.SystemUser; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserService; +import org.hisp.dhis.user.UserSettingsService; +import org.jboss.aerogear.security.otp.api.Base32; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author Morten Svanæs + */ +@Slf4j +@Service +@AllArgsConstructor +public class TwoFactorAuthService { + + public static final String TWO_FACTOR_AUTH_REQUIRED_RESTRICTION_NAME = "R_ENABLE_2FA"; + public static final long TWOFA_EMAIL_CODE_EXPIRY_MILLIS = 900_000; // 15 minutes + + private final SystemSettingsProvider settingsProvider; + private final UserService userService; + private final MessageSender emailMessageSender; + private final UserSettingsService userSettingsService; + private final I18nManager i18nManager; + + /** + * Enroll user in time-based one-time password (TOTP) 2FA authentication. + * + * @param username The user that is being enrolled. + */ + @Transactional + public void enrollTOTP2FA(@Nonnull String username) throws ConflictException { + User user = userService.getUserByUsername(username); + if (user == null) { + throw new ConflictException(ErrorCode.E6201); + } + if (user.isTwoFactorEnabled()) { + throw new ConflictException(ErrorCode.E3022); + } + if (!settingsProvider.getCurrentSettings().getTOTP2FAEnabled()) { + throw new ConflictException(ErrorCode.E3046); + } + String totpSeed = Base32.encode(generateSecureRandomBytes(20)); + user.setSecret(totpSeed); + user.setTwoFactorType(TwoFactorType.ENROLLING_TOTP); + userService.updateUser(user); + } + + /** + * Enroll user in email-based 2FA authentication. + * + * @param username The user that is being enrolled. + */ + @Transactional + public void enrollEmail2FA(@Nonnull String username) throws ConflictException { + User user = userService.getUserByUsername(username); + if (user == null) { + throw new ConflictException(ErrorCode.E6201); + } + if (user.isTwoFactorEnabled()) { + throw new ConflictException(ErrorCode.E3022); + } + if (!settingsProvider.getCurrentSettings().getEmail2FAEnabled()) { + throw new ConflictException(ErrorCode.E3045); + } + if (!userService.isEmailVerified(user)) { + throw new ConflictException(ErrorCode.E3043); + } + Email2FACode email2FACode = generateEmail2FACode(); + user.setSecret(email2FACode.encodedCode()); + user.setTwoFactorType(TwoFactorType.ENROLLING_EMAIL); + + send2FACodeWithEmailSender(user, email2FACode.code()); + + userService.updateUser(user); + } + + /** + * Enable 2FA authentication for the user if the user is in the 2FA enrolling state. The user must + * provide the correct 2FA code to enable 2FA. This proves that the user has access to the 2FA + * secret (TOTP) or has access to the verified email (Email-based 2FA). + * + * @param username The user to enable 2FA authentication for. + * @param code The 2FA code that the user generated with the authenticator app (TOTP), or the + * email based 2FA code sent to the user's email address. + */ + @Transactional + public void enable2FA(@Nonnull String username, @Nonnull String code, UserDetails currentUser) + throws ConflictException, ForbiddenException { + User user = userService.getUserByUsername(username); + if (user == null) { + throw new ConflictException(ErrorCode.E6201); + } + if (user.getTwoFactorType().isEnabled()) { + throw new ConflictException(ErrorCode.E3022); + } + if (!user.getTwoFactorType().isEnrolling()) { + throw new ConflictException(ErrorCode.E3029); + } + if (isInvalid2FACode(user, code)) { + throw new ForbiddenException(ErrorCode.E3023); + } + + user.setTwoFactorType(user.getTwoFactorType().getEnabledType()); + userService.updateUser(user, currentUser); + } + + /** + * If the user has 2FA authentication enabled, and the code is valid, then disable 2FA + * authentication. + * + * @param username The user to disable 2FA authentication + * @param code The 2FA code, (If no code i supplied and user has email 2FA, a code will be sent to + * the verified email address) + */ + @Transactional + public void disable2FA(@Nonnull String username, @Nonnull String code) + throws ConflictException, ForbiddenException { + if (userService.is2FADisableEndpointLocked(username)) { + throw new ConflictException(ErrorCode.E3042); + } + User user = userService.getUserByUsername(username); + if (user == null) { + throw new ConflictException(ErrorCode.E6201); + } + if (!user.isTwoFactorEnabled()) { + throw new ConflictException(ErrorCode.E3031); + } + if (TwoFactorType.EMAIL_ENABLED.equals(user.getTwoFactorType()) + && Strings.isNullOrEmpty(code)) { + sendEmail2FACode(user.getUsername()); + throw new ConflictException(ErrorCode.E3051); + } + if (Strings.isNullOrEmpty(code)) { + throw new ConflictException(ErrorCode.E3050); + } + + if (isInvalid2FACode(user, code)) { + userService.registerFailed2FADisableAttempt(CurrentUserUtil.getCurrentUsername()); + throw new ForbiddenException(ErrorCode.E3023); + } + + reset2FA(user.getUsername(), CurrentUserUtil.getCurrentUserDetails()); + userService.registerSuccess2FADisable(user.getUsername()); + } + + private void reset2FA(@Nonnull String username, @Nonnull UserDetails actingUser) { + User user = userService.getUserByUsername(username); + user.setSecret(null); + user.setTwoFactorType(null); + userService.updateUser(user, actingUser); + } + + /** + * "If the current user is not the user being modified, and the current user has the authority to + * modify the user, then disable two-factor authentication for the user." + * + * @param currentUser The user who is making the request. + * @param userUid The user UID of the user to disable 2FA for. + * @param errors A Consumer object that will be called if there is an error. + */ + @Transactional + public void privileged2FADisable( + @Nonnull User currentUser, @Nonnull String userUid, @Nonnull Consumer errors) + throws ForbiddenException, NotFoundException { + User user = userService.getUser(userUid); + if (user == null) { + throw new NotFoundException(ErrorCode.E6201); + } + if (currentUser.getUid().equals(user.getUid()) + || !userService.canCurrentUserCanModify(currentUser, user, errors)) { + throw new ForbiddenException(ErrorCode.E3021); + } + UserDetails actingUser = UserDetails.fromUser(currentUser); + if (actingUser == null) { + throw new NotFoundException(ErrorCode.E6201); + } + reset2FA(user.getUsername(), actingUser); + } + + /** + * Email the user with a new 2FA code. + * + * @param username The user to send the 2FA code. + */ + @Transactional + public void sendEmail2FACode(@Nonnull String username) throws ConflictException { + User user = userService.getUserByUsername(username); + if (user == null) { + throw new ConflictException(ErrorCode.E6201); + } + if (!user.isTwoFactorEnabled()) { + throw new ConflictException(ErrorCode.E3031); + } + if (!user.getTwoFactorType().equals(TwoFactorType.EMAIL_ENABLED)) { + throw new ConflictException(ErrorCode.E3048); + } + if (!userService.isEmailVerified(user)) { + throw new ConflictException(ErrorCode.E3043); + } + + Email2FACode email2FACode = generateEmail2FACode(); + user.setSecret(email2FACode.encodedCode()); + + send2FACodeWithEmailSender(user, email2FACode.code()); + + userService.updateUser(user, new SystemUser()); + } + + public record Email2FACode(String code, String encodedCode) {} + + @Nonnull + @NonTransactional + public static Email2FACode generateEmail2FACode() { + String code = new String(CodeGenerator.generateSecureRandomNumber(6)); + String encodedCode = code + "|" + (System.currentTimeMillis() + TWOFA_EMAIL_CODE_EXPIRY_MILLIS); + return new Email2FACode(code, encodedCode); + } + + private void send2FACodeWithEmailSender(@Nonnull User user, @Nonnull String code) + throws ConflictException { + I18n i18n = + i18nManager.getI18n( + userSettingsService.getUserSettings(user.getUsername(), true).getUserUiLocale()); + + String applicationTitle = settingsProvider.getCurrentSettings().getApplicationTitle(); + + Map vars = new HashMap<>(); + vars.put("applicationTitle", applicationTitle); + vars.put("i18n", i18n); + vars.put("username", user.getUsername()); + vars.put("email", user.getEmail()); + vars.put("fullName", user.getName()); + vars.put("code", code); + + VelocityManager vm = new VelocityManager(); + String messageBody = vm.render(vars, "twofa_email_body_template_v1"); + String messageSubject = i18n.getString("email_2fa_subject") + " " + applicationTitle; + + OutboundMessageResponse status = + emailMessageSender.sendMessage(messageSubject, messageBody, null, null, Set.of(user), true); + + if (EmailResponse.SENT != status.getResponseObject()) { + throw new ConflictException(ErrorCode.E3049); + } + } + + @NonTransactional + public @Nonnull byte[] generateQRCode(@Nonnull User currentUser) throws ConflictException { + if (!settingsProvider.getCurrentSettings().getTOTP2FAEnabled()) { + throw new ConflictException(ErrorCode.E3046); + } + if (!TwoFactorType.ENROLLING_TOTP.equals(currentUser.getTwoFactorType())) { + throw new ConflictException(ErrorCode.E3047); + } + + String totpURL = + TwoFactorAuthUtils.generateTOTP2FAURL( + settingsProvider.getCurrentSettings().getApplicationTitle(), + currentUser.getSecret(), + currentUser.getUsername()); + + List errorCodes = new ArrayList<>(); + byte[] qrCode = TwoFactorAuthUtils.generateQRCode(totpURL, 200, 200, errorCodes::add); + // Check for errors in the QR code generation + if (!errorCodes.isEmpty()) { + throw new ConflictException(errorCodes.get(0)); + } + return qrCode; + } + + /** + * Verify the 2FA code for the user. + * + * @param user The user + * @param code The 2FA code + * @return true if the code is invalid, false if the code is valid. + */ + private boolean isInvalid2FACode(@Nonnull User user, @Nonnull String code) + throws ConflictException { + if (Strings.isNullOrEmpty(user.getSecret())) { + throw new ConflictException(ErrorCode.E3028); + } + return !TwoFactorAuthUtils.isValid2FACode(user.getTwoFactorType(), code, user.getSecret()); + } +} diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/twofa/TwoFactorAuthUtils.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/twofa/TwoFactorAuthUtils.java new file mode 100644 index 000000000000..0675bb1121cb --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/twofa/TwoFactorAuthUtils.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2004-2022, 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 static org.hisp.dhis.feedback.ErrorCode.E3026; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.MultiFormatWriter; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.hisp.dhis.feedback.ErrorCode; +import org.hisp.dhis.user.User; +import org.jboss.aerogear.security.otp.Totp; +import org.jboss.aerogear.security.otp.api.Base32; +import org.jboss.aerogear.security.otp.api.Base32.DecodingException; + +/** + * @author Henning Håkonsen + * @author Morten Svanæs + */ +@Slf4j +public class TwoFactorAuthUtils { + private TwoFactorAuthUtils() { + throw new IllegalStateException("Utility class"); + } + + private static final Pattern PIPE_SPLIT = Pattern.compile("\\|"); + private static final Pattern SECRET_AND_TTL = Pattern.compile("^[0-9]{6}\\|\\d+$"); + private static final Pattern TWO_FACTOR_CODE = Pattern.compile("^[0-9]{6}$"); + + /** + * Generate QR code in PNG format based on given qrContent. + * + * @param qrContent content to be used for generating the QR code. + * @param width width of the generated PNG image. + * @param height height of the generated PNG image. + * @return PNG image as a byte array or an empty byte array if the generation fails. + */ + public static byte[] generateQRCode( + @Nonnull String qrContent, int width, int height, @Nonnull Consumer errorCode) { + try { + BitMatrix bitMatrix = + new MultiFormatWriter() + .encode( + new String(qrContent.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8), + BarcodeFormat.QR_CODE, + width, + height); + + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + MatrixToImageWriter.writeToStream(bitMatrix, "PNG", byteArrayOutputStream); + return byteArrayOutputStream.toByteArray(); + } catch (WriterException | IOException e) { + log.warn("Failed to create QR PNG", e); + errorCode.accept(E3026); + return ArrayUtils.EMPTY_BYTE_ARRAY; + } + } + + /** + * Generate TOTP URL-based appName and {@link User}, this is used as input to the TOTP generator. + * + *

The URL format is otpauth://totp/Service%20Name:test@example.com? + * secret=CLAH6OEOV52XVYTKHGKBERP42IUZHY4D&issuer=Example%20Service + * + *

The format was invented and defined by Google, see: + * https://github.com/google/google-authenticator/wiki/Key-Uri-Format + */ + public static String generateTOTP2FAURL( + @Nonnull String appName, @Nonnull String secret, @Nonnull String username) { + String normalizedAppname = StringUtils.stripToEmpty(appName); + // replace possible non-ASCII characters into 'X's + normalizedAppname = normalizedAppname.replaceAll("[^\\p{ASCII}]", "X"); + String app = ("DHIS2_" + normalizedAppname).replace(" ", "%20"); + return String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s", app, username, secret, app); + } + + /** + * Validate the 2FA code based on the given type. + * + * @param type {@link TwoFactorType} + * @param code 2FA code + * @param secret 2FA secret + * @return true if the code is valid, false otherwise. + */ + public static boolean isValid2FACode( + @Nonnull TwoFactorType type, @Nonnull String code, @Nonnull String secret) { + code = StringUtils.deleteWhitespace(code); + if (code.isEmpty()) { + return false; + } + if (TwoFactorType.TOTP_ENABLED == type || TwoFactorType.ENROLLING_TOTP == type) { + return TwoFactorAuthUtils.verifyTOTP2FACode(code, secret); + } else if (TwoFactorType.EMAIL_ENABLED == type || TwoFactorType.ENROLLING_EMAIL == type) { + return TwoFactorAuthUtils.verifyEmail2FACode(code, secret); + } + return false; + } + + /** + * Verify the email based2FA code. + * + * @param code 2FA code + * @param secretAndTTL secret and TTL string separated by a pipe character. + * @return true if the code is valid, false otherwise. + */ + public static boolean verifyEmail2FACode(@Nonnull String code, @Nonnull String secretAndTTL) { + if (!SECRET_AND_TTL.matcher(secretAndTTL).matches()) { + return false; + } + String[] parts = PIPE_SPLIT.split(secretAndTTL); + String secret = parts[0]; + long ttl = Long.parseLong(parts[1]); + if (ttl < System.currentTimeMillis()) { + return false; + } + return code.equals(secret); + } + + /** + * Verify the TOTP 2FA code. + * + * @param code 2FA code + * @param secret 2FA secret + * @return true if the code is valid, false otherwise. + */ + public static boolean verifyTOTP2FACode(@Nonnull String code, @Nonnull String secret) { + if (!TWO_FACTOR_CODE.matcher(code).matches()) { + return false; + } + + try { + byte[] decodedSecretBytes = Base32.decode(secret); + if (decodedSecretBytes == null || decodedSecretBytes.length != 20) { + log.warn("TOTP secret decoding failed, is null or invalid length"); + return false; + } + } catch (DecodingException e) { + log.warn("TOTP secret decoding failed", e); + return false; + } + + try { + Totp totp = new Totp(secret); + return totp.verify(code); + } catch (Exception e) { + log.warn("TOTP secret decoding or verification failed", e); + return false; + } + } +} diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/startup/DefaultAdminUserPopulator.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/startup/DefaultAdminUserPopulator.java index 6cfae69fcaf9..c0d1bfb4b254 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/startup/DefaultAdminUserPopulator.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/startup/DefaultAdminUserPopulator.java @@ -28,7 +28,7 @@ package org.hisp.dhis.startup; import static com.google.common.base.Preconditions.checkNotNull; -import static org.hisp.dhis.user.DefaultUserService.TWO_FACTOR_AUTH_REQUIRED_RESTRICTION_NAME; +import static org.hisp.dhis.security.twofa.TwoFactorAuthService.TWO_FACTOR_AUTH_REQUIRED_RESTRICTION_NAME; import java.util.Set; import java.util.UUID; diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/DefaultUserService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/DefaultUserService.java index 6409343033a1..81ed017ad7b7 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/DefaultUserService.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/DefaultUserService.java @@ -75,7 +75,6 @@ import org.hisp.dhis.feedback.ConflictException; import org.hisp.dhis.feedback.ErrorCode; import org.hisp.dhis.feedback.ErrorReport; -import org.hisp.dhis.feedback.ForbiddenException; import org.hisp.dhis.feedback.NotFoundException; import org.hisp.dhis.i18n.I18n; import org.hisp.dhis.i18n.I18nManager; @@ -86,14 +85,12 @@ import org.hisp.dhis.period.Cal; import org.hisp.dhis.period.PeriodType; import org.hisp.dhis.security.PasswordManager; -import org.hisp.dhis.security.TwoFactoryAuthenticationUtils; import org.hisp.dhis.security.acl.AclService; import org.hisp.dhis.setting.SystemSettingsProvider; import org.hisp.dhis.system.util.ValidationUtils; import org.hisp.dhis.system.velocity.VelocityManager; import org.hisp.dhis.util.DateUtils; import org.hisp.dhis.util.ObjectUtils; -import org.jboss.aerogear.security.otp.api.Base32; import org.springframework.context.annotation.Lazy; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; @@ -105,6 +102,7 @@ /** * @author Chau Thu Tran + * @author Morten Svanæs */ @Slf4j @Lazy @@ -794,33 +792,6 @@ public List getExpiringUserAccounts(int inDays) { return userStore.getExpiringUserAccounts(inDays); } - @Transactional - @Override - public void resetTwoFactor(User user, UserDetails actingUser) { - user.setSecret(null); - updateUser(user, actingUser); - } - - @Transactional - @Override - public void enableTwoFa(User user, String code) { - if (user.getSecret() == null) { - throw new IllegalStateException( - "User has not asked for a QR code yet, call the /qr endpoint first"); - } - - if (!UserService.hasTwoFactorSecretForApproval(user)) { - throw new IllegalStateException( - "QR already approved, you must call /disable and then call /qr before you can enable"); - } - - if (!TwoFactoryAuthenticationUtils.verify(code, user.getSecret())) { - throw new IllegalStateException("Invalid code"); - } - - approveTwoFactorSecret(user, CurrentUserUtil.getCurrentUserDetails()); - } - @Override public void registerFailed2FADisableAttempt(String username) { Integer attempts = twoFaDisableFailedAttemptCache.get(username).orElse(0); @@ -834,47 +805,10 @@ public void registerSuccess2FADisable(String username) { } @Override - public boolean twoFaDisableIsLocked(String username) { + public boolean is2FADisableEndpointLocked(String username) { return twoFaDisableFailedAttemptCache.get(username).orElse(0) >= LOGIN_MAX_FAILED_ATTEMPTS; } - @Transactional - @Override - public void disableTwoFa(User user, String code) { - if (user.getSecret() == null) { - throw new IllegalStateException("Two factor is not enabled, enable first"); - } - - if (twoFaDisableIsLocked(user.getUsername())) { - throw new IllegalStateException("Too many failed attempts, try again later"); - } - - if (!TwoFactoryAuthenticationUtils.verify(code, user.getSecret())) { - registerFailed2FADisableAttempt(user.getUsername()); - throw new IllegalStateException("Invalid code"); - } - - resetTwoFactor(user, CurrentUserUtil.getCurrentUserDetails()); - registerSuccess2FADisable(user.getUsername()); - } - - @Override - @Transactional - public void privilegedTwoFactorDisable( - User currentUser, String userUid, Consumer errors) throws ForbiddenException { - User user = getUser(userUid); - if (user == null) { - throw new IllegalArgumentException("User not found"); - } - - if (currentUser.getUid().equals(user.getUid()) - || !canCurrentUserCanModify(currentUser, user, errors)) { - throw new ForbiddenException(ErrorCode.E3021.getMessage()); - } - - resetTwoFactor(user, UserDetails.fromUser(currentUser)); - } - @Override @Transactional public int disableUsersInactiveSince(Date inactiveSince) { @@ -996,64 +930,6 @@ public boolean canCurrentUserCanModify( return true; } - @Override - @Transactional - public void generateTwoFactorOtpSecretForApproval(User user) { - String newSecret = TWO_FACTOR_CODE_APPROVAL_PREFIX + Base32.random(); - user.setSecret(newSecret); - updateUser(user); - } - - @Override - @Transactional - public void approveTwoFactorSecret(User user, UserDetails actingUser) { - if (user.getSecret() != null && UserService.hasTwoFactorSecretForApproval(user)) { - user.setSecret(user.getSecret().replace(TWO_FACTOR_CODE_APPROVAL_PREFIX, "")); - updateUser(user, actingUser); - } - } - - @Override - public boolean hasTwoFactorRoleRestriction(UserDetails userDetails) { - return userDetails.hasAnyRestrictions(Set.of(TWO_FACTOR_AUTH_REQUIRED_RESTRICTION_NAME)); - } - - @Override - @Transactional - public void validateTwoFactorUpdate(boolean before, boolean after, User userToModify) - throws ForbiddenException { - if (before == after) { - return; - } - - if (!before) { - throw new ForbiddenException("You can not enable 2FA with this API endpoint, only disable."); - } - - UserDetails currentUserDetails = CurrentUserUtil.getCurrentUserDetails(); - - // Current user can not update their own 2FA settings, must use - // /2fa/enable or disable API, even if they are admin. - if (currentUserDetails.getUid().equals(userToModify.getUid())) { - throw new ForbiddenException(ErrorCode.E3030.getMessage()); - } - - // If current user has access to manage this user, they can disable 2FA. - if (!aclService.canUpdate(currentUserDetails, userToModify)) { - throw new ForbiddenException( - String.format( - "User `%s` is not allowed to update object `%s`.", - currentUserDetails.getUsername(), userToModify)); - } - - User currentUser = userStore.getUserByUsername(currentUserDetails.getUsername()); - - if (!canAddOrUpdateUser(getUids(userToModify.getGroups()), currentUser) - || !currentUserDetails.canModifyUser(userToModify)) { - throw new ForbiddenException("You don't have the proper permissions to update this user."); - } - } - @Override @Nonnull @Transactional(readOnly = true) @@ -1082,20 +958,36 @@ public List getLinkedUserAccounts(@Nonnull User actingUser) { } @Override - public void invalidateUserSessions(String userUid) { - UserDetails principal = getPrincipalFromSessionRegistry(userUid); - if (principal != null) { - List allSessions = sessionRegistry.getAllSessions(principal, false); - allSessions.forEach(SessionInformation::expireNow); + public List listSessions(String userUID) { + User user = userStore.getByUid(userUID); + if (user == null) { + return List.of(); } + return sessionRegistry.getAllSessions(createUserDetails(user), true); + } + + @Override + public List listSessions(UserDetails principal) { + return sessionRegistry.getAllSessions(principal, true); } - private UserDetails getPrincipalFromSessionRegistry(String userUid) { - return sessionRegistry.getAllPrincipals().stream() - .map(UserDetails.class::cast) - .filter(principal -> userUid.equals(principal.getUid())) - .findFirst() - .orElse(null); + @Override + public void invalidateAllSessions() { + for (Object allPrincipal : sessionRegistry.getAllPrincipals()) { + for (SessionInformation allSession : sessionRegistry.getAllSessions(allPrincipal, true)) { + sessionRegistry.removeSessionInformation(allSession.getSessionId()); + } + } + } + + @Override + public void invalidateUserSessions(String username) { + User user = getUserByUsername(username); + UserDetails userDetails = createUserDetails(user); + if (userDetails != null) { + List allSessions = sessionRegistry.getAllSessions(userDetails, false); + allSessions.forEach(SessionInformation::expireNow); + } } @Override @@ -1564,7 +1456,7 @@ public boolean sendEmailVerificationToken(User user, String token, String reques @Override @Transactional public boolean verifyEmail(String token) { - User user = getUserByVerificationToken(token); + User user = getUserByEmailVerificationToken(token); if (user == null) { return false; } @@ -1588,8 +1480,8 @@ public boolean verifyEmail(String token) { @Override @Transactional(readOnly = true) - public User getUserByVerificationToken(String token) { - return userStore.getUserByVerificationToken(token); + public User getUserByEmailVerificationToken(String token) { + return userStore.getUserByEmailVerificationToken(token); } @Override @@ -1600,7 +1492,7 @@ public List getUsersWithOrgUnit( @Override public boolean isEmailVerified(User user) { - return user.getEmail().equals(user.getVerifiedEmail()); + return user.isEmailVerified(); } @Override diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/hibernate/HibernateUserStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/hibernate/HibernateUserStore.java index 2ab8d5cb73b6..fb61d674501d 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/hibernate/HibernateUserStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/hibernate/HibernateUserStore.java @@ -640,7 +640,7 @@ public void setActiveLinkedAccounts( } @Override - public User getUserByVerificationToken(String token) { + public User getUserByEmailVerificationToken(String token) { Query query = getSession() .createQuery("from User u where u.emailVerificationToken like :token", User.class); diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/i18n_global.properties b/dhis-2/dhis-services/dhis-service-core/src/main/resources/i18n_global.properties index 1c3fa2aca13e..02bace0e18d3 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/i18n_global.properties +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/i18n_global.properties @@ -1798,6 +1798,16 @@ email_verify_1_2nd_paragraph=Please click the link below to verify the email add email_verify_1_3rd_paragraph=You must respond to this email within one hour. If you take no action, the email address will not be verified. verify_email_subject=Verify email address +#-- 2FA code email -------------------------------------------------------------# + +email_2fa_subject=Your Two-Factor Authentication Code for +email_2fa_1_greeting=Dear +email_2fa_1_1st_paragraph_application_title=You have requested a two-factor authentication code to log into your account. +email_2fa_1_2nd_paragraph=Your two-factor authentication code: +email_2fa_1_3rd_paragraph=Please use this code within 15 minutes to log into your account. +email_2fa_1_4th_paragraph=If you did not request this code, please contact your system administrator immediately to secure your account. +email_2fa_1_ending=Thank you, + #-- Cache strategy display strings --------------------------------------------# cache_strategy=Cache strategy diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/user/hibernate/User.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/user/hibernate/User.hbm.xml index be38c5269d64..72d1925d7aac 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/user/hibernate/User.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/user/hibernate/User.hbm.xml @@ -125,6 +125,14 @@ + + + org.hisp.dhis.security.twofa.TwoFactorType + true + 12 + + + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/twofa_email_body_template_v1.vm b/dhis-2/dhis-services/dhis-service-core/src/main/resources/twofa_email_body_template_v1.vm new file mode 100644 index 000000000000..96e4c8326bab --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/twofa_email_body_template_v1.vm @@ -0,0 +1,15 @@ +$object.i18n.getString( "email_2fa_1_greeting" ) ${object.fullName}, + +$object.i18n.getString( + "email_2fa_1_1st_paragraph_application_title" ) + +$object.i18n.getString( "email_2fa_1_2nd_paragraph" ) +${object.code} + +$object.i18n.getString( "email_2fa_1_3rd_paragraph" ) + +$object.i18n.getString( "email_2fa_1_4th_paragraph" ) + + +$object.i18n.getString( "email_2fa_1_ending" ) +${object.applicationTitle} \ No newline at end of file diff --git a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserObjectBundleHook.java b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserObjectBundleHook.java index a32a9334ec3f..952d9104ea65 100644 --- a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserObjectBundleHook.java +++ b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserObjectBundleHook.java @@ -208,7 +208,7 @@ public void postUpdate(User persistedUser, ObjectBundle bundle) { updateUserSettings(persistedUser); if (Boolean.TRUE.equals(invalidateSessions)) { - userService.invalidateUserSessions(persistedUser.getUid()); + userService.invalidateUserSessions(persistedUser.getUsername()); } bundle.removeExtras(persistedUser, PRE_UPDATE_USER_KEY); diff --git a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserRoleBundleHook.java b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserRoleBundleHook.java index 36c119e36474..4b94f396b1fc 100644 --- a/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserRoleBundleHook.java +++ b/dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/objectbundle/hooks/UserRoleBundleHook.java @@ -68,7 +68,7 @@ public void postUpdate(UserRole updatedUserRole, ObjectBundle bundle) { if (Boolean.TRUE.equals(invalidateSessions)) { for (User user : updatedUserRole.getUsers()) { - userService.invalidateUserSessions(user.getUid()); + userService.invalidateUserSessions(user.getUsername()); } } diff --git a/dhis-2/dhis-services/dhis-service-setting/src/test/java/org/hisp/dhis/setting/SystemSettingsTest.java b/dhis-2/dhis-services/dhis-service-setting/src/test/java/org/hisp/dhis/setting/SystemSettingsTest.java index 0af982a766c3..7b13eed663b8 100644 --- a/dhis-2/dhis-services/dhis-service-setting/src/test/java/org/hisp/dhis/setting/SystemSettingsTest.java +++ b/dhis-2/dhis-services/dhis-service-setting/src/test/java/org/hisp/dhis/setting/SystemSettingsTest.java @@ -90,7 +90,7 @@ void testIsTranslatable() { @Test void testKeysWithDefaults() { Set keys = SystemSettings.keysWithDefaults(); - assertEquals(137, keys.size()); + assertEquals(139, keys.size()); // just check some at random assertTrue(keys.contains("syncSkipSyncForDataChangedBefore")); assertTrue(keys.contains("keyTrackerDashboardLayout")); diff --git a/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.42/V2_42_29__add_2FA_type_userinfo.sql b/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.42/V2_42_29__add_2FA_type_userinfo.sql new file mode 100644 index 000000000000..ea279aa07f06 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.42/V2_42_29__add_2FA_type_userinfo.sql @@ -0,0 +1,9 @@ +-- DHIS2-13334: Add twofactortype column to userinfo table +ALTER TABLE userinfo + ADD COLUMN IF NOT EXISTS twofactortype character varying(50) DEFAULT 'NOT_ENABLED' NOT NULL; + +-- Set all existing users to have the default twofactortype TOTP, if the secret column is not empty and does not start with 'APPROVAL_'. +UPDATE userinfo +SET twofactortype = 'TOTP_ENABLED' +WHERE secret IS NOT NULL + AND secret NOT LIKE 'APPROVAL_%'; \ No newline at end of file diff --git a/dhis-2/dhis-test-e2e/docker-compose.yml b/dhis-2/dhis-test-e2e/docker-compose.yml index 6c5d0d166996..c0f219bea265 100644 --- a/dhis-2/dhis-test-e2e/docker-compose.yml +++ b/dhis-2/dhis-test-e2e/docker-compose.yml @@ -58,8 +58,3 @@ services: MINIO_ROOT_USER: root MINIO_ROOT_PASSWORD: dhisdhis - selenium: - image: "selenium/standalone-chrome:latest" - ports: - - "4444" - - "7900" diff --git a/dhis-2/dhis-test-e2e/pom.xml b/dhis-2/dhis-test-e2e/pom.xml index 9957dd40f7e0..cb44f7768147 100644 --- a/dhis-2/dhis-test-e2e/pom.xml +++ b/dhis-2/dhis-test-e2e/pom.xml @@ -36,9 +36,33 @@ 4.5.14 5.4.1 2.18.0 + 6.1.12 + 1.2 + 1.0.0 + + org.jboss.aerogear + aerogear-otp-java + ${aerogear-otp-java.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + org.subethamail + subethasmtp-wiser + ${subethasmtp-wiser.version} + test + + + org.springframework + spring-web + ${spring.version} + com.fasterxml.jackson.core jackson-annotations diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/CodeGenerator.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/CodeGenerator.java new file mode 100644 index 000000000000..2c2b30f008d3 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/CodeGenerator.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2004-2022, 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.login; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * @author bobj + */ +public class CodeGenerator { + + private CodeGenerator() { + throw new IllegalStateException("Utility class"); + } + + public static final String NUMERIC_CHARS = "0123456789"; + + public static final String UPPERCASE_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + public static final String LOWERCASE_LETTERS = "abcdefghijklmnopqrstuvwxyz"; + + public static final String LETTERS = LOWERCASE_LETTERS + UPPERCASE_LETTERS; + + public static final String ALPHANUMERIC_CHARS = NUMERIC_CHARS + LETTERS; + + /** + * Generates a string of random alphanumeric characters to the following rules: + * + *

+ * + * @return a code. + */ + private static char[] generateRandomAlphanumericCode(int codeSize, java.util.Random r) { + char[] randomChars = new char[codeSize]; + + // First char should be a letter + randomChars[0] = LETTERS.charAt(r.nextInt(LETTERS.length())); + + for (int i = 1; i < codeSize; ++i) { + randomChars[i] = ALPHANUMERIC_CHARS.charAt(r.nextInt(ALPHANUMERIC_CHARS.length())); + } + + return randomChars; + } + + /** + * Generates a string of random alphanumeric characters. Uses a {@link ThreadLocalRandom} instance + * and is considered non-secure and should not be used for security. + * + * @param codeSize the number of characters in the code. + * @return the code. + */ + public static String generateCode(int codeSize) { + ThreadLocalRandom r = ThreadLocalRandom.current(); + return new String(generateRandomAlphanumericCode(codeSize, r)); + } +} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/SpringHttpSessionInitializer.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/HttpHeadersBuilder.java similarity index 72% rename from dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/SpringHttpSessionInitializer.java rename to dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/HttpHeadersBuilder.java index 41d6fcf0379b..f5aaabbdbbc1 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/SpringHttpSessionInitializer.java +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/HttpHeadersBuilder.java @@ -25,16 +25,30 @@ * (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.webapi.security.session; +package org.hisp.dhis.login; -import org.hisp.dhis.webapi.filter.DefaultSessionConfig; -import org.springframework.session.web.context.AbstractHttpSessionApplicationInitializer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; /** - * This is used for adding springSessionRepositoryFilter into the filter chain. The actual filter - * bean used will be either backed by redis from the {@link RedisSpringSessionConfig} or a dummy - * filter from {@link DefaultSessionConfig}. + * Builder of Spring {@link HttpHeaders} instances. * - * @author Ameen Mohamed + * @author Lars Helge Overland */ -public class SpringHttpSessionInitializer extends AbstractHttpSessionApplicationInitializer {} +public class HttpHeadersBuilder { + private HttpHeaders headers; + + public HttpHeadersBuilder() { + this.headers = new HttpHeaders(); + } + + /** Builds the {@link HttpHeaders} instance. */ + public HttpHeaders build() { + return headers; + } + + public HttpHeadersBuilder withContentTypeJson() { + this.headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + return this; + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/LoginRequest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/LoginRequest.java new file mode 100644 index 000000000000..1e6828f568b3 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/LoginRequest.java @@ -0,0 +1,48 @@ +/* + * 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.login; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author Morten Svanæs + */ +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Builder +public class LoginRequest { + + @JsonProperty private String username; + @JsonProperty private String password; + @JsonProperty private String twoFactorCode; +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/LoginResponse.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/LoginResponse.java new file mode 100644 index 000000000000..5acb9643f5d4 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/LoginResponse.java @@ -0,0 +1,65 @@ +/* + * 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.login; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * @author Morten Svanæs + */ +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Builder +public class LoginResponse { + @Getter + public enum STATUS { + SUCCESS("loginSuccess"), + ACCOUNT_DISABLED("accountDisabled"), + ACCOUNT_LOCKED("accountLocked"), + ACCOUNT_EXPIRED("accountExpired"), + PASSWORD_EXPIRED("passwordExpired"), + INCORRECT_TWO_FACTOR_CODE("incorrectTwoFactorCode"), + REQUIRES_TWO_FACTOR_ENROLMENT("requiresTwoFactorEnrolment"); + + private final String keyName; + private final String defaultValue; + + STATUS(String keyName) { + this.keyName = keyName; + this.defaultValue = null; + } + } + + @JsonProperty private STATUS loginStatus; + @JsonProperty private String redirectUrl; +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/LoginTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/LoginTest.java new file mode 100644 index 000000000000..b74127a9c8b2 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/LoginTest.java @@ -0,0 +1,824 @@ +/* + * 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.login; + +import static org.hisp.dhis.login.PortUtil.findAvailablePort; +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import javax.mail.BodyPart; +import javax.mail.Message; +import javax.mail.MessagingException; +import javax.mail.Part; +import javax.mail.internet.MimeMessage; +import javax.mail.internet.MimeMultipart; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.login.LoginResponse.STATUS; +import org.hisp.dhis.test.e2e.helpers.config.TestConfiguration; +import org.jboss.aerogear.security.otp.Totp; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.http.*; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.subethamail.wiser.Wiser; +import org.subethamail.wiser.WiserMessage; + +@Tag("logintests") +@Slf4j +public class LoginTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final RestTemplate restTemplate = new RestTemplate(); + + public static String dhis2ServerApi = "http://localhost:8080/api"; + public static String dhis2Server = "http://localhost:8080/"; + + public static final String DEFAULT_DASHBOARD_PATH = "/dhis-web-dashboard/"; + private static final String LOGIN_API_PATH = "/auth/login"; + public static final String SUPER_USER_ROLE_UID = "yrB6vc5Ip3r"; + + // Change this to "localhost" if you want to run the tests locally + public static final String SMTP_HOSTNAME = "test"; + + private static int smtpPort; + private static Wiser wiser; + + private static String orgUnitUID; + + @BeforeAll + static void setup() throws JsonProcessingException { + startSMTPServer(); + dhis2ServerApi = TestConfiguration.get().baseUrl(); + dhis2Server = TestConfiguration.get().baseUrl().replace("/api", "/"); + orgUnitUID = createOrgUnit(); + } + + @AfterEach + void tearDown() { + wiser.getMessages().clear(); + invalidateAllSession(); + } + + @Test + void testDefaultLogin() throws JsonProcessingException { + String username = CodeGenerator.generateCode(8); + String password = "Test123###..."; + createSuperuser(username, password, orgUnitUID); + + ResponseEntity loginResponse = + loginWithUsernameAndPassword(username, password, null); + assertLoginSuccess(loginResponse, DEFAULT_DASHBOARD_PATH); + String cookie = extractSessionCookie(loginResponse); + + // Verify session cookie works + ResponseEntity meResponse = getWithCookie("/me", cookie); + assertEquals(HttpStatus.OK, meResponse.getStatusCode()); + assertNotNull(meResponse.getBody()); + } + + @Test + void testDefaultLoginFailure() { + LoginRequest loginRequest = + LoginRequest.builder().username("admin").password("wrongpassword").build(); + HttpEntity requestEntity = new HttpEntity<>(loginRequest, jsonHeaders()); + try { + restTemplate.postForEntity( + dhis2ServerApi + LOGIN_API_PATH, requestEntity, LoginResponse.class); + fail("Should have thrown an exception"); + } catch (HttpClientErrorException e) { + assertEquals(HttpStatus.UNAUTHORIZED, e.getStatusCode()); + } + } + + @Test + void testLoginWithTOTP2FA() throws JsonProcessingException { + String username = CodeGenerator.generateCode(8); + String password = "Test123###..."; + enrollAndLoginTOTP2FA(username, password); + } + + @Test + void testLoginWithEmail2FA() throws IOException, MessagingException { + try { + String username = CodeGenerator.generateCode(8).toLowerCase(); + String password = "Test123###..."; + enrollAndLoginEmail2FA(username, password); + } finally { + // Reset system settings + setSystemProperty("email2FAEnabled", "false"); + setSystemProperty("keyEmailHostName", ""); + setSystemProperty("keyEmailPort", "25"); + setSystemProperty("keyEmailUsername", null); + setSystemProperty("keyEmailSender", ""); + setSystemProperty("keyEmailTls", "true"); + } + } + + @Test + void testDisableTOTP2FA() throws JsonProcessingException { + String username = CodeGenerator.generateCode(8); + String password = "Test123###..."; + QrSecretAndCookie qrSecretAndCookie = enrollAndLoginTOTP2FA(username, password); + + // Test Login doesn't work without 2FA code + ResponseEntity failedLoginResp = + loginWithUsernameAndPassword(username, password, null); + assertLoginStatus(failedLoginResp, STATUS.INCORRECT_TWO_FACTOR_CODE); + + // Disable TOTP 2FA + disable2FAWithTOTP(qrSecretAndCookie); + + // Test Login works without 2FA code + ResponseEntity successfulLoginResp = + loginWithUsernameAndPassword(username, password, null); + assertLoginSuccess(successfulLoginResp, DEFAULT_DASHBOARD_PATH); + } + + @Test + void testDisableEmail2FA() throws IOException, MessagingException { + String username = CodeGenerator.generateCode(8); + String password = "Test123###..."; + String cookie = enrollAndLoginEmail2FA(username, password); + + // Test Login doesn't work without 2FA code + ResponseEntity failedLoginResp = + loginWithUsernameAndPassword(username, password, null); + assertLoginStatus(failedLoginResp, STATUS.INCORRECT_TWO_FACTOR_CODE); + + // Disable Email 2FA + disable2FAWithEmail(cookie); + + // Test Login works without 2FA code + ResponseEntity successfulLoginResp = + loginWithUsernameAndPassword(username, password, null); + assertLoginSuccess(successfulLoginResp, DEFAULT_DASHBOARD_PATH); + } + + @Test + void testShowQrCodeAfterEnabledFails() throws JsonProcessingException { + String username = CodeGenerator.generateCode(8); + String password = "Test123###..."; + QrSecretAndCookie qrSecretAndCookie = enrollAndLoginTOTP2FA(username, password); + + // Should fail since 2FA is already enabled + ResponseEntity getQrJsonResp = + getWithCookie("/2fa/qrCodeJson", qrSecretAndCookie.cookie()); + assertEquals(HttpStatus.CONFLICT, getQrJsonResp.getStatusCode()); + assertMessage(getQrJsonResp, "User is not in TOTP 2FA enrollment mode"); + + ResponseEntity getQrPngResp = + getWithCookie("/2fa/qrCodePng", qrSecretAndCookie.cookie()); + assertEquals(HttpStatus.CONFLICT, getQrPngResp.getStatusCode()); + assertMessage(getQrPngResp, "User is not in TOTP 2FA enrollment mode"); + } + + @Test + void testReEnrollFails() throws JsonProcessingException { + String username = CodeGenerator.generateCode(8); + String password = "Test123###..."; + QrSecretAndCookie qrSecretAndCookie = enrollAndLoginTOTP2FA(username, password); + + ResponseEntity enrollTOTPResp = + postWithCookie("/2fa/enrollTOTP2FA", null, qrSecretAndCookie.cookie()); + assertEquals(HttpStatus.CONFLICT, enrollTOTPResp.getStatusCode()); + assertMessage( + enrollTOTPResp, "User has 2FA enabled already, disable 2FA before you try to enroll again"); + + ResponseEntity enrollEmailResp = + postWithCookie("/2fa/enrollEmail2FA", null, qrSecretAndCookie.cookie()); + assertEquals(HttpStatus.CONFLICT, enrollEmailResp.getStatusCode()); + assertMessage( + enrollEmailResp, + "User has 2FA enabled already, disable 2FA before you try to enroll again"); + } + + @Test + void testRedirectWithQueryParam() { + assertRedirectToSameUrl("/api/users?fields=id,name,displayName"); + } + + @Test + void testRedirectWithoutQueryParam() { + assertRedirectToSameUrl("/api/users"); + } + + @Test + void testRedirectToResource() { + assertRedirectUrl("/users/resource.js", DEFAULT_DASHBOARD_PATH); + } + + @Test + void testRedirectToHtmlResource() { + assertRedirectToSameUrl("/users/resource.html"); + } + + @Test + void testRedirectToSlashEnding() { + assertRedirectToSameUrl("/users/"); + } + + @Test + void testRedirectToResourceWorker() { + assertRedirectUrl("/dhis-web-dashboard/service-worker.js", DEFAULT_DASHBOARD_PATH); + } + + @Test + void testRedirectToCssResourceWorker() { + assertRedirectUrl("/dhis-web-dashboard/static/css/main.4536e618.css", DEFAULT_DASHBOARD_PATH); + } + + @Test + void testRedirectAccountWhenVerifiedEmailEnforced() { + changeSystemSetting("enforceVerifiedEmail", "true"); + try { + assertRedirectUrl("/dhis-web-dashboard/", "/dhis-web-user-profile/#/account"); + } finally { + changeSystemSetting("enforceVerifiedEmail", "false"); + } + } + + @Test + void testRedirectEndingSlash() { + assertRedirectToSameUrl("/dhis-web-dashboard/"); + } + + @Test + void testRedirectMissingEndingSlash() { + testRedirectWhenLoggedIn("/dhis-web-dashboard", "/dhis-web-dashboard/"); + } + + // -------------------------------------------------------------------------------------------- + // Helper classes and records + // -------------------------------------------------------------------------------------------- + + public record QrSecretAndCookie(String secret, String cookie) {} + + // -------------------------------------------------------------------------------------------- + // Private helper methods for starting servers and setup + // -------------------------------------------------------------------------------------------- + + private static void startSMTPServer() { + smtpPort = findAvailablePort(); + wiser = new Wiser(); + wiser.setHostname(SMTP_HOSTNAME); + wiser.setPort(smtpPort); + wiser.start(); + } + + private static void invalidateAllSession() { + ResponseEntity response = deleteWithAdminBasicAuth("/sessions", null); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + // -------------------------------------------------------------------------------------------- + // 2FA enrollment / disabling methods + // -------------------------------------------------------------------------------------------- + + private QrSecretAndCookie enrollAndLoginTOTP2FA(String username, String password) + throws JsonProcessingException { + createSuperuser(username, password, orgUnitUID); + + String cookie = performInitialLogin(username, password); + + String base32Secret = enrollTOTP2FA(cookie); + + // Attempt login with a new TOTP code + cookie = loginWith2FA(username, password, new Totp(base32Secret).now()); + + return new QrSecretAndCookie(base32Secret, cookie); + } + + private static String enrollAndLoginEmail2FA(String username, String password) + throws IOException, MessagingException { + createSuperuser(username, password, orgUnitUID); + + // First login + String cookie = performInitialLogin(username, password); + + // Enable email 2FA in system settings + configureEmail2FASettings(cookie); + + // Try enrolling email 2FA, should fail without verified email + ResponseEntity twoFAResp = postWithCookie("/2fa/enrollEmail2FA", null, cookie); + assertEquals(HttpStatus.CONFLICT, twoFAResp.getStatusCode()); + assertMessage( + twoFAResp, + "User does not have a verified email, please verify your email before you try to enable 2FA"); + + verifyEmail(cookie); + + enrollEmail2FA(cookie); + + // Attempt to login with email 2FA + loginWith2FA(username, password, extract2FACodeFromLatestEmail()); + + return cookie; + } + + private static void enrollEmail2FA(String cookie) throws MessagingException, IOException { + // Enroll in Email 2FA successfully + ResponseEntity twoFAEnableResp = postWithCookie("/2fa/enrollEmail2FA", null, cookie); + assertEquals(HttpStatus.OK, twoFAEnableResp.getStatusCode()); + assertMessage( + twoFAEnableResp, + "The user has enrolled in email-based 2FA, a code was generated and sent successfully to the user's email"); + + // Enable Email 2FA + String enroll2FACode = extract2FACodeFromLatestEmail(); + ResponseEntity enableResp = + postWithCookie("/2fa/enable", Map.of("code", enroll2FACode), cookie); + assertEquals(HttpStatus.OK, enableResp.getStatusCode()); + assertMessage(enableResp, "2FA was enabled successfully"); + } + + private static void verifyEmail(String cookie) throws MessagingException, IOException { + sendVerificationEmail(cookie); + String verifyToken = extractEmailVerifyToken(); + verifyEmailWithToken(cookie, verifyToken); + } + + private void disable2FAWithTOTP(QrSecretAndCookie qrSecretAndCookie) + throws JsonProcessingException { + // Generate TOTP code + String code = new Totp(qrSecretAndCookie.secret()).now(); + ResponseEntity disableResp = + postWithCookie("/2fa/disable", Map.of("code", code), qrSecretAndCookie.cookie()); + assertEquals(HttpStatus.OK, disableResp.getStatusCode()); + assertMessage(disableResp, "2FA was disabled successfully"); + } + + private void disable2FAWithEmail(String cookie) throws IOException, MessagingException { + Map emptyCode = Map.of("code", ""); + ResponseEntity disableFailResp = postWithCookie("/2fa/disable", emptyCode, cookie); + assertEquals(HttpStatus.CONFLICT, disableFailResp.getStatusCode()); + assertMessage(disableFailResp, "2FA code was sent to the users email"); + String disable2FACode = extract2FACodeFromLatestEmail(); + + ResponseEntity disableOkResp = + postWithCookie("/2fa/disable", Map.of("code", disable2FACode), cookie); + assertEquals(HttpStatus.OK, disableOkResp.getStatusCode()); + assertMessage(disableOkResp, "2FA was disabled successfully"); + } + + // -------------------------------------------------------------------------------------------- + // Private helper methods for login and assertions + // -------------------------------------------------------------------------------------------- + + private static String performInitialLogin(String username, String password) { + ResponseEntity loginResponse = + loginWithUsernameAndPassword(username, password, null); + assertLoginSuccess(loginResponse, DEFAULT_DASHBOARD_PATH); + return extractSessionCookie(loginResponse); + } + + private static String loginWith2FA(String username, String password, String twoFACode) { + ResponseEntity login2FAResp = + loginWithUsernameAndPassword(username, password, twoFACode); + assertLoginSuccess(login2FAResp, DEFAULT_DASHBOARD_PATH); + return extractSessionCookie(login2FAResp); + } + + // -------------------------------------------------------------------------------------------- + // Private helper methods for TOTP enrollment steps + // -------------------------------------------------------------------------------------------- + + private static String enrollTOTP2FA(String cookie) throws JsonProcessingException { + ResponseEntity twoFAResp = postWithCookie("/2fa/enrollTOTP2FA", null, cookie); + assertMessage( + twoFAResp, + "The user has enrolled in TOTP 2FA, call the QR code endpoint to continue the process"); + + // Get base32 TOTP secret from QR code + String base32Secret = getBase32SecretFromQR(cookie); + + // Enable TOTP 2FA + enableTOTP2FA(cookie, base32Secret); + + return base32Secret; + } + + private static String getBase32SecretFromQR(String cookie) throws JsonProcessingException { + ResponseEntity showQRResp = getWithCookie("/2fa/qrCodeJson", cookie); + JsonNode showQrRespJson = objectMapper.readTree(showQRResp.getBody()); + String base32Secret = showQrRespJson.get("base32Secret").asText(); + assertNotNull(base32Secret); + return base32Secret; + } + + private static void enableTOTP2FA(String cookie, String base32Secret) + throws JsonProcessingException { + String enable2FACode = new Totp(base32Secret).now(); + Map enable2FAReqBody = Map.of("code", enable2FACode); + ResponseEntity enable2FAResp = postWithCookie("/2fa/enable", enable2FAReqBody, cookie); + assertMessage(enable2FAResp, "2FA was enabled successfully"); + } + + // -------------------------------------------------------------------------------------------- + // Private helper methods for email verification steps + // -------------------------------------------------------------------------------------------- + + private static void configureEmail2FASettings(String cookie) { + setSystemPropertyWithCookie("email2FAEnabled", "true", cookie); + setSystemPropertyWithCookie("keyEmailHostName", SMTP_HOSTNAME, cookie); + setSystemPropertyWithCookie("keyEmailPort", String.valueOf(smtpPort), cookie); + setSystemPropertyWithCookie("keyEmailUsername", "nils", cookie); + setSystemPropertyWithCookie("keyEmailSender", "system@nils.no", cookie); + setSystemPropertyWithCookie("keyEmailTls", "false", cookie); + } + + private static void sendVerificationEmail(String cookie) { + ResponseEntity sendVerificationEmailResp = + postWithCookie("/account/sendEmailVerification", null, cookie); + assertEquals(HttpStatus.CREATED, sendVerificationEmailResp.getStatusCode()); + } + + private static void verifyEmailWithToken(String cookie, String verifyToken) { + ResponseEntity verifyEmailResp = + getWithCookie("/account/verifyEmail?token=" + verifyToken, cookie); + assertEquals(HttpStatus.OK, verifyEmailResp.getStatusCode()); + } + + // -------------------------------------------------------------------------------------------- + // Private helper methods for assertions + // -------------------------------------------------------------------------------------------- + + private static void assertLoginSuccess( + ResponseEntity response, String expectedRedirectUrl) { + assertLoginStatus(response, STATUS.SUCCESS); + assertNotNull(response.getBody()); + assertEquals(expectedRedirectUrl, response.getBody().getRedirectUrl()); + } + + private static void assertLoginStatus(ResponseEntity response, STATUS status) { + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(status, response.getBody().getLoginStatus()); + } + + private static void assertMessage(ResponseEntity response, String expectedMessage) + throws JsonProcessingException { + assertNotNull(response); + JsonNode jsonResponse = objectMapper.readTree(response.getBody()); + assertEquals(expectedMessage, jsonResponse.get("message").asText()); + } + + // -------------------------------------------------------------------------------------------- + // Private helper methods for redirect assertions + // -------------------------------------------------------------------------------------------- + + private static void assertRedirectToSameUrl(String url) { + assertRedirectUrl(url, url); + } + + private static void assertRedirectUrl(String url, String redirectUrl) { + // Do an invalid login to store original URL request + ResponseEntity firstResponse = + restTemplate.postForEntity(dhis2Server + url, null, LoginResponse.class); + String cookie = firstResponse.getHeaders().get(HttpHeaders.SET_COOKIE).get(0); + + // Do a valid login with the captured cookie + HttpHeaders getHeaders = jsonHeaders(); + getHeaders.set("Cookie", cookie); + LoginRequest loginRequest = + LoginRequest.builder().username("admin").password("district").build(); + HttpEntity requestEntity = new HttpEntity<>(loginRequest, getHeaders); + + ResponseEntity loginResponse = + restTemplate.postForEntity( + dhis2ServerApi + LOGIN_API_PATH, requestEntity, LoginResponse.class); + + assertNotNull(loginResponse); + assertEquals(HttpStatus.OK, loginResponse.getStatusCode()); + LoginResponse body = loginResponse.getBody(); + assertNotNull(body); + assertEquals(STATUS.SUCCESS, body.getLoginStatus()); + assertEquals(redirectUrl, body.getRedirectUrl()); + } + + private static void testRedirectWhenLoggedIn(String url, String redirectUrl) { + // Disable auto-redirects + ClientHttpRequestFactory requestFactory = + new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(HttpURLConnection connection, String httpMethod) + throws IOException { + super.prepareConnection(connection, httpMethod); + connection.setInstanceFollowRedirects(false); + } + }; + + RestTemplate restTemplateNoRedirects = new RestTemplate(requestFactory); + + // Do an invalid login to capture URL request + ResponseEntity firstResponse = + restTemplateNoRedirects.postForEntity( + dhis2Server + url, + new HttpEntity<>( + LoginRequest.builder().username("username").password("password").build(), + new HttpHeaders()), + LoginResponse.class); + String cookie = firstResponse.getHeaders().get(HttpHeaders.SET_COOKIE).get(0); + + // Do a valid login + HttpHeaders cookieHeaders = jsonHeaders(); + cookieHeaders.set("Cookie", cookie); + ResponseEntity secondResponse = + restTemplateNoRedirects.postForEntity( + dhis2ServerApi + LOGIN_API_PATH, + new HttpEntity<>( + LoginRequest.builder().username("admin").password("district").build(), + cookieHeaders), + LoginResponse.class); + cookie = extractSessionCookie(secondResponse); + + // Test the redirect + HttpHeaders headers = jsonHeaders(); + headers.set("Cookie", cookie); + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity redirResp = + restTemplateNoRedirects.exchange( + dhis2Server + "/dhis-web-dashboard", HttpMethod.GET, entity, String.class); + List location = redirResp.getHeaders().get("Location"); + assertNotNull(location); + assertEquals(1, location.size()); + String actual = location.get(0); + assertEquals(redirectUrl, actual.replaceAll(dhis2Server, "")); + } + + // -------------------------------------------------------------------------------------------- + // Private helper methods for HTTP calls + // -------------------------------------------------------------------------------------------- + + private static HttpHeaders jsonHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private static ResponseEntity loginWithUsernameAndPassword( + String username, String password, String twoFACode) { + HttpHeaders headers = jsonHeaders(); + LoginRequest loginRequest = + LoginRequest.builder() + .username(username) + .password(password) + .twoFactorCode(twoFACode) + .build(); + return restTemplate.postForEntity( + dhis2ServerApi + LOGIN_API_PATH, + new HttpEntity<>(loginRequest, headers), + LoginResponse.class); + } + + private static ResponseEntity postWithCookie(String path, Object body, String cookie) { + HttpHeaders headers = jsonHeaders(); + headers.set("Cookie", cookie); + if (body != null) { + headers.setContentType(MediaType.APPLICATION_JSON); + } + HttpEntity requestEntity = new HttpEntity<>(body, headers); + try { + return restTemplate.postForEntity(dhis2ServerApi + path, requestEntity, String.class); + } catch (HttpClientErrorException e) { + return ResponseEntity.status(e.getStatusCode()).body(e.getResponseBodyAsString()); + } + } + + private static ResponseEntity getWithCookie(String path, String cookie) { + HttpHeaders headers = jsonHeaders(); + headers.set("Cookie", cookie); + return exchangeWithHeaders(path, HttpMethod.GET, null, headers); + } + + private static ResponseEntity getWithAdminBasicAuth( + String path, Map map) { + RestTemplate rt = createRestTemplateWithAdminBasicAuthHeader(); + return rt.exchange( + dhis2ServerApi + path, HttpMethod.GET, new HttpEntity<>(map, jsonHeaders()), String.class); + } + + private static ResponseEntity postWithAdminBasicAuth( + String path, Map map) { + RestTemplate rt = createRestTemplateWithAdminBasicAuthHeader(); + return rt.exchange( + dhis2ServerApi + path, HttpMethod.POST, new HttpEntity<>(map, jsonHeaders()), String.class); + } + + private static ResponseEntity deleteWithAdminBasicAuth( + String path, Map map) { + RestTemplate rt = createRestTemplateWithAdminBasicAuthHeader(); + return rt.exchange( + dhis2ServerApi + path, + HttpMethod.DELETE, + new HttpEntity<>(map, jsonHeaders()), + String.class); + } + + private static ResponseEntity exchangeWithHeaders( + String path, HttpMethod method, Object body, HttpHeaders headers) { + try { + return restTemplate.exchange( + dhis2ServerApi + path, method, new HttpEntity<>(body, headers), String.class); + } catch (HttpClientErrorException e) { + return ResponseEntity.status(e.getStatusCode()).body(e.getResponseBodyAsString()); + } + } + + private static RestTemplate createRestTemplateWithAdminBasicAuthHeader() { + RestTemplate template = new RestTemplate(); + String authHeader = + Base64.getUrlEncoder().encodeToString("admin:district".getBytes(StandardCharsets.UTF_8)); + template + .getInterceptors() + .add( + (request, body, execution) -> { + request.getHeaders().add(HttpHeaders.AUTHORIZATION, "Basic " + authHeader); + return execution.execute(request, body); + }); + return template; + } + + // -------------------------------------------------------------------------------------------- + // Private helper methods for parsing and extracting content from emails + // -------------------------------------------------------------------------------------------- + + private static @NotNull String extract2FACodeFromLatestEmail() + throws MessagingException, IOException { + List messages = wiser.getMessages(); + String text = getTextFromMessage(messages.get(messages.size() - 1).getMimeMessage()); + return text.substring(text.indexOf("code:") + 7, text.indexOf("code:") + 13); + } + + private static String getTextFromMessage(Message message) throws MessagingException, IOException { + if (message.isMimeType("text/plain")) { + return message.getContent().toString(); + } else if (message.isMimeType("multipart/*")) { + return getTextFromMimeMultipart((MimeMultipart) message.getContent()); + } else if (message.isMimeType("message/rfc822")) { + return getTextFromMessage((Message) message.getContent()); + } else { + Object content = message.getContent(); + if (content instanceof String) { + return (String) content; + } + } + return ""; + } + + private static String getTextFromMimeMultipart(MimeMultipart mimeMultipart) + throws MessagingException, IOException { + StringBuilder result = new StringBuilder(); + int count = mimeMultipart.getCount(); + for (int i = 0; i < count; i++) { + BodyPart bodyPart = mimeMultipart.getBodyPart(i); + if (Part.ATTACHMENT.equalsIgnoreCase(bodyPart.getDisposition())) { + continue; + } + if (bodyPart.isMimeType("text/plain")) { + result.append(bodyPart.getContent().toString()); + } else if (bodyPart.isMimeType("text/html")) { + result.append(bodyPart.getContent().toString()); + } else if (bodyPart.getContent() instanceof MimeMultipart) { + result.append(getTextFromMimeMultipart((MimeMultipart) bodyPart.getContent())); + } + } + return result.toString(); + } + + private static @NotNull String extractEmailVerifyToken() throws MessagingException, IOException { + assertFalse(wiser.getMessages().isEmpty()); + WiserMessage wiserMessage = wiser.getMessages().get(0); + MimeMessage verificationMessage = wiserMessage.getMimeMessage(); + String verificationEmail = getTextFromMessage(verificationMessage); + String verifyToken = + verificationEmail.substring( + verificationEmail.indexOf("?token=") + 7, + verificationEmail.indexOf("You must respond")); + return verifyToken.replaceAll("[\\n\\r]", ""); + } + + // -------------------------------------------------------------------------------------------- + // Private helper methods for server configuration and resource creation + // -------------------------------------------------------------------------------------------- + + private static String createSuperuser(String username, String password, String orgUnitUID) + throws JsonProcessingException { + Map userMap = + Map.of( + "username", + username, + "password", + password, + "email", + username + "@email.com", + "userRoles", + List.of(Map.of("id", SUPER_USER_ROLE_UID)), + "firstName", + "user", + "surname", + "userson", + "organisationUnits", + List.of(Map.of("id", orgUnitUID))); + + // Create user + ResponseEntity response = postWithAdminBasicAuth("/users", userMap); + JsonNode fullResponseNode = objectMapper.readTree(response.getBody()); + String uid = fullResponseNode.get("response").get("uid").asText(); + assertNotNull(uid); + + // Verify user + ResponseEntity userResp = getWithAdminBasicAuth("/users/" + uid, Map.of()); + assertEquals(HttpStatus.OK, userResp.getStatusCode()); + JsonNode userJson = objectMapper.readTree(userResp.getBody()); + assertEquals(username, userJson.get("username").asText()); + assertEquals(username + "@email.com", userJson.get("email").asText()); + assertEquals("user", userJson.get("firstName").asText()); + assertEquals("userson", userJson.get("surname").asText()); + assertEquals(orgUnitUID, userJson.get("organisationUnits").get(0).get("id").asText()); + assertEquals(SUPER_USER_ROLE_UID, userJson.get("userRoles").get(0).get("id").asText()); + return uid; + } + + private static String createOrgUnit() throws JsonProcessingException { + ResponseEntity jsonStringResponse = + postWithAdminBasicAuth( + "/organisationUnits", + Map.of("name", "orgA", "shortName", "orgA", "openingDate", "2024-11-21T16:00:00.000Z")); + + JsonNode fullResponseNode = objectMapper.readTree(jsonStringResponse.getBody()); + String uid = fullResponseNode.get("response").get("uid").asText(); + assertNotNull(uid); + return uid; + } + + private static String extractSessionCookie(ResponseEntity response) { + List cookies = response.getHeaders().get(HttpHeaders.SET_COOKIE); + assertNotNull(cookies); + assertEquals(1, cookies.size()); + return cookies.get(0); + } + + private static void setSystemPropertyWithCookie(String property, String value, String cookie) { + ResponseEntity systemSettingsResp = + postWithCookie("/systemSettings/" + property + "?value=" + value, null, cookie); + assertEquals(HttpStatus.OK, systemSettingsResp.getStatusCode()); + } + + private static void setSystemProperty(String property, String value) { + ResponseEntity systemSettingsResp = + postWithAdminBasicAuth("/systemSettings/" + property + "?value=" + value, null); + assertEquals(HttpStatus.OK, systemSettingsResp.getStatusCode()); + } + + private static void changeSystemSetting(String key, String value) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.TEXT_PLAIN); + RestTemplate rt = createRestTemplateWithAdminBasicAuthHeader(); + ResponseEntity response = + rt.exchange( + dhis2ServerApi + "/systemSettings/" + key, + HttpMethod.POST, + new HttpEntity<>(value, headers), + String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/PortUtil.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/PortUtil.java new file mode 100644 index 000000000000..8d0713df0012 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/login/PortUtil.java @@ -0,0 +1,59 @@ +/* + * 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.login; + +import java.io.IOException; +import java.net.ServerSocket; +import java.util.Random; + +public class PortUtil { + private static final int MIN_PORT = 49152; + private static final int MAX_PORT = 65535; + private static final Random random = new Random(); + + public static int findAvailablePort() { + int port; + do { + port = pickRandomPort(); + } while (!isPortAvailable(port)); + return port; + } + + private static int pickRandomPort() { + return random.nextInt(MAX_PORT - MIN_PORT + 1) + MIN_PORT; + } + + private static boolean isPortAvailable(int port) { + try (ServerSocket serverSocket = new ServerSocket(port)) { + serverSocket.setReuseAddress(true); + return true; + } catch (IOException e) { + return false; + } + } +} diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/user/UserServiceTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/user/UserServiceTest.java index 483d13db2814..6bbb14876f03 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/user/UserServiceTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/user/UserServiceTest.java @@ -54,11 +54,14 @@ import org.hisp.dhis.common.DeleteNotAllowedException; import org.hisp.dhis.common.IdentifiableObjectManager; import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.feedback.ConflictException; import org.hisp.dhis.feedback.ErrorReport; import org.hisp.dhis.feedback.ForbiddenException; +import org.hisp.dhis.feedback.NotFoundException; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.organisationunit.OrganisationUnitService; import org.hisp.dhis.security.PasswordManager; +import org.hisp.dhis.security.twofa.TwoFactorAuthService; import org.hisp.dhis.setting.SystemSettingsService; import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; import org.junit.jupiter.api.BeforeAll; @@ -86,6 +89,8 @@ class UserServiceTest extends PostgresIntegrationTestBase { @Autowired private PasswordManager passwordManager; + @Autowired private TwoFactorAuthService twoFactorAuthService; + private OrganisationUnit unitA; private OrganisationUnit unitB; @@ -632,20 +637,22 @@ void testFindNotifiableUsersWithLastLoginBetween() { } @Test - void testDisableTwoFaWithAdminUser() throws ForbiddenException { + void testDisableTwoFaWithAdminUser() + throws ForbiddenException, NotFoundException, ConflictException { User userToModify = createAndAddUser("A"); - userService.generateTwoFactorOtpSecretForApproval(userToModify); + twoFactorAuthService.enrollTOTP2FA(userToModify.getUsername()); userService.updateUser(userToModify); List errors = new ArrayList<>(); - userService.privilegedTwoFactorDisable(getAdminUser(), userToModify.getUid(), errors::add); + twoFactorAuthService.privileged2FADisable(getAdminUser(), userToModify.getUid(), errors::add); assertTrue(errors.isEmpty()); } @Test - void testDisableTwoFaWithManageUser() throws ForbiddenException { + void testDisableTwoFaWithManageUser() + throws ForbiddenException, ConflictException, NotFoundException { User userToModify = createAndAddUser("A"); - userService.generateTwoFactorOtpSecretForApproval(userToModify); + twoFactorAuthService.enrollTOTP2FA(userToModify.getUsername()); UserGroup userGroupA = createUserGroup('A', Sets.newHashSet(userToModify)); userGroupService.addUserGroup(userGroupA); @@ -663,7 +670,7 @@ void testDisableTwoFaWithManageUser() throws ForbiddenException { userService.updateUser(currentUser); List errors = new ArrayList<>(); - userService.privilegedTwoFactorDisable(currentUser, userToModify.getUid(), errors::add); + twoFactorAuthService.privileged2FADisable(currentUser, userToModify.getUid(), errors::add); assertTrue(errors.isEmpty()); } diff --git a/dhis-2/dhis-test-web-api/pom.xml b/dhis-2/dhis-test-web-api/pom.xml index e7aacf51a6a8..ac54aae39485 100644 --- a/dhis-2/dhis-test-web-api/pom.xml +++ b/dhis-2/dhis-test-web-api/pom.xml @@ -131,6 +131,17 @@ + + + com.google.zxing + core + test + + + com.google.zxing + javase + test + com.fasterxml.jackson.core jackson-annotations diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/AccountControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/AccountControllerTest.java index 58b1944fe370..6422a1c19332 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/AccountControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/AccountControllerTest.java @@ -235,6 +235,9 @@ void testGetLinkedAccounts() { @Test void testVerifyEmailWithTokenTwice() { + settingsService.put("keyEmailHostName", "mail.example.com"); + settingsService.put("keyEmailUsername", "mailer"); + User user = switchToNewUser("kent"); String emailAddress = user.getEmail(); @@ -252,6 +255,9 @@ void testVerifyEmailWithTokenTwice() { @Test void testSendEmailVerification() { + settingsService.put("keyEmailHostName", "mail.example.com"); + settingsService.put("keyEmailUsername", "mailer"); + User user = switchToNewUser("clark"); String emailAddress = user.getEmail(); @@ -266,6 +272,9 @@ void testSendEmailVerification() { @Test void testVerifyEmailWithToken() { + settingsService.put("keyEmailHostName", "mail.example.com"); + settingsService.put("keyEmailUsername", "mailer"); + User user = switchToNewUser("lex"); String emailAddress = user.getEmail(); @@ -298,7 +307,7 @@ void testSendEmailWithoutEmail() { "Conflict", 409, "ERROR", - "Email is not set", + "User has no email set", POST("/account/sendEmailVerification").content(HttpStatus.CONFLICT)); } @@ -311,7 +320,7 @@ void testSendEmailIsAlreadyVerifiedBySameUser() { "Conflict", 409, "ERROR", - "Email is already verified", + "User has already verified the email address", POST("/account/sendEmailVerification").content(HttpStatus.CONFLICT)); } @@ -329,12 +338,12 @@ void testSendEmailIsAlreadyVerifiedByAnotherUser() { "Conflict", 409, "ERROR", - "Email is already in use by another account", + "The email the user is trying to verify is already verified by another account", POST("/account/sendEmailVerification").content(HttpStatus.CONFLICT)); } private void assertValidEmailVerificationToken(String token) { - User user = userService.getUserByVerificationToken(token); + User user = userService.getUserByEmailVerificationToken(token); assertNotNull(user); } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/TwoFactorControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/TwoFactorControllerTest.java index 2015e98110f8..aa12206101eb 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/TwoFactorControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/TwoFactorControllerTest.java @@ -29,67 +29,198 @@ import static org.hisp.dhis.http.HttpAssertions.assertStatus; import static org.hisp.dhis.test.webapi.Assertions.assertWebMessage; -import static org.hisp.dhis.user.UserService.TWO_FACTOR_CODE_APPROVAL_PREFIX; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.zxing.BinaryBitmap; +import com.google.zxing.ChecksumException; +import com.google.zxing.FormatException; +import com.google.zxing.LuminanceSource; +import com.google.zxing.NotFoundException; +import com.google.zxing.Result; +import com.google.zxing.client.j2se.BufferedImageLuminanceSource; +import com.google.zxing.common.HybridBinarizer; +import com.google.zxing.qrcode.QRCodeReader; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Base64; import java.util.List; +import javax.imageio.ImageIO; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.feedback.ConflictException; import org.hisp.dhis.http.HttpStatus; +import org.hisp.dhis.jsontree.JsonMixed; +import org.hisp.dhis.message.MessageSender; +import org.hisp.dhis.outboundmessage.OutboundMessage; +import org.hisp.dhis.security.twofa.TwoFactorAuthService; +import org.hisp.dhis.security.twofa.TwoFactorType; +import org.hisp.dhis.setting.SystemSettingsService; import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; import org.hisp.dhis.user.CurrentUserUtil; import org.hisp.dhis.user.SystemUser; import org.hisp.dhis.user.User; import org.hisp.dhis.webapi.controller.security.TwoFactorController; import org.jboss.aerogear.security.otp.Totp; +import org.jboss.aerogear.security.otp.api.Base32; +import org.jboss.aerogear.security.otp.api.Base32.DecodingException; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; /** - * Tests the {@link TwoFactorController} sing (mocked) REST requests. + * Tests the {@link TwoFactorController} * * @author Jan Bernitt + * @author Morten Svanæs */ +@Slf4j @Transactional class TwoFactorControllerTest extends H2ControllerIntegrationTestBase { + + @Autowired private TwoFactorAuthService twoFactorAuthService; + @Autowired private SystemSettingsService systemSettingsService; + @Autowired private MessageSender emailMessageSender; + + @AfterEach + void tearDown() { + emailMessageSender.clearMessages(); + systemSettingsService.put("email2FAEnabled", "false"); + } + @Test - void testQr2FaConflictMustDisableFirst() { - assertNull(getCurrentUser().getSecret()); + void testEnrollTOTP2FA() + throws ChecksumException, NotFoundException, DecodingException, IOException, FormatException { + User user = makeUser("X", List.of("TEST")); + user.setEmail("valid.x@email.com"); + userService.addUser(user); + switchToNewUser(user); - User user = userService.getUser(CurrentUserUtil.getCurrentUserDetails().getUid()); - userService.generateTwoFactorOtpSecretForApproval(user); + assertStatus(HttpStatus.OK, POST("/2fa/enrollTOTP2FA")); + User enrolledUser = userService.getUserByUsername(user.getUsername()); + assertNotNull(enrolledUser.getSecret()); + assertTrue(enrolledUser.getSecret().matches("^[a-zA-Z0-9]{32}$")); + assertSame(TwoFactorType.ENROLLING_TOTP, enrolledUser.getTwoFactorType()); - user = userService.getUser(CurrentUserUtil.getCurrentUserDetails().getUid()); - assertNotNull(user.getSecret()); + HttpResponse res = GET("/2fa/qrCodeJson"); + assertStatus(HttpStatus.OK, res); + assertNotNull(res.content()); + JsonMixed content = res.content(); + String base32Secret = content.getString("base32Secret").string(); + String base64QRImage = content.getString("base64QRImage").string(); + String codeFromQR = decodeBase64QRAndExtractBase32Secret(base64QRImage); + assertEquals(base32Secret, codeFromQR); - String code = getCode(user); + String code = new Totp(base32Secret).now(); + assertStatus(HttpStatus.OK, POST("/2fa/enable", "{'code':'" + code + "'}")); + } - assertStatus(HttpStatus.OK, POST("/2fa/enabled", "{'code':'" + code + "'}")); + private String decodeBase64QRAndExtractBase32Secret(String base64QRCode) + throws IOException, NotFoundException, ChecksumException, FormatException, DecodingException { + // Decode the base64 encoded QR code (PNG image) + byte[] imageBytes = Base64.getDecoder().decode(base64QRCode); + // Create the image object from the byte array + BufferedImage qrImage = ImageIO.read(new ByteArrayInputStream(imageBytes)); + assertNotNull(qrImage, "QR image could not be loaded"); - user = userService.getUser(CurrentUserUtil.getCurrentUserDetails().getUid()); - assertNotNull(user.getSecret()); + // Decode QR code URL from the image + String qrCodeContent = decodeQRCode(qrImage); + assertNotNull(qrCodeContent, "QR code content could not be decoded"); + + // content looks like this: otpauth://totp/username?secret=base32secret&issuer=issuer + String secret = + qrCodeContent.substring(qrCodeContent.indexOf("?") + 8, qrCodeContent.indexOf("&")); + + // Extract the Base32-encoded secret bytes, check that it is 20 bytes long, which is 160 bits + // of entropy, which is the RFC 4226 recommendation. + byte[] decodedSecretBytes = Base32.decode(secret); + assertEquals(20, decodedSecretBytes.length); + + return secret; + } + + private String decodeQRCode(BufferedImage qrImage) + throws ChecksumException, NotFoundException, FormatException { + // Convert the BufferedImage to a ZXing binary bitmap source + LuminanceSource source = new BufferedImageLuminanceSource(qrImage); + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + // Use the ZXing's QRCodeReader to decode the QR code image + Result code = new QRCodeReader().decode(bitmap); + return code.getText(); + } + + @Test + void testEnrollEmail2FA() { + assertStatus(HttpStatus.OK, POST("/systemSettings/email2FAEnabled?value=true")); + + User user = makeUser("X", List.of("TEST")); + String emailAddress = "valid.x@email.com"; + user.setEmail(emailAddress); + user.setVerifiedEmail(emailAddress); + userService.addUser(user); + switchToNewUser(user); + + assertStatus(HttpStatus.OK, POST("/2fa/enrollEmail2FA")); + User enrolledUser = userService.getUserByUsername(user.getUsername()); + assertNotNull(enrolledUser.getSecret()); + assertTrue(enrolledUser.getSecret().matches("^[0-9]{6}\\|\\d+$")); + assertSame(TwoFactorType.ENROLLING_EMAIL, enrolledUser.getTwoFactorType()); + + List messagesByEmail = emailMessageSender.getMessagesByEmail(emailAddress); + assertFalse(messagesByEmail.isEmpty()); + String email = messagesByEmail.get(0).getText(); + + // Extract the 6-digit code from the email + String code = email.substring(email.indexOf("code:\n") + 6, email.indexOf("code:\n") + 12); + assertStatus(HttpStatus.OK, POST("/2fa/enable", "{'code':'" + code + "'}")); } @Test - void testEnable2Fa() { + void testEnableTOTP2FA() throws ConflictException { User user = makeUser("X", List.of("TEST")); user.setEmail("valid.x@email.com"); userService.addUser(user); - userService.generateTwoFactorOtpSecretForApproval(user); + twoFactorAuthService.enrollTOTP2FA(user.getUsername()); + switchToNewUser(user); + String code = new Totp(user.getSecret()).now(); + assertStatus(HttpStatus.OK, POST("/2fa/enable", "{'code':'" + code + "'}")); + } + + @Test + void testEnableEmail2FA() throws ConflictException { + assertStatus(HttpStatus.OK, POST("/systemSettings/email2FAEnabled?value=true")); + + User user = makeUser("X", List.of("TEST")); + user.setEmail("valid.x@email.com"); + user.setVerifiedEmail("valid.x@email.com"); + userService.addUser(user); + twoFactorAuthService.enrollEmail2FA(user.getUsername()); switchToNewUser(user); - String code = getCode(user); + User enrolledUser = userService.getUserByUsername(user.getUsername()); + String secret = enrolledUser.getSecret(); + assertNotNull(secret); + String[] codeAndTTL = secret.split("\\|"); + // Check that the secret is a 6-digit code followed by a TTL in milliseconds + assertTrue(codeAndTTL[0].matches("^[0-9]{6}$")); + assertTrue(codeAndTTL[1].matches("^\\d+$")); + + String code = codeAndTTL[0]; assertStatus(HttpStatus.OK, POST("/2fa/enabled", "{'code':'" + code + "'}")); } @Test - void testEnable2FaWrongCode() { + void testEnableTOTP2FAWrongCode() throws ConflictException { User user = makeUser("X", List.of("TEST")); user.setEmail("valid.x@email.com"); userService.addUser(user); - userService.generateTwoFactorOtpSecretForApproval(user); - + twoFactorAuthService.enrollTOTP2FA(user.getUsername()); switchToNewUser(user); assertEquals( @@ -100,60 +231,98 @@ void testEnable2FaWrongCode() { } @Test - void testEnable2FaNotCalledQrFirst() { + void testQr2FAConflictMustDisableFirst() throws ConflictException { + assertNull(getCurrentUser().getSecret()); + + User user = userService.getUser(CurrentUserUtil.getCurrentUserDetails().getUid()); + twoFactorAuthService.enrollTOTP2FA(user.getUsername()); + user = userService.getUser(CurrentUserUtil.getCurrentUserDetails().getUid()); + assertNotNull(user.getSecret()); + + String code = new Totp(user.getSecret()).now(); + assertStatus(HttpStatus.OK, POST("/2fa/enable", "{'code':'" + code + "'}")); + + user = userService.getUser(CurrentUserUtil.getCurrentUserDetails().getUid()); + assertNotNull(user.getSecret()); + } + + @Test + void testEnable2FANotEnrolledFirst() { assertEquals( - "User must call the /qrCode endpoint first", - POST("/2fa/enabled", "{'code':'wrong'}") - .error(HttpStatus.Series.CLIENT_ERROR) - .getMessage()); + "User must start 2FA enrollment first", + POST("/2fa/enable", "{'code':'wrong'}").error(HttpStatus.Series.CLIENT_ERROR).getMessage()); } @Test - void testDisable2Fa() { + void testDisableTOTP2FA() throws ConflictException { User newUser = makeUser("Y", List.of("TEST")); newUser.setEmail("valid.y@email.com"); + userService.addUser(newUser); + twoFactorAuthService.enrollTOTP2FA(newUser.getUsername()); + + User user = userService.getUserByUsername(newUser.getUsername()); + user.setTwoFactorType(user.getTwoFactorType().getEnabledType()); + userService.updateUser(user, new SystemUser()); + switchToNewUser(newUser); + + String code = new Totp(newUser.getSecret()).now(); + assertStatus(HttpStatus.OK, POST("/2fa/disable", "{'code':'" + code + "'}")); + } + + @Test + void testDisableEmail2FA() throws ConflictException { + assertStatus(HttpStatus.OK, POST("/systemSettings/email2FAEnabled?value=true")); + + User newUser = makeUser("Y", List.of("TEST")); + newUser.setEmail("valid.y@email.com"); + newUser.setVerifiedEmail("valid.y@email.com"); userService.addUser(newUser); - userService.generateTwoFactorOtpSecretForApproval(newUser); - userService.approveTwoFactorSecret(newUser, new SystemUser()); + twoFactorAuthService.enrollEmail2FA(newUser.getUsername()); + + User user = userService.getUserByUsername(newUser.getUsername()); + user.setTwoFactorType(user.getTwoFactorType().getEnabledType()); + userService.updateUser(user, new SystemUser()); switchToNewUser(newUser); - String code = getCode(newUser); + User enabledUser = userService.getUserByUsername(newUser.getUsername()); + String secretAndTTL = enabledUser.getSecret(); + String code = secretAndTTL.split("\\|")[0]; + assertStatus(HttpStatus.OK, POST("/2fa/disable", "{'code':'" + code + "'}")); - assertStatus(HttpStatus.OK, POST("/2fa/disabled", "{'code':'" + code + "'}")); + User disabledUser = userService.getUserByUsername(newUser.getUsername()); + assertNull(disabledUser.getSecret()); } @Test - void testDisable2FaNotEnabled() { + void testDisable2FANotEnabled() { assertEquals( - "Two factor authentication is not enabled", - POST("/2fa/disabled", "{'code':'wrong'}") + "User has not enabled 2FA", + POST("/2fa/disable", "{'code':'wrong'}") .error(HttpStatus.Series.CLIENT_ERROR) .getMessage()); } @Test - void testDisable2FaTooManyTimes() { + void testDisable2FATooManyTimes() throws ConflictException { User user = makeUser("X", List.of("TEST")); user.setEmail("valid.x@email.com"); userService.addUser(user); - userService.generateTwoFactorOtpSecretForApproval(user); - + twoFactorAuthService.enrollTOTP2FA(user.getUsername()); switchToNewUser(user); - String code = getCode(user); - assertStatus(HttpStatus.OK, POST("/2fa/enabled", "{'code':'" + code + "'}")); - - assertStatus(HttpStatus.UNAUTHORIZED, POST("/2fa/disabled", "{'code':'333333'}")); + String code = new Totp(user.getSecret()).now(); + assertStatus(HttpStatus.OK, POST("/2fa/enable", "{'code':'" + code + "'}")); + assertStatus(HttpStatus.FORBIDDEN, POST("/2fa/disable", "{'code':'333333'}")); for (int i = 0; i < 3; i++) { assertWebMessage( - "Unauthorized", - 401, + "Forbidden", + 403, "ERROR", "Invalid 2FA code", - POST("/2fa/disabled", "{'code':'333333'}").content(HttpStatus.UNAUTHORIZED)); + POST("/2fa/disable", "{'code':'333333'}").content(HttpStatus.FORBIDDEN)); } assertWebMessage( @@ -161,14 +330,6 @@ void testDisable2FaTooManyTimes() { 409, "ERROR", "Too many failed disable attempts. Please try again later", - POST("/2fa/disabled", "{'code':'333333'}").content(HttpStatus.CONFLICT)); - } - - private static String replaceApprovalPartOfTheSecret(User user) { - return user.getSecret().replace(TWO_FACTOR_CODE_APPROVAL_PREFIX, ""); - } - - private static String getCode(User newUser) { - return new Totp(replaceApprovalPartOfTheSecret(newUser)).now(); + POST("/2fa/disable", "{'code':'333333'}").content(HttpStatus.CONFLICT)); } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/AuthenticationControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/AuthenticationControllerTest.java index 8c8adcc44be9..9c81bb5da4ef 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/AuthenticationControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/AuthenticationControllerTest.java @@ -27,6 +27,7 @@ */ package org.hisp.dhis.webapi.controller.security; +import static org.hisp.dhis.common.CodeGenerator.generateSecureRandomBytes; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -34,7 +35,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.Calendar; +import lombok.extern.slf4j.Slf4j; import org.hisp.dhis.http.HttpStatus; +import org.hisp.dhis.security.twofa.TwoFactorAuthService; +import org.hisp.dhis.security.twofa.TwoFactorAuthService.Email2FACode; +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; @@ -52,6 +57,7 @@ /** * @author Morten Svanæs */ +@Slf4j class AuthenticationControllerTest extends AuthenticationApiTestBase { @Autowired private SystemSettingsService settingsService; @@ -62,16 +68,14 @@ void tearDown() { settingsService.put("keyLockMultipleFailedLogins", false); settingsService.put("credentialsExpires", 0); settingsService.clearCurrentSettings(); + userService.invalidateAllSessions(); + clearSecurityContext(); } @Test - void testSuccessfulLoginWithOldUsername() { - User adminUser = userService.getUserByUsername("admin"); - adminUser.setUsername("Üsername"); - userService.updateUser(adminUser); - + void testSuccessfulLogin() { JsonLoginResponse response = - POST("/auth/login", "{'username':'Üsername','password':'district'}") + POST("/auth/login", "{'username':'admin','password':'district'}") .content(HttpStatus.OK) .as(JsonLoginResponse.class); @@ -80,18 +84,22 @@ void testSuccessfulLoginWithOldUsername() { } @Test - void testSuccessfulLogin() { + void testLoginWithDeprecatedUsername() { + User adminUser = userService.getUserByUsername("admin"); + adminUser.setUsername("Üsername"); + userService.updateUser(adminUser); JsonLoginResponse response = - POST("/auth/login", "{'username':'admin','password':'district'}") + POST("/auth/login", "{'username':'Üsername','password':'district'}") .content(HttpStatus.OK) .as(JsonLoginResponse.class); assertEquals("SUCCESS", response.getLoginStatus()); assertEquals("/dhis-web-dashboard/", response.getRedirectUrl()); + userService.invalidateAllSessions(); } @Test - void testWrongUsernameOrPassword() { + void testLoginWrongPassword() { User userA = createUserWithAuth("userb", "ALL"); injectSecurityContextUser(userA); @@ -108,42 +116,62 @@ void testWrongUsernameOrPassword() { } @Test - void testLoginWith2FAEnrolmentUser() throws Exception { - User userA = createUserWithAuth("usera", "ALL"); - injectSecurityContextUser(userA); - - mvc.perform( - get("/api/2fa/qrCode") - .header("Authorization", "Basic dXNlcmE6ZGlzdHJpY3Q=") - .contentType("application/octet-stream") - .accept("application/octet-stream")) - .andExpect(status().isAccepted()); + void testLoginWithTOTP2FA() { + User admin = userService.getUserByUsername("admin"); + String secret = Base32.encode(generateSecureRandomBytes(20)); + admin.setSecret(secret); + admin.setTwoFactorType(TwoFactorType.TOTP_ENABLED); + userService.updateUser(admin); JsonLoginResponse wrong2FaCodeResponse = - POST("/auth/login", "{'username':'usera','password':'district'}") + POST("/auth/login", "{'username':'admin','password':'district'}") .content(HttpStatus.OK) .as(JsonLoginResponse.class); - assertEquals("REQUIRES_TWO_FACTOR_ENROLMENT", wrong2FaCodeResponse.getLoginStatus()); - assertNull(wrong2FaCodeResponse.getRedirectUrl()); + assertEquals("INCORRECT_TWO_FACTOR_CODE", wrong2FaCodeResponse.getLoginStatus()); + Assertions.assertNull(wrong2FaCodeResponse.getRedirectUrl()); + + Totp totp = new Totp(secret); + String code = totp.now(); + loginWith2FACode(code); } @Test - void testLoginWith2FAEnabledUser() { + void testLoginEmail2FA() { User admin = userService.getUserByUsername("admin"); - String secret = Base32.random(); + String emailAddress = "valid.x@email.com"; + admin.setEmail(emailAddress); + admin.setVerifiedEmail(emailAddress); + Email2FACode email2FACode = TwoFactorAuthService.generateEmail2FACode(); + String secret = email2FACode.encodedCode(); admin.setSecret(secret); + admin.setTwoFactorType(TwoFactorType.EMAIL_ENABLED); userService.updateUser(admin); - JsonLoginResponse wrong2FaCodeResponse = - POST("/auth/login", "{'username':'admin','password':'district'}") + loginWith2FACode(email2FACode.code()); + } + + @Test + void testLoginWith2FAEnrolmentOngoing() throws Exception { + User userA = createUserWithAuth("usera", "ALL"); + injectSecurityContextUser(userA); + + // This will initiate TOTP 2FA enrolment. + mvc.perform( + get("/api/2fa/qrCode") + .header("Authorization", "Basic dXNlcmE6ZGlzdHJpY3Q=") + .contentType("application/octet-stream") + .accept("application/octet-stream")) + .andExpect(status().isAccepted()); + + JsonLoginResponse loginResponse = + POST("/auth/login", "{'username':'usera','password':'district'}") .content(HttpStatus.OK) .as(JsonLoginResponse.class); - assertEquals("INCORRECT_TWO_FACTOR_CODE", wrong2FaCodeResponse.getLoginStatus()); - Assertions.assertNull(wrong2FaCodeResponse.getRedirectUrl()); - - validateTOTP(secret); + // This means that the user can still log in as normal while the 2FA enrolment is ongoing. + assertEquals("SUCCESS", loginResponse.getLoginStatus()); + assertEquals("/dhis-web-dashboard/", loginResponse.getRedirectUrl()); } @Test @@ -228,7 +256,7 @@ void testLoginWithAccountExpiredUser() { @Test void testSessionGetsCreated() { - clearSecurityContext(); + userService.invalidateAllSessions(); HttpResponse response = POST("/auth/login", "{'username':'admin','password':'district'}"); assertNotNull(response); @@ -240,11 +268,7 @@ void testSessionGetsCreated() { assertEquals("admin", actual.getUsername()); } - // test redirect to login page when not logged in, remember url befire login... - - private void validateTOTP(String secret) { - Totp totp = new Totp(secret); - String code = totp.now(); + private void loginWith2FACode(String code) { JsonLoginResponse ok2FaCodeResponse = POST( "/auth/login", diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/AccountController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/AccountController.java index 1dcb789a56ea..8fbe8cdf33fe 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/AccountController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/AccountController.java @@ -45,12 +45,14 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.hisp.dhis.common.DhisApiVersion; +import org.hisp.dhis.common.HashUtils; import org.hisp.dhis.common.IllegalQueryException; import org.hisp.dhis.common.OpenApi; import org.hisp.dhis.configuration.ConfigurationService; @@ -74,6 +76,7 @@ import org.hisp.dhis.user.RestoreType; import org.hisp.dhis.user.SystemUser; import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; import org.hisp.dhis.user.UserLookup; import org.hisp.dhis.user.UserRole; import org.hisp.dhis.user.UserService; @@ -87,6 +90,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.session.SessionInformation; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -536,13 +540,17 @@ public ResponseEntity> validatePasswordPost( public void sendEmailVerification(@CurrentUser User currentUser, HttpServletRequest request) throws ConflictException { if (Strings.isNullOrEmpty(currentUser.getEmail())) { - throw new ConflictException("Email is not set"); + throw new ConflictException("User has no email set"); } if (userService.isEmailVerified(currentUser)) { - throw new ConflictException("Email is already verified"); + throw new ConflictException("User has already verified the email address"); } if (userService.getUserByVerifiedEmail(currentUser.getEmail()) != null) { - throw new ConflictException("Email is already in use by another account"); + throw new ConflictException( + "The email the user is trying to verify is already verified by another account"); + } + if (!settingsProvider.getCurrentSettings().isEmailConfigured()) { + throw new ConflictException("System has no SMTP server configured"); } // Generate a new email verification token and send it, we do this in two steps: @@ -568,6 +576,16 @@ public void verifyEmail(@RequestParam String token) throws ConflictException { } } + @GetMapping("/listSessions") + public @ResponseBody Map listSessions(@CurrentUser UserDetails userDetails) { + List sessionInformation = userService.listSessions(userDetails); + return sessionInformation.stream() + .collect( + Collectors.toMap( + s -> HashUtils.hashSHA1(s.getSessionId().getBytes()), + s -> String.valueOf(s.isExpired()))); + } + // --------------------------------------------------------------------- // Supportive methods // --------------------------------------------------------------------- diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/AuthenticationController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/AuthenticationController.java index 8c3b80d61b34..d88aa8a24b54 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/AuthenticationController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/AuthenticationController.java @@ -29,6 +29,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpSessionEvent; import java.util.List; import javax.annotation.PostConstruct; import org.hisp.dhis.common.DhisApiVersion; @@ -70,6 +72,7 @@ import org.springframework.security.web.savedrequest.DefaultSavedRequest; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.savedrequest.SavedRequest; +import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -106,6 +109,7 @@ public class AuthenticationController { @Autowired private UserService userService; @Autowired protected ApplicationEventPublisher eventPublisher; + @Autowired private HttpSessionEventPublisher httpSessionEventPublisher; private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy(); @@ -117,21 +121,23 @@ public class AuthenticationController { @PostConstruct public void init() { - if (sessionRegistry != null) { - - int maxSessions = - Integer.parseInt(dhisConfig.getProperty((ConfigurationKey.MAX_SESSIONS_PER_USER))); - ConcurrentSessionControlAuthenticationStrategy concurrentStrategy = - new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry); - concurrentStrategy.setMaximumSessions(maxSessions); - - sessionStrategy = - new CompositeSessionAuthenticationStrategy( - List.of( - concurrentStrategy, - new SessionFixationProtectionStrategy(), - new RegisterSessionAuthenticationStrategy(sessionRegistry))); + if (sessionRegistry == null) { + throw new IllegalStateException("SessionRegistry is null"); } + + int maxSessions = + Integer.parseInt(dhisConfig.getProperty((ConfigurationKey.MAX_SESSIONS_PER_USER))); + + ConcurrentSessionControlAuthenticationStrategy concurrentStrategy = + new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry); + concurrentStrategy.setMaximumSessions(maxSessions); + + sessionStrategy = + new CompositeSessionAuthenticationStrategy( + List.of( + concurrentStrategy, + new SessionFixationProtectionStrategy(), + new RegisterSessionAuthenticationStrategy(sessionRegistry))); } @PostMapping("/login") @@ -144,7 +150,6 @@ public LoginResponse login( validateRequest(loginRequest); Authentication authenticationResult = doAuthentication(request, loginRequest); - this.sessionStrategy.onAuthentication(authenticationResult, request, response); saveContext(request, response, authenticationResult); @@ -210,6 +215,9 @@ private void saveContext( this.securityContextHolderStrategy.setContext(context); this.securityContextRepository.saveContext(context, request, response); + + HttpSession session = request.getSession(true); + httpSessionEventPublisher.sessionCreated(new HttpSessionEvent(session)); } private String getRedirectUrl( diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/SessionController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/SessionController.java new file mode 100644 index 000000000000..312cf22ca5e3 --- /dev/null +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/SessionController.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2004-2022, 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.webapi.controller.security; + +import static org.hisp.dhis.security.Authorities.ALL; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.hisp.dhis.common.DhisApiVersion; +import org.hisp.dhis.common.HashUtils; +import org.hisp.dhis.common.OpenApi; +import org.hisp.dhis.feedback.NotFoundException; +import org.hisp.dhis.security.RequiresAuthority; +import org.hisp.dhis.user.User; +import org.hisp.dhis.user.UserDetails; +import org.hisp.dhis.user.UserService; +import org.hisp.dhis.webapi.mvc.annotation.ApiVersion; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author Morten Svanæs + */ +@OpenApi.Document( + entity = User.class, + classifiers = {"team:platform", "purpose:support"}) +@RestController +@RequestMapping("/api/sessions") +@RequiredArgsConstructor +@ApiVersion({DhisApiVersion.DEFAULT, DhisApiVersion.ALL}) +public class SessionController { + + private final SessionRegistry sessionRegistry; + private final UserService userService; + + @GetMapping(produces = APPLICATION_JSON_VALUE) + @RequiresAuthority(anyOf = ALL) + public Map listAllSessions() throws NotFoundException { + return listAllUserSessions().stream() + .collect( + Collectors.toMap( + s -> HashUtils.hashSHA1(s.getSessionId().getBytes()), + s -> + ((UserDetails) s.getPrincipal()).getUsername() + + (s.isExpired() ? ", (inactive)" : ", (active)"))); + } + + private List listAllUserSessions() throws NotFoundException { + List allSessions = new ArrayList<>(); + List allUsers = userService.getAllUsers(); + for (User user : allUsers) { + allSessions.addAll( + sessionRegistry.getAllSessions(userService.createUserDetails(user.getUid()), true)); + } + return allSessions; + } + + @DeleteMapping(value = "/{username}") + @RequiresAuthority(anyOf = ALL) + public void invalidateSessions(@PathVariable("username") String username) { + User user = userService.getUserByUsername(username); + UserDetails userDetails = userService.createUserDetails(user); + if (userDetails != null) { + List allSessions = sessionRegistry.getAllSessions(userDetails, false); + allSessions.forEach(SessionInformation::expireNow); + } + } + + @DeleteMapping + @RequiresAuthority(anyOf = ALL) + public void invalidateAllSessions() { + List allUsers = userService.getAllUsers(); + for (User user : allUsers) { + UserDetails userDetails = userService.createUserDetails(user); + if (userDetails != null) { + List allSessions = sessionRegistry.getAllSessions(userDetails, false); + allSessions.forEach(SessionInformation::expireNow); + } + } + } +} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/TwoFactorController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/TwoFactorController.java index b3c53962a3fd..00cc9acc4d7c 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/TwoFactorController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/security/TwoFactorController.java @@ -27,38 +27,34 @@ */ package org.hisp.dhis.webapi.controller.security; -import static org.hisp.dhis.dxf2.webmessage.WebMessageUtils.conflict; import static org.hisp.dhis.dxf2.webmessage.WebMessageUtils.ok; -import static org.hisp.dhis.dxf2.webmessage.WebMessageUtils.unauthorized; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.Strings; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; +import java.util.Base64; import java.util.Map; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.hisp.dhis.common.DhisApiVersion; import org.hisp.dhis.common.OpenApi; import org.hisp.dhis.dxf2.webmessage.WebMessage; -import org.hisp.dhis.dxf2.webmessage.WebMessageException; +import org.hisp.dhis.feedback.ConflictException; import org.hisp.dhis.feedback.ErrorCode; -import org.hisp.dhis.security.TwoFactoryAuthenticationUtils; -import org.hisp.dhis.setting.SystemSettings; +import org.hisp.dhis.feedback.ForbiddenException; +import org.hisp.dhis.security.twofa.TwoFactorAuthService; import org.hisp.dhis.user.CurrentUser; import org.hisp.dhis.user.User; import org.hisp.dhis.user.UserDetails; -import org.hisp.dhis.user.UserService; import org.hisp.dhis.webapi.mvc.annotation.ApiVersion; import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -66,6 +62,7 @@ * @author Henning Håkonsen * @author Morten Svanaes */ +@Slf4j @OpenApi.Document( entity = User.class, classifiers = {"team:platform", "purpose:support"}) @@ -74,142 +71,122 @@ @ApiVersion({DhisApiVersion.DEFAULT, DhisApiVersion.ALL}) @AllArgsConstructor public class TwoFactorController { - private final UserService defaultUserService; + private final TwoFactorAuthService twoFactorAuthService; - /** - * @deprecated Use {@link #generateQRCode}. - */ - @Deprecated(since = "2.39") - @GetMapping(value = "/qr", produces = APPLICATION_JSON_VALUE) - @ResponseStatus(HttpStatus.ACCEPTED) - @ResponseBody - public Map getQrCode(@CurrentUser UserDetails currentUser) - throws WebMessageException { - if (currentUser == null) { - throw new WebMessageException(conflict(ErrorCode.E3027.getMessage(), ErrorCode.E3027)); - } + @PostMapping(value = "/enrollTOTP2FA") + @ResponseStatus(HttpStatus.OK) + public WebMessage enrollTOTP2FA(@CurrentUser User currentUser) throws ConflictException { + twoFactorAuthService.enrollTOTP2FA(currentUser.getUsername()); + return ok( + "The user has enrolled in TOTP 2FA, call the QR code endpoint to continue the process"); + } - return Map.of("url", "url"); + @PostMapping(value = "/enrollEmail2FA") + @ResponseStatus(HttpStatus.OK) + public WebMessage enrollEmail2FA(@CurrentUser User currentUser) throws ConflictException { + twoFactorAuthService.enrollEmail2FA(currentUser.getUsername()); + return ok( + "The user has enrolled in email-based 2FA, a code was generated and sent successfully to the user's email"); } + /** + * Returns generated QR code for the user to scan. + * + * @throws IOException + * @throws ConflictException + */ @OpenApi.Response(byte[].class) - @GetMapping(value = "/qrCode", produces = APPLICATION_OCTET_STREAM_VALUE) + @GetMapping( + value = {"/qrCodePng"}, + produces = APPLICATION_OCTET_STREAM_VALUE) @ResponseStatus(HttpStatus.ACCEPTED) - public void generateQRCode( - @CurrentUser User currentUser, HttpServletResponse response, SystemSettings settings) - throws IOException, WebMessageException { - if (currentUser == null) { - throw new WebMessageException(conflict(ErrorCode.E3027.getMessage(), ErrorCode.E3027)); - } - - if (currentUser.isTwoFactorEnabled() - && !UserService.hasTwoFactorSecretForApproval(currentUser)) { - throw new WebMessageException(conflict(ErrorCode.E3022.getMessage(), ErrorCode.E3022)); - } - - defaultUserService.generateTwoFactorOtpSecretForApproval(currentUser); - - String appName = settings.getApplicationTitle(); - - List errorCodes = new ArrayList<>(); - - String qrContent = - TwoFactoryAuthenticationUtils.generateQrContent(appName, currentUser, errorCodes::add); - - if (!errorCodes.isEmpty()) { - throw new WebMessageException(conflict(errorCodes.get(0).getMessage(), errorCodes.get(0))); - } + public void qrCodePng(@CurrentUser User currentUser, HttpServletResponse response) + throws IOException, ConflictException { + byte[] qrCode = twoFactorAuthService.generateQRCode(currentUser); + response.getOutputStream().write(qrCode); + } - byte[] qrCode = - TwoFactoryAuthenticationUtils.generateQRCode(qrContent, 200, 200, errorCodes::add); + /** + * Shows the generated QR code for the user to scan as a JSON object. Where the QR code (PNG + * image) is represented as a base64 encoded string. And the secret encoded in Base32. + * + * @throws ConflictException + */ + @GetMapping( + value = {"/qrCodeJson"}, + produces = APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.OK) + public QRCode qrCodeJson(@CurrentUser User currentUser) throws ConflictException { + byte[] qrCode = twoFactorAuthService.generateQRCode(currentUser); + return new QRCode(currentUser.getSecret(), Base64.getEncoder().encodeToString(qrCode)); + } - if (!errorCodes.isEmpty()) { - throw new WebMessageException(conflict(errorCodes.get(0).getMessage(), errorCodes.get(0))); - } + public record QRCode(@JsonProperty String base32Secret, @JsonProperty String base64QRImage) {} - OutputStream outputStream = response.getOutputStream(); - outputStream.write(qrCode); + /** + * Enrolls the user in TOTP 2FA and generates a QR code for the user to scan. + * + * @throws IOException The QR code could not be generated. + * @throws ConflictException The user is already enrolled in 2FA. + */ + @OpenApi.Response(byte[].class) + @GetMapping( + value = {"/qrCode"}, + produces = APPLICATION_OCTET_STREAM_VALUE) + @ResponseStatus(HttpStatus.ACCEPTED) + @Deprecated(forRemoval = true, since = "2.42") + public void generateQRCode(@CurrentUser User currentUser, HttpServletResponse response) + throws IOException, ConflictException { + twoFactorAuthService.enrollTOTP2FA(currentUser.getUsername()); + byte[] qrCode = twoFactorAuthService.generateQRCode(currentUser); + response.getOutputStream().write(qrCode); } @GetMapping( value = "/enabled", consumes = {"text/*", "application/*"}) @ResponseStatus(HttpStatus.OK) - @ResponseBody public boolean isEnabled(@CurrentUser(required = true) User currentUser) { - return currentUser.isTwoFactorEnabled() - && !UserService.hasTwoFactorSecretForApproval(currentUser); + return currentUser.isTwoFactorEnabled(); } /** - * Enable 2FA authentication for the current user. + * Enable 2FA authentication for the current user if the user is the enrollment mode and the code + * is valid. * * @param body The body of the request. - * @param currentUser This is the user that is currently logged in. + * @param currentUser The user currently logged in. */ @PostMapping( - value = "/enabled", + value = {"/enabled", "/enable"}, consumes = {"text/*", "application/*"}) @ResponseStatus(HttpStatus.OK) - @ResponseBody public WebMessage enable( - @RequestBody Map body, @CurrentUser(required = true) User currentUser) - throws WebMessageException { + @RequestBody Map body, @CurrentUser(required = true) UserDetails currentUser) + throws ForbiddenException, ConflictException { String code = body.get("code"); - - if (!currentUser.isTwoFactorEnabled() - || !UserService.hasTwoFactorSecretForApproval(currentUser)) { - throw new WebMessageException(conflict(ErrorCode.E3029.getMessage(), ErrorCode.E3029)); + if (Strings.isNullOrEmpty(code)) { + throw new ConflictException(ErrorCode.E3050); } - - if (!verifyCode(code, currentUser)) { - return unauthorized(ErrorCode.E3023.getMessage()); - } - - defaultUserService.enableTwoFa(currentUser, code); - - return ok("Two factor authentication was enabled successfully"); + twoFactorAuthService.enable2FA(currentUser.getUsername(), code, currentUser); + return ok("2FA was enabled successfully"); } /** * Disable 2FA authentication for the current user. * * @param body The body of the request. - * @param currentUser This is the user that is currently logged in. + * @param currentUser The user currently logged in. */ @PostMapping( - value = "/disabled", + value = {"/disabled", "/disable"}, consumes = {"text/*", "application/*"}) @ResponseStatus(HttpStatus.OK) - @ResponseBody public WebMessage disable( - @RequestBody Map body, @CurrentUser(required = true) User currentUser) - throws WebMessageException { + @RequestBody Map body, @CurrentUser(required = true) UserDetails currentUser) + throws ForbiddenException, ConflictException { String code = body.get("code"); - - if (!currentUser.isTwoFactorEnabled()) { - throw new WebMessageException(conflict(ErrorCode.E3031.getMessage(), ErrorCode.E3031)); - } - - if (defaultUserService.twoFaDisableIsLocked(currentUser.getUsername())) { - throw new WebMessageException(conflict(ErrorCode.E3042.getMessage(), ErrorCode.E3042)); - } - - if (!verifyCode(code, currentUser)) { - defaultUserService.registerFailed2FADisableAttempt(currentUser.getUsername()); - return unauthorized(ErrorCode.E3023.getMessage()); - } - - defaultUserService.disableTwoFa(currentUser, code); - - return ok("Two factor authentication was disabled successfully"); - } - - private static boolean verifyCode(String code, User currentUser) { - if (currentUser == null) { - throw new BadCredentialsException(ErrorCode.E3025.getMessage()); - } - - return TwoFactoryAuthenticationUtils.verify(code, currentUser.getSecret()); + twoFactorAuthService.disable2FA(currentUser.getUsername(), code); + return ok("2FA was disabled successfully"); } } diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/MeController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/MeController.java index e8446e9965a7..7723e6c6f638 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/MeController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/MeController.java @@ -315,7 +315,7 @@ public void changePassword( updatePassword(currentUser, newPassword); manager.update(currentUser); - userService.invalidateUserSessions(currentUser.getUid()); + userService.invalidateUserSessions(currentUser.getUsername()); } @OpenApi.Document(group = OpenApi.Document.GROUP_MANAGE) diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/UserController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/UserController.java index 72aad3fcd1ab..0c90bd0acc08 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/UserController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/user/UserController.java @@ -90,6 +90,7 @@ import org.hisp.dhis.schema.MetadataMergeParams; import org.hisp.dhis.schema.descriptors.UserSchemaDescriptor; import org.hisp.dhis.security.RequiresAuthority; +import org.hisp.dhis.security.twofa.TwoFactorAuthService; import org.hisp.dhis.setting.UserSettings; import org.hisp.dhis.system.util.ValidationUtils; import org.hisp.dhis.user.CredentialsInfo; @@ -145,6 +146,8 @@ public class UserController @Autowired private PasswordValidationService passwordValidationService; + @Autowired private TwoFactorAuthService twoFactorAuthService; + // ------------------------------------------------------------------------- // GET // ------------------------------------------------------------------------- @@ -546,20 +549,18 @@ public void unexpireUser(@PathVariable("uid") String uid) throws Exception { } /** - * "Disable two-factor authentication for the user with the given uid." - * - *

+ * Disable 2FA for the user with the given uid. * - * @param uid The uid of the user to disable two-factor authentication for. - * @param currentUser This is the user that is currently logged in. + * @param uid The uid of the user to disable 2FA for. + * @param currentUser This is the user currently logged in. * @return A WebMessage object. */ @PostMapping("/{uid}/twoFA/disabled") @ResponseBody public WebMessage disableTwoFa(@PathVariable("uid") String uid, @CurrentUser User currentUser) - throws ForbiddenException { + throws ForbiddenException, NotFoundException { List errors = new ArrayList<>(); - userService.privilegedTwoFactorDisable(currentUser, uid, errors::add); + twoFactorAuthService.privileged2FADisable(currentUser, uid, errors::add); if (errors.isEmpty()) { return WebMessageUtils.ok(); @@ -633,7 +634,7 @@ protected ImportReport updateUser(String userUid, User inputUser) // We chose to expire the special case if password is set to the // same. i.e. no before & after equals pw check if (isPasswordChangeAttempt) { - userService.invalidateUserSessions(inputUser.getUid()); + userService.invalidateUserSessions(inputUser.getUsername()); } } @@ -665,7 +666,7 @@ protected void postPatchEntity(JsonPatch patch, User entityAfter) { // Make sure we always expire all the user's active sessions if we // have disabled the user. if (entityAfter != null && entityAfter.isDisabled()) { - userService.invalidateUserSessions(entityAfter.getUid()); + userService.invalidateUserSessions(entityAfter.getUsername()); } updateUserGroups(patch, entityAfter); @@ -835,7 +836,7 @@ private void setDisabled(String uid, boolean disable) } if (disable) { - userService.invalidateUserSessions(userToModify.getUid()); + userService.invalidateUserSessions(userToModify.getUsername()); } } @@ -865,7 +866,7 @@ private void setExpires(String uid, Date accountExpiry) userService.updateUser(userToModify); if (!userToModify.isAccountNonExpired()) { - userService.invalidateUserSessions(userToModify.getUid()); + userService.invalidateUserSessions(userToModify.getUsername()); } } diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/DhisWebApiWebSecurityConfig.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/DhisWebApiWebSecurityConfig.java index a7eec83ee4f3..dded893f0214 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/DhisWebApiWebSecurityConfig.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/config/DhisWebApiWebSecurityConfig.java @@ -74,8 +74,6 @@ import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.session.SessionRegistry; -import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; @@ -147,11 +145,6 @@ public static void setApiContextPath(String apiContextPath) { @Autowired private RequestCache requestCache; - @Bean - public SessionRegistry sessionRegistry() { - return new SessionRegistryImpl(); - } - private static class CustomRequestMatcher implements RequestMatcher { private final List excludePatterns = @@ -250,7 +243,6 @@ protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.requestCache().requestCache(requestCache); configureMatchers(http); - configureFormLogin(http); configureCspFilter(http, dhisConfig, configurationService); configureApiTokenAuthorizationFilter(http); configureOAuthTokenFilters(http); @@ -260,18 +252,6 @@ protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } - private void configureFormLogin(HttpSecurity http) throws Exception { - http.formLogin() - .authenticationDetailsSource(twoFactorWebAuthenticationDetailsSource) - .loginPage("/dhis-web-login/") - .usernameParameter("j_username") - .passwordParameter("j_password") - .loginProcessingUrl("/api/authentication/login") - .failureUrl("/dhis-web-login/?error=true") - .defaultSuccessUrl("/dhis-web-dashboard/", true) - .permitAll(); - } - public static void setHttpHeaders(HttpSecurity http) throws Exception { http.headers() .defaultsDisabled() diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/DefaultSessionConfig.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/NonRedisSessionConfig.java similarity index 80% rename from dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/DefaultSessionConfig.java rename to dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/NonRedisSessionConfig.java index 8e477e6e1d86..da0883deefaa 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/DefaultSessionConfig.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/NonRedisSessionConfig.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2004-2022, University of Oslo + * Copyright (c) 2004-2024, University of Oslo * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -25,13 +25,17 @@ * (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.webapi.filter; +package org.hisp.dhis.webapi.security.session; import jakarta.servlet.Filter; import org.hisp.dhis.condition.RedisDisabledCondition; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.web.filter.CharacterEncodingFilter; /** @@ -45,13 +49,20 @@ * @author Ameen Mohamed */ @Configuration +@Order(1998) @Conditional(RedisDisabledCondition.class) -public class DefaultSessionConfig { - /** - * Defines a {@link CharacterEncodingFilter} named springSessionRepositoryFilter - * - * @return a {@link CharacterEncodingFilter} without specifying encoding. - */ +public class NonRedisSessionConfig { + + @Bean + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + @Bean public Filter springSessionRepositoryFilter() { return new CharacterEncodingFilter(); diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/RedisSpringSessionConfig.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/RedisSpringSessionConfig.java index 8f5175a6edb6..8d32db09ae53 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/RedisSpringSessionConfig.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/RedisSpringSessionConfig.java @@ -28,13 +28,22 @@ package org.hisp.dhis.webapi.security.session; import org.hisp.dhis.condition.RedisEnabledCondition; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.security.web.session.HttpSessionEventPublisher; +import org.springframework.session.data.redis.RedisIndexedSessionRepository; import org.springframework.session.data.redis.config.ConfigureRedisAction; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; +import org.springframework.session.security.SpringSessionBackedSessionRegistry; +import org.springframework.session.web.http.CookieHttpSessionIdResolver; +import org.springframework.session.web.http.DefaultCookieSerializer; /** * Configuration registered if {@link RedisEnabledCondition} matches to true. Redis backed Spring @@ -48,6 +57,39 @@ @EnableRedisHttpSession public class RedisSpringSessionConfig { + @Bean + public RedisIndexedSessionRepository sessionRepository( + @Autowired LettuceConnectionFactory lettuceConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(lettuceConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new JdkSerializationRedisSerializer()); + redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer()); + redisTemplate.afterPropertiesSet(); + RedisIndexedSessionRepository repository = new RedisIndexedSessionRepository(redisTemplate); + repository.setDefaultSerializer(new JdkSerializationRedisSerializer()); + return repository; + } + + @Bean + public CookieHttpSessionIdResolver httpSessionIdResolver() { + CookieHttpSessionIdResolver resolver = new CookieHttpSessionIdResolver(); + DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer(); + cookieSerializer.setCookieName("JSESSIONID"); + cookieSerializer.setSameSite("Lax"); + cookieSerializer.setUseSecureCookie(false); + cookieSerializer.setUseHttpOnlyCookie(true); + resolver.setCookieSerializer(cookieSerializer); + return resolver; + } + + @Bean + public SpringSessionBackedSessionRegistry sessionRegistry( + RedisIndexedSessionRepository sessionRepository) { + return new SpringSessionBackedSessionRegistry<>(sessionRepository); + } + @Bean public static ConfigureRedisAction configureRedisAction() { return ConfigureRedisAction.NO_OP; diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java index 4d1c875a24a7..f07a6a3b8cc3 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java @@ -95,6 +95,12 @@ private DhisConfigurationProvider getConfig() { public static void setupServlets( ServletContext context, AnnotationConfigWebApplicationContext webApplicationContext) { + context + .addFilter( + "SpringSessionRepositoryFilter", + new DelegatingFilterProxy("springSessionRepositoryFilter")) + .addMappingForUrlPatterns(null, false, "/*"); + DispatcherServlet servlet = new DispatcherServlet(webApplicationContext); ServletRegistration.Dynamic dispatcher = context.addServlet("dispatcher", servlet); dispatcher.setAsyncSupported(true); diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/user/UserControllerTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/user/UserControllerTest.java index 67a7608ebe1e..274d314b7caa 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/user/UserControllerTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/controller/user/UserControllerTest.java @@ -230,7 +230,7 @@ void expireUserNowDoesExpireSession() throws Exception { userController.expireUser(user.getUid(), now); assertUserUpdatedWithAccountExpiry(now); - verify(userService, atLeastOnce()).invalidateUserSessions(same(user.getUid())); + verify(userService, atLeastOnce()).invalidateUserSessions(same(user.getUsername())); } @Test diff --git a/dhis-2/dhis-web-server/pom.xml b/dhis-2/dhis-web-server/pom.xml index 559bcd2f4b63..5577fc51763e 100644 --- a/dhis-2/dhis-web-server/pom.xml +++ b/dhis-2/dhis-web-server/pom.xml @@ -75,21 +75,6 @@ ${spring-boot-maven-plugin.version} provided - - org.testcontainers - testcontainers - test - - - org.hisp.dhis - dhis-support-test - test - - - org.testcontainers - postgresql - test - org.hisp.dhis dhis-service-administration @@ -107,24 +92,11 @@ jakarta.servlet jakarta.servlet-api - - org.hisp.dhis - dhis-support-system - org.junit.jupiter junit-jupiter test - - com.fasterxml.jackson.core - jackson-databind - - - org.springframework - spring-test - test - diff --git a/dhis-2/dhis-web-server/src/test/java/org/hisp/dhis/auth/AuthTest.java b/dhis-2/dhis-web-server/src/test/java/org/hisp/dhis/auth/AuthTest.java deleted file mode 100644 index c7a1040eea76..000000000000 --- a/dhis-2/dhis-web-server/src/test/java/org/hisp/dhis/auth/AuthTest.java +++ /dev/null @@ -1,390 +0,0 @@ -/* - * 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.auth; - -import static org.hisp.dhis.common.network.PortUtil.findAvailablePort; -import static org.hisp.dhis.system.StartupEventPublisher.SERVER_STARTED_LATCH; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import com.fasterxml.jackson.databind.JsonNode; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.Base64; -import java.util.List; -import java.util.Map; -import lombok.extern.slf4j.Slf4j; -import org.hisp.dhis.system.util.HttpHeadersBuilder; -import org.hisp.dhis.test.IntegrationTest; -import org.hisp.dhis.webapi.controller.security.LoginRequest; -import org.hisp.dhis.webapi.controller.security.LoginResponse; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.http.converter.StringHttpMessageConverter; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.RestTemplate; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.utility.DockerImageName; - -/** - * @author Morten Svanæs - */ -@Disabled( - "fails to build in CI with Connections could not be acquired from the underlying database!") -@Slf4j -@IntegrationTest -@ActiveProfiles(profiles = {"test-postgres"}) -class AuthTest { - private static final String POSTGRES_POSTGIS_VERSION = "13-3.4-alpine"; - private static final DockerImageName POSTGIS_IMAGE_NAME = - DockerImageName.parse("postgis/postgis").asCompatibleSubstituteFor("postgres"); - private static final String POSTGRES_DATABASE_NAME = "dhis"; - private static final String POSTGRES_USERNAME = "dhis"; - private static final String POSTGRES_PASSWORD = "dhis"; - private static PostgreSQLContainer POSTGRES_CONTAINER; - private static int availablePort; - - @BeforeAll - static void setup() throws Exception { - availablePort = findAvailablePort(); - - POSTGRES_CONTAINER = - new PostgreSQLContainer<>(POSTGIS_IMAGE_NAME.withTag(POSTGRES_POSTGIS_VERSION)) - .withDatabaseName(POSTGRES_DATABASE_NAME) - .withUsername(POSTGRES_USERNAME) - .withPassword(POSTGRES_PASSWORD) - .withInitScript("db/extensions.sql") - .withTmpFs(Map.of("/testtmpfs", "rw")) - .withEnv("LC_COLLATE", "C"); - - POSTGRES_CONTAINER.start(); - - createTmpDhisConf(); - - System.setProperty("dhis2.home", System.getProperty("java.io.tmpdir")); - - Thread printingHook = - new Thread( - () -> { - log.info("In the middle of a shutdown"); - }); - Runtime.getRuntime().addShutdownHook(printingHook); - - Thread longRunningHook = - new Thread( - () -> { - try { - System.setProperty("server.port", Integer.toString(availablePort)); - org.hisp.dhis.web.tomcat.Main.main(null); - } catch (InterruptedException ignored) { - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - longRunningHook.start(); - - SERVER_STARTED_LATCH.await(); - - log.info("Server started"); - } - - private static void createTmpDhisConf() { - String jdbcUrl = POSTGRES_CONTAINER.getJdbcUrl(); - log.info("JDBC URL: " + jdbcUrl); - String multiLineString = - """ - connection.dialect = org.hibernate.dialect.PostgreSQLDialect - connection.driver_class = org.postgresql.Driver - connection.url = %s - connection.username = dhis - connection.password = dhis - # Database schema behavior, can be validate, update, create, create-drop - connection.schema = update - system.audit.enabled = false - """ - .formatted(jdbcUrl); - try { - String tmpDir = System.getProperty("java.io.tmpdir"); - Path tmpFilePath = Path.of(tmpDir, "dhis.conf"); - Files.writeString(tmpFilePath, multiLineString, StandardOpenOption.CREATE); - log.info("File written successfully to " + tmpFilePath); - } catch (Exception e) { - log.error("Error creating file", e); - } - } - - @Test - void testLogin() { - String port = Integer.toString(availablePort); - - RestTemplate restTemplate = new RestTemplate(); - - HttpHeadersBuilder headersBuilder = new HttpHeadersBuilder().withContentTypeJson(); - - LoginRequest loginRequest = - LoginRequest.builder().username("admin").password("district").build(); - HttpEntity requestEntity = new HttpEntity<>(loginRequest, headersBuilder.build()); - - ResponseEntity loginResponse = - restTemplate.postForEntity( - "http://localhost:" + port + "/api/auth/login", requestEntity, LoginResponse.class); - - assertNotNull(loginResponse); - assertEquals(HttpStatus.OK, loginResponse.getStatusCode()); - LoginResponse body = loginResponse.getBody(); - assertNotNull(body); - assertEquals(LoginResponse.STATUS.SUCCESS, body.getLoginStatus()); - HttpHeaders headers = loginResponse.getHeaders(); - - assertEquals("/dhis-web-dashboard/", body.getRedirectUrl()); - - assertNotNull(headers); - List cookieHeader = headers.get(HttpHeaders.SET_COOKIE); - assertNotNull(cookieHeader); - assertEquals(1, cookieHeader.size()); - String cookie = cookieHeader.get(0); - - HttpHeaders getHeaders = new HttpHeaders(); - getHeaders.set("Cookie", cookie); - HttpEntity getEntity = new HttpEntity<>("", getHeaders); - - ResponseEntity getResponse = - restTemplate.exchange( - "http://localhost:" + port + "/api/me", HttpMethod.GET, getEntity, JsonNode.class); - - assertEquals(HttpStatus.OK, getResponse.getStatusCode()); - - assertNotNull(getResponse); - assertNotNull(getResponse.getBody()); - } - - @Test - void testLoginFailure() { - String port = Integer.toString(availablePort); - - RestTemplate restTemplate = new RestTemplate(); - - HttpHeadersBuilder headersBuilder = new HttpHeadersBuilder().withContentTypeJson(); - - LoginRequest loginRequest = - LoginRequest.builder().username("admin").password("wrongpassword").build(); - HttpEntity requestEntity = new HttpEntity<>(loginRequest, headersBuilder.build()); - - try { - restTemplate.postForEntity( - "http://localhost:" + port + "/api/auth/login", requestEntity, LoginResponse.class); - } catch (HttpClientErrorException e) { - assertEquals(HttpStatus.UNAUTHORIZED, e.getStatusCode()); - } - } - - @Test - void testRedirectWithQueryParam() { - testRedirectUrl("/api/users?fields=id,name,displayName"); - } - - @Test - void testRedirectWithoutQueryParam() { - testRedirectUrl("/api/users"); - } - - @Test - void testRedirectToResource() { - testRedirectUrl("/api/users/resource.js", "/dhis-web-dashboard/"); - } - - @Test - void testRedirectToHtmlResource() { - testRedirectUrl("/api/users/resource.html", "/api/users/resource.html"); - } - - @Test - void testRedirectToSlashEnding() { - testRedirectUrl("/api/users/", "/api/users/"); - } - - @Test - void testRedirectToResourceWorker() { - testRedirectUrl("/dhis-web-dashboard/service-worker.js", "/dhis-web-dashboard/"); - } - - @Test - void testRedirectToCssResourceWorker() { - testRedirectUrl("/dhis-web-dashboard/static/css/main.4536e618.css", "/dhis-web-dashboard/"); - } - - @Test - void testRedirectAccountWhenVerifiedEmailEnforced() { - changeSystemSetting("enforceVerifiedEmail", "true"); - testRedirectUrl("/dhis-web-dashboard/", "/dhis-web-user-profile/#/account"); - changeSystemSetting("enforceVerifiedEmail", "false"); - } - - @Test - void testRedirectMissingEndingSlash() { - testRedirectWhenLoggedIn("/dhis-web-dashboard/", "/dhis-web-dashboard/"); - } - - private static void testRedirectUrl(String url) { - testRedirectUrl(url, url); - } - - private static RestTemplate createRestTemplateWithBasicAuthHeader() { - RestTemplate restTemplate = new RestTemplate(); - - // Create the authentication header - String authHeader = - Base64.getUrlEncoder().encodeToString("admin:district".getBytes(StandardCharsets.UTF_8)); - - // Add header to every request - restTemplate - .getInterceptors() - .add( - (request, body, execution) -> { - request.getHeaders().add(HttpHeaders.AUTHORIZATION, "Basic " + authHeader); - return execution.execute(request, body); - }); - - return restTemplate; - } - - private static void changeSystemSetting(String key, String value) { - String port = Integer.toString(availablePort); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_PLAIN); - - RestTemplate restTemplate = createRestTemplateWithBasicAuthHeader(); - HttpEntity requestEntity = new HttpEntity<>(value, headers); - - ResponseEntity response = - restTemplate.exchange( - "http://localhost:" + port + "/api/systemSettings/" + key, - HttpMethod.POST, - requestEntity, - String.class); - - assertEquals(HttpStatus.OK, response.getStatusCode()); - } - - private static void testRedirectUrl(String url, String redirectUrl) { - String port = Integer.toString(availablePort); - - RestTemplate restTemplate = new RestTemplate(); - - ResponseEntity firstResponse = - restTemplate.postForEntity("http://localhost:" + port + url, null, LoginResponse.class); - HttpHeaders headersFirstResponse = firstResponse.getHeaders(); - String firstCookie = headersFirstResponse.get(HttpHeaders.SET_COOKIE).get(0); - - HttpHeaders getHeaders = new HttpHeaders(); - getHeaders.set("Cookie", firstCookie); - LoginRequest loginRequest = - LoginRequest.builder().username("admin").password("district").build(); - HttpEntity requestEntity = new HttpEntity<>(loginRequest, getHeaders); - - ResponseEntity loginResponse = - restTemplate.postForEntity( - "http://localhost:" + port + "/api/auth/login", requestEntity, LoginResponse.class); - - assertNotNull(loginResponse); - assertEquals(HttpStatus.OK, loginResponse.getStatusCode()); - LoginResponse body = loginResponse.getBody(); - assertNotNull(body); - assertEquals(LoginResponse.STATUS.SUCCESS, body.getLoginStatus()); - - assertEquals(redirectUrl, body.getRedirectUrl()); - } - - private static void testRedirectWhenLoggedIn(String url, String redirectUrl) { - String port = Integer.toString(availablePort); - - // Create a custom ClientHttpRequestFactory that disables redirects - ClientHttpRequestFactory requestFactory = - new SimpleClientHttpRequestFactory() { - @Override - protected void prepareConnection(HttpURLConnection connection, String httpMethod) - throws IOException { - super.prepareConnection(connection, httpMethod); - connection.setInstanceFollowRedirects(false); // Disable redirects - } - }; - - RestTemplate restTemplate = new RestTemplate(requestFactory); - restTemplate - .getMessageConverters() - .add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); - - ResponseEntity firstResponse = - restTemplate.postForEntity("http://localhost:" + port + url, null, LoginResponse.class); - HttpHeaders headersFirstResponse = firstResponse.getHeaders(); - String firstCookie = headersFirstResponse.get(HttpHeaders.SET_COOKIE).get(0); - - HttpHeaders getHeaders = new HttpHeaders(); - getHeaders.set("Cookie", firstCookie); - LoginRequest loginRequest = - LoginRequest.builder().username("admin").password("district").build(); - HttpEntity requestEntity = new HttpEntity<>(loginRequest, getHeaders); - - ResponseEntity loginResponse = - restTemplate.postForEntity( - "http://localhost:" + port + "/api/auth/login", requestEntity, LoginResponse.class); - HttpHeaders loginHeaders = loginResponse.getHeaders(); - String loggedInCookie = loginHeaders.get(HttpHeaders.SET_COOKIE).get(0); - - HttpHeaders headers = new HttpHeaders(); - headers.set("Cookie", loggedInCookie); - HttpEntity entity = new HttpEntity<>(headers); - ResponseEntity redirResp = - restTemplate.exchange( - "http://localhost:" + port + "/dhis-web-dashboard", - HttpMethod.GET, - entity, - String.class); - - HttpHeaders respHeaders = redirResp.getHeaders(); - List location = respHeaders.get("Location"); - assertNotNull(location); - assertEquals(1, location.size()); - assertEquals(redirectUrl, location.get(0)); - } -} diff --git a/dhis-2/dhis-web-server/src/test/resources/db/extensions.sql b/dhis-2/dhis-web-server/src/test/resources/db/extensions.sql deleted file mode 100644 index 0e25eb7053f8..000000000000 --- a/dhis-2/dhis-web-server/src/test/resources/db/extensions.sql +++ /dev/null @@ -1,2 +0,0 @@ -create extension pg_trgm; -create extension btree_gin; \ No newline at end of file diff --git a/dhis-2/pom.xml b/dhis-2/pom.xml index 6efce4bc5836..647756463195 100644 --- a/dhis-2/pom.xml +++ b/dhis-2/pom.xml @@ -2068,6 +2068,7 @@ jasperreports.version=${jasperreports.version} true + org.springframework.data:spring-data-redis:jar org.geotools:gt-main org.geotools:gt-epsg-hsql org.geotools:gt-cql @@ -2086,7 +2087,9 @@ jasperreports.version=${jasperreports.version} org.hibernate:hibernate-micrometer io.micrometer:micrometer-spring-legacy io.micrometer:micrometer-registry-prometheus-simpleclient + io.micrometer:micrometer-registry-prometheus-simpleclient + org.springframework.data:spring-data-redis:jar org.apache.velocity.tools:velocity-tools-generic org.springframework:spring-context-support org.springframework:spring-web @@ -2120,6 +2123,7 @@ jasperreports.version=${jasperreports.version} org.apache.activemq:artemis-jakarta-server + org.springframework.data:spring-data-redis:jar org.flywaydb:flyway-core org.antlr:antlr4-runtime org.junit.jupiter:junit-jupiter-api @@ -2135,11 +2139,13 @@ jasperreports.version=${jasperreports.version} + org.springframework.data:spring-data-redis:jar org.springframework:spring-beans io.debezium:debezium-connector-postgres org.springframework.security:spring-security-core com.fasterxml.jackson.core:jackson-databind + org.springframework.data:spring-data-redis:jar org.springframework:spring-web org.hisp.dhis:dhis-support-system