Skip to content

Commit

Permalink
Feature: Forgot password (#9509)
Browse files Browse the repository at this point in the history
* Feature: Forgot password

* Address comments

* fixups

* Make forgot password disabled by default

* Apply suggestions from code review

* Address comments
  • Loading branch information
vishesh92 committed Sep 10, 2024
1 parent 638c152 commit 0655075
Show file tree
Hide file tree
Showing 29 changed files with 1,726 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@

import javax.servlet.http.HttpSession;

import com.cloud.domain.Domain;
import com.cloud.exception.CloudAuthenticationException;
import com.cloud.user.UserAccount;

public interface ApiServerService {
public boolean verifyRequest(Map<String, Object[]> requestParameters, Long userId, InetAddress remoteAddress) throws ServerApiException;
Expand All @@ -42,4 +44,8 @@ public ResponseObject loginUser(HttpSession session, String username, String pas
public String handleRequest(Map<String, Object[]> params, String responseType, StringBuilder auditTrailSb) throws ServerApiException;

public Class<?> getCmdClass(String cmdName);

boolean forgotPassword(UserAccount userAccount, Domain domain);

boolean resetPassword(UserAccount userAccount, String token, String password);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
package org.apache.cloudstack.api.auth;

public enum APIAuthenticationType {
LOGIN_API, LOGOUT_API, READONLY_API, LOGIN_2FA_API
LOGIN_API, LOGOUT_API, READONLY_API, LOGIN_2FA_API, PASSWORD_RESET
}
4 changes: 4 additions & 0 deletions engine/schema/src/main/java/com/cloud/user/UserAccountVO.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.cloud.user;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import javax.persistence.Column;
Expand Down Expand Up @@ -361,6 +362,9 @@ public void setUser2faProvider(String user2faProvider) {

@Override
public Map<String, String> getDetails() {
if (details == null) {
details = new HashMap<>();
}
return details;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public class UserDetailVO implements ResourceDetail {
private boolean display = true;

public static final String Setup2FADetail = "2FASetupStatus";
public static final String PasswordResetToken = "PasswordResetToken";
public static final String PasswordResetTokenExpiryDate = "PasswordResetTokenExpiryDate";

public UserDetailVO() {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,11 @@ public ConfigKey<?>[] getConfigKeys() {
return null;
}

public void validateUserPasswordAndUpdateIfNeeded(String newPassword, UserVO user,
String currentPassword,
boolean skipCurrentPassValidation) {
}

@Override
public void checkApiAccess(Account account, String command) throws PermissionDeniedException {

Expand Down
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@
<cs.kafka-clients.version>2.7.0</cs.kafka-clients.version>
<cs.libvirt-java.version>0.5.3</cs.libvirt-java.version>
<cs.mail.version>1.5.0-b01</cs.mail.version>
<cs.mustache.version>0.9.14</cs.mustache.version>
<cs.mysql.version>8.0.33</cs.mysql.version>
<cs.neethi.version>2.0.4</cs.neethi.version>
<cs.nitro.version>10.1</cs.nitro.version>
Expand Down
5 changes: 5 additions & 0 deletions server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@
<artifactId>commons-math3</artifactId>
<version>${cs.commons-math3.version}</version>
</dependency>
<dependency>
<groupId>com.github.spullara.mustache.java</groupId>
<artifactId>compiler</artifactId>
<version>${cs.mustache.version}</version>
</dependency>
<dependency>
<groupId>org.apache.cloudstack</groupId>
<artifactId>cloud-utils</artifactId>
Expand Down
71 changes: 64 additions & 7 deletions server/src/main/java/com/cloud/api/ApiServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import com.cloud.user.Account;
import com.cloud.user.AccountManager;
import com.cloud.user.AccountManagerImpl;
import com.cloud.user.DomainManager;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.user.UserVO;
import org.apache.cloudstack.acl.APIChecker;
import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiConstants;
Expand Down Expand Up @@ -103,7 +110,9 @@
import org.apache.cloudstack.framework.messagebus.MessageDispatcher;
import org.apache.cloudstack.framework.messagebus.MessageHandler;
import org.apache.cloudstack.managed.context.ManagedContextRunnable;
import org.apache.cloudstack.user.UserPasswordResetManager;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.EnumUtils;
import org.apache.http.ConnectionClosedException;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
Expand Down Expand Up @@ -157,13 +166,6 @@
import com.cloud.exception.UnavailableCommandException;
import com.cloud.projects.dao.ProjectDao;
import com.cloud.storage.VolumeApiService;
import com.cloud.user.Account;
import com.cloud.user.AccountManager;
import com.cloud.user.AccountManagerImpl;
import com.cloud.user.DomainManager;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.user.UserVO;
import com.cloud.utils.ConstantTimeComparator;
import com.cloud.utils.DateUtil;
import com.cloud.utils.HttpUtils;
Expand All @@ -182,6 +184,8 @@
import com.cloud.utils.net.NetUtils;
import com.google.gson.reflect.TypeToken;

import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled;

@Component
public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiServerService, Configurable {

Expand Down Expand Up @@ -214,6 +218,8 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
private ProjectDao projectDao;
@Inject
private UUIDManager uuidMgr;
@Inject
private UserPasswordResetManager userPasswordResetManager;

private List<PluggableService> pluggableServices;

Expand Down Expand Up @@ -1223,6 +1229,57 @@ public boolean verifyUser(final Long userId) {
return true;
}

@Override
public boolean forgotPassword(UserAccount userAccount, Domain domain) {
if (!UserPasswordResetEnabled.value()) {
String errorMessage = String.format("%s is false. Password reset for the user is not allowed.",
UserPasswordResetEnabled.key());
logger.error(errorMessage);
throw new CloudRuntimeException(errorMessage);
}
if (StringUtils.isBlank(userAccount.getEmail())) {
logger.error(String.format(
"Email is not set. username: %s account id: %d domain id: %d",
userAccount.getUsername(), userAccount.getAccountId(), userAccount.getDomainId()));
throw new CloudRuntimeException("Email is not set for the user.");
}

if (!EnumUtils.getEnumIgnoreCase(Account.State.class, userAccount.getState()).equals(Account.State.ENABLED)) {
logger.error(String.format(
"User is not enabled. username: %s account id: %d domain id: %s",
userAccount.getUsername(), userAccount.getAccountId(), domain.getUuid()));
throw new CloudRuntimeException("User is not enabled.");
}

if (!EnumUtils.getEnumIgnoreCase(Account.State.class, userAccount.getAccountState()).equals(Account.State.ENABLED)) {
logger.error(String.format(
"Account is not enabled. username: %s account id: %d domain id: %s",
userAccount.getUsername(), userAccount.getAccountId(), domain.getUuid()));
throw new CloudRuntimeException("Account is not enabled.");
}

if (!domain.getState().equals(Domain.State.Active)) {
logger.error(String.format(
"Domain is not active. username: %s account id: %d domain id: %s",
userAccount.getUsername(), userAccount.getAccountId(), domain.getUuid()));
throw new CloudRuntimeException("Domain is not active.");
}

userPasswordResetManager.setResetTokenAndSend(userAccount);
return true;
}

@Override
public boolean resetPassword(UserAccount userAccount, String token, String password) {
if (!UserPasswordResetEnabled.value()) {
String errorMessage = String.format("%s is false. Password reset for the user is not allowed.",
UserPasswordResetEnabled.key());
logger.error(errorMessage);
throw new CloudRuntimeException(errorMessage);
}
return userPasswordResetManager.validateAndResetPassword(userAccount, token, password);
}

private void checkCommandAvailable(final User user, final String commandName, final InetAddress remoteAddress) throws PermissionDeniedException {
if (user == null) {
throw new PermissionDeniedException("User is null for role based API access check for command" + commandName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import com.cloud.utils.component.ComponentContext;
import com.cloud.utils.component.ManagerBase;

import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled;

@SuppressWarnings("unchecked")
public class APIAuthenticationManagerImpl extends ManagerBase implements APIAuthenticationManager {

Expand Down Expand Up @@ -75,6 +77,10 @@ public List<Class<?>> getCommands() {
List<Class<?>> cmdList = new ArrayList<Class<?>>();
cmdList.add(DefaultLoginAPIAuthenticatorCmd.class);
cmdList.add(DefaultLogoutAPIAuthenticatorCmd.class);
if (UserPasswordResetEnabled.value()) {
cmdList.add(DefaultForgotPasswordAPIAuthenticatorCmd.class);
cmdList.add(DefaultResetPasswordAPIAuthenticatorCmd.class);
}

cmdList.add(ListUserTwoFactorAuthenticatorProvidersCmd.class);
cmdList.add(ValidateUserTwoFactorAuthenticationCodeCmd.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package com.cloud.api.auth;

import com.cloud.api.ApiServlet;
import com.cloud.api.response.ApiResponseSerializer;
import com.cloud.domain.Domain;
import com.cloud.user.Account;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.utils.exception.CloudRuntimeException;
import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ApiErrorCode;
import org.apache.cloudstack.api.ApiServerService;
import org.apache.cloudstack.api.BaseCmd;
import org.apache.cloudstack.api.Parameter;
import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.auth.APIAuthenticationType;
import org.apache.cloudstack.api.auth.APIAuthenticator;
import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator;
import org.apache.cloudstack.api.response.SuccessResponse;
import org.jetbrains.annotations.Nullable;

import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;

@APICommand(name = "forgotPassword",
description = "Sends an email to the user with a token to reset the password using resetPassword command.",
since = "4.20.0.0",
requestHasSensitiveInfo = true,
responseObject = SuccessResponse.class)
public class DefaultForgotPasswordAPIAuthenticatorCmd extends BaseCmd implements APIAuthenticator {


/////////////////////////////////////////////////////
//////////////// API parameters /////////////////////
/////////////////////////////////////////////////////
@Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, description = "Username", required = true)
private String username;

@Parameter(name = ApiConstants.DOMAIN, type = CommandType.STRING, description = "Path of the domain that the user belongs to. Example: domain=/com/cloud/internal. If no domain is passed in, the ROOT (/) domain is assumed.")
private String domain;

@Inject
ApiServerService _apiServer;

/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////

public String getUsername() {
return username;
}

public String getDomainName() {
return domain;
}


/////////////////////////////////////////////////////
/////////////// API Implementation///////////////////
/////////////////////////////////////////////////////

@Override
public long getEntityOwnerId() {
return Account.Type.NORMAL.ordinal();
}

@Override
public void execute() throws ServerApiException {
// We should never reach here
throw new ServerApiException(ApiErrorCode.METHOD_NOT_ALLOWED, "This is an authentication api, cannot be used directly");
}

@Override
public String authenticate(String command, Map<String, Object[]> params, HttpSession session, InetAddress remoteAddress, String responseType, StringBuilder auditTrailSb, final HttpServletRequest req, final HttpServletResponse resp) throws ServerApiException {
final String[] username = (String[])params.get(ApiConstants.USERNAME);
final String[] domainName = (String[])params.get(ApiConstants.DOMAIN);

Long domainId = null;
String domain = null;
domain = getDomainName(auditTrailSb, domainName, domain);

String serializedResponse = null;
if (username != null) {
try {
final Domain userDomain = _domainService.findDomainByPath(domain);
if (userDomain != null) {
domainId = userDomain.getId();
} else {
throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find the domain from the path %s", domain));
}
final UserAccount userAccount = _accountService.getActiveUserAccount(username[0], domainId);
if (userAccount != null && List.of(User.Source.SAML2, User.Source.OAUTH2, User.Source.LDAP).contains(userAccount.getSource())) {
throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Forgot Password is not allowed for this user");
}
boolean success = _apiServer.forgotPassword(userAccount, userDomain);
logger.debug("Forgot password request for user " + username[0] + " in domain " + domain + " is successful: " + success);
} catch (final CloudRuntimeException ex) {
ApiServlet.invalidateHttpSession(session, "fall through to API key,");
String msg = String.format("%s", ex.getMessage() != null ?
ex.getMessage() :
"forgot password request failed for user, check if username/domain are correct");
auditTrailSb.append(" " + ApiErrorCode.ACCOUNT_ERROR + " " + msg);
serializedResponse = _apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), msg, params, responseType);
if (logger.isTraceEnabled()) {
logger.trace(msg);
}
}
SuccessResponse successResponse = new SuccessResponse();
successResponse.setSuccess(true);
successResponse.setResponseName(getCommandName());
return ApiResponseSerializer.toSerializedString(successResponse, responseType);
}
// We should not reach here and if we do we throw an exception
throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, serializedResponse);
}

@Nullable
private String getDomainName(StringBuilder auditTrailSb, String[] domainName, String domain) {
if (domainName != null) {
domain = domainName[0];
auditTrailSb.append(" domain=" + domain);
if (domain != null) {
// ensure domain starts with '/' and ends with '/'
if (!domain.endsWith("/")) {
domain += '/';
}
if (!domain.startsWith("/")) {
domain = "/" + domain;
}
}
}
return domain;
}

@Override
public APIAuthenticationType getAPIType() {
return APIAuthenticationType.PASSWORD_RESET;
}

@Override
public void setAuthenticators(List<PluggableAPIAuthenticator> authenticators) {
}
}
Loading

0 comments on commit 0655075

Please sign in to comment.