This is an automated email from the ASF dual-hosted git repository.

joao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new 0655075f51c Feature: Forgot password (#9509)
0655075f51c is described below

commit 0655075f51c57cf031f9f9195f94cf2d2f3d8abc
Author: Vishesh <[email protected]>
AuthorDate: Tue Sep 10 21:25:28 2024 +0530

    Feature: Forgot password (#9509)
    
    * Feature: Forgot password
    
    * Address comments
    
    * fixups
    
    * Make forgot password disabled by default
    
    * Apply suggestions from code review
    
    * Address comments
---
 .../apache/cloudstack/api/ApiServerService.java    |   6 +
 .../cloudstack/api/auth/APIAuthenticationType.java |   2 +-
 .../main/java/com/cloud/user/UserAccountVO.java    |   4 +
 .../cloudstack/resourcedetail/UserDetailVO.java    |   2 +
 .../contrail/management/MockAccountManager.java    |   5 +
 pom.xml                                            |   1 +
 server/pom.xml                                     |   5 +
 server/src/main/java/com/cloud/api/ApiServer.java  |  71 ++++-
 .../api/auth/APIAuthenticationManagerImpl.java     |   6 +
 .../DefaultForgotPasswordAPIAuthenticatorCmd.java  | 165 +++++++++++
 .../DefaultResetPasswordAPIAuthenticatorCmd.java   | 193 +++++++++++++
 .../main/java/com/cloud/user/AccountManager.java   |   4 +-
 .../java/com/cloud/user/AccountManagerImpl.java    |   9 +-
 .../cloudstack/user/UserPasswordResetManager.java  |  71 +++++
 .../user/UserPasswordResetManagerImpl.java         | 312 ++++++++++++++++++++
 .../core/spring-server-core-managers-context.xml   |   1 +
 .../src/test/java/com/cloud/api/ApiServerTest.java |  92 +++++-
 .../com/cloud/user/AccountManagerImplTest.java     |  20 +-
 .../com/cloud/user/MockAccountManagerImpl.java     |   4 +
 .../user/UserPasswordResetManagerImplTest.java     | 150 ++++++++++
 tools/apidoc/gen_toc.py                            |   4 +-
 ui/public/locales/en.json                          |   5 +
 ui/src/config/router.js                            |  10 +
 ui/src/permission.js                               |   2 +-
 ui/src/utils/request.js                            |   2 +-
 ui/src/views/auth/ForgotPassword.vue               | 260 +++++++++++++++++
 ui/src/views/auth/Login.vue                        |  23 +-
 ui/src/views/auth/ResetPassword.vue                | 318 +++++++++++++++++++++
 .../cloudstack/utils/mailing/SMTPMailSender.java   |  20 +-
 29 files changed, 1726 insertions(+), 41 deletions(-)

diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiServerService.java 
b/api/src/main/java/org/apache/cloudstack/api/ApiServerService.java
index 54fda7e36b8..cbbcdc3bda4 100644
--- a/api/src/main/java/org/apache/cloudstack/api/ApiServerService.java
+++ b/api/src/main/java/org/apache/cloudstack/api/ApiServerService.java
@@ -21,7 +21,9 @@ import java.util.Map;
 
 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;
@@ -42,4 +44,8 @@ public interface ApiServerService {
     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);
 }
diff --git 
a/api/src/main/java/org/apache/cloudstack/api/auth/APIAuthenticationType.java 
b/api/src/main/java/org/apache/cloudstack/api/auth/APIAuthenticationType.java
index 5ba9d182daa..1f78708f7e5 100644
--- 
a/api/src/main/java/org/apache/cloudstack/api/auth/APIAuthenticationType.java
+++ 
b/api/src/main/java/org/apache/cloudstack/api/auth/APIAuthenticationType.java
@@ -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
 }
diff --git a/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java 
b/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java
index c18ca53f7ab..1da7d52a366 100644
--- a/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java
+++ b/engine/schema/src/main/java/com/cloud/user/UserAccountVO.java
@@ -17,6 +17,7 @@
 package com.cloud.user;
 
 import java.util.Date;
+import java.util.HashMap;
 import java.util.Map;
 
 import javax.persistence.Column;
@@ -361,6 +362,9 @@ public class UserAccountVO implements UserAccount, 
InternalIdentity {
 
     @Override
     public Map<String, String> getDetails() {
+        if (details == null) {
+            details = new HashMap<>();
+        }
         return details;
     }
 
diff --git 
a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java
 
b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java
index 1b430e806e2..d0cfcc3d439 100644
--- 
a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java
+++ 
b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java
@@ -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() {
     }
diff --git 
a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java
 
b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java
index 5d2efa0dc9a..6bb9752d764 100644
--- 
a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java
+++ 
b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java
@@ -515,6 +515,11 @@ public class MockAccountManager extends ManagerBase 
implements AccountManager {
         return null;
     }
 
+    public void validateUserPasswordAndUpdateIfNeeded(String newPassword, 
UserVO user,
+                                               String currentPassword,
+                                               boolean 
skipCurrentPassValidation) {
+    }
+
     @Override
     public void checkApiAccess(Account account, String command) throws 
PermissionDeniedException {
 
diff --git a/pom.xml b/pom.xml
index ff6cac2ff6a..29fc939f553 100644
--- a/pom.xml
+++ b/pom.xml
@@ -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>
diff --git a/server/pom.xml b/server/pom.xml
index ec157b00e30..6b027b2c7c7 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -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>
diff --git a/server/src/main/java/com/cloud/api/ApiServer.java 
b/server/src/main/java/com/cloud/api/ApiServer.java
index 0d4382097c2..739ad765afa 100644
--- a/server/src/main/java/com/cloud/api/ApiServer.java
+++ b/server/src/main/java/com/cloud/api/ApiServer.java
@@ -55,6 +55,13 @@ import javax.naming.ConfigurationException;
 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;
@@ -103,7 +110,9 @@ import 
org.apache.cloudstack.framework.messagebus.MessageBus;
 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;
@@ -157,13 +166,6 @@ import com.cloud.exception.ResourceUnavailableException;
 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;
@@ -182,6 +184,8 @@ import com.cloud.utils.exception.ExceptionProxyObject;
 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 {
 
@@ -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;
 
@@ -1223,6 +1229,57 @@ public class ApiServer extends ManagerBase implements 
HttpRequestHandler, ApiSer
         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);
diff --git 
a/server/src/main/java/com/cloud/api/auth/APIAuthenticationManagerImpl.java 
b/server/src/main/java/com/cloud/api/auth/APIAuthenticationManagerImpl.java
index 907ef088ee8..3c8282d0280 100644
--- a/server/src/main/java/com/cloud/api/auth/APIAuthenticationManagerImpl.java
+++ b/server/src/main/java/com/cloud/api/auth/APIAuthenticationManagerImpl.java
@@ -31,6 +31,8 @@ import 
org.apache.cloudstack.api.auth.PluggableAPIAuthenticator;
 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 {
 
@@ -75,6 +77,10 @@ public class APIAuthenticationManagerImpl extends 
ManagerBase implements APIAuth
         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);
diff --git 
a/server/src/main/java/com/cloud/api/auth/DefaultForgotPasswordAPIAuthenticatorCmd.java
 
b/server/src/main/java/com/cloud/api/auth/DefaultForgotPasswordAPIAuthenticatorCmd.java
new file mode 100644
index 00000000000..1e90b43c5e8
--- /dev/null
+++ 
b/server/src/main/java/com/cloud/api/auth/DefaultForgotPasswordAPIAuthenticatorCmd.java
@@ -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) {
+    }
+}
diff --git 
a/server/src/main/java/com/cloud/api/auth/DefaultResetPasswordAPIAuthenticatorCmd.java
 
b/server/src/main/java/com/cloud/api/auth/DefaultResetPasswordAPIAuthenticatorCmd.java
new file mode 100644
index 00000000000..077efdee087
--- /dev/null
+++ 
b/server/src/main/java/com/cloud/api/auth/DefaultResetPasswordAPIAuthenticatorCmd.java
@@ -0,0 +1,193 @@
+// 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.exception.CloudAuthenticationException;
+import com.cloud.user.Account;
+import com.cloud.user.User;
+import com.cloud.user.UserAccount;
+import com.cloud.utils.UuidUtils;
+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 = "resetPassword",
+        description = "Resets the password for the user using the token 
generated via forgotPassword command.",
+        since = "4.20.0.0",
+        requestHasSensitiveInfo = true,
+        responseObject = SuccessResponse.class)
+public class DefaultResetPasswordAPIAuthenticatorCmd 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;
+
+    @Parameter(name = ApiConstants.TOKEN,
+            type = CommandType.STRING,
+            required = true,
+            description = "Token generated via forgotPassword command.")
+    private String token;
+
+    @Parameter(name = ApiConstants.PASSWORD,
+            type = CommandType.STRING,
+            required = true,
+            description = "New password in clear text (Default hashed to 
SHA256SALT).")
+    private String password;
+
+    @Inject
+    ApiServerService _apiServer;
+
+    /////////////////////////////////////////////////////
+    /////////////////// Accessors ///////////////////////
+    /////////////////////////////////////////////////////
+
+    public String getUsername() {
+        return username;
+    }
+
+    public String getDomainName() {
+        return domain;
+    }
+
+    public String getToken() {
+        return token;
+    }
+
+    /////////////////////////////////////////////////////
+    /////////////// 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[] password = (String[])params.get(ApiConstants.PASSWORD);
+        final String[] domainName = (String[])params.get(ApiConstants.DOMAIN);
+        final String[] token = (String[])params.get(ApiConstants.TOKEN);
+
+        Long domainId = null;
+        String domain = null;
+        domain = getDomainName(auditTrailSb, domainName, domain);
+
+        String serializedResponse = null;
+
+        if (!UuidUtils.isUuid(token[0])) {
+            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Invalid 
token");
+        }
+
+        if (username != null) {
+            final String pwd = ((password == null) ? null : password[0]);
+            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 CloudAuthenticationException("Password reset is 
not allowed for CloudStack login");
+                }
+                boolean success = _apiServer.resetPassword(userAccount, 
token[0], pwd);
+                SuccessResponse successResponse = new SuccessResponse();
+                successResponse.setSuccess(success);
+                successResponse.setResponseName(getCommandName());
+                return 
ApiResponseSerializer.toSerializedString(successResponse, responseType);
+            } catch (final CloudRuntimeException ex) {
+                ApiServlet.invalidateHttpSession(session, "fall through to API 
key,");
+                String msg = String.format("%s", ex.getMessage() != null ?
+                        ex.getMessage() :
+                        "failed to reset password for user, check your 
inputs");
+                auditTrailSb.append(" " + ApiErrorCode.ACCOUNT_ERROR + " " + 
msg);
+                serializedResponse = 
_apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), msg, 
params, responseType);
+                if (logger.isTraceEnabled()) {
+                    logger.trace(msg);
+                }
+            }
+        }
+        // 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) {
+    }
+}
diff --git a/server/src/main/java/com/cloud/user/AccountManager.java 
b/server/src/main/java/com/cloud/user/AccountManager.java
index 72235a808a4..1e5526688b7 100644
--- a/server/src/main/java/com/cloud/user/AccountManager.java
+++ b/server/src/main/java/com/cloud/user/AccountManager.java
@@ -200,5 +200,7 @@ public interface AccountManager extends AccountService, 
Configurable {
 
     List<String> getApiNameList();
 
-    void checkApiAccess(Account caller, String command);
+    void validateUserPasswordAndUpdateIfNeeded(String newPassword, UserVO 
user, String currentPassword, boolean skipCurrentPassValidation);
+
+  void checkApiAccess(Account caller, String command);
 }
diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java 
b/server/src/main/java/com/cloud/user/AccountManagerImpl.java
index 6a9e15a58c7..78234497cd0 100644
--- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java
+++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java
@@ -1455,7 +1455,7 @@ public class AccountManagerImpl extends ManagerBase 
implements AccountManager, M
         validateAndUpdateLastNameIfNeeded(updateUserCmd, user);
         validateAndUpdateUsernameIfNeeded(updateUserCmd, user, account);
 
-        validateUserPasswordAndUpdateIfNeeded(updateUserCmd.getPassword(), 
user, updateUserCmd.getCurrentPassword());
+        validateUserPasswordAndUpdateIfNeeded(updateUserCmd.getPassword(), 
user, updateUserCmd.getCurrentPassword(), false);
         String email = updateUserCmd.getEmail();
         if (StringUtils.isNotBlank(email)) {
             user.setEmail(email);
@@ -1483,7 +1483,7 @@ public class AccountManagerImpl extends ManagerBase 
implements AccountManager, M
      *
      * If all checks pass, we encode the given password with the most 
preferable password mechanism given in {@link #_userPasswordEncoders}.
      */
-    protected void validateUserPasswordAndUpdateIfNeeded(String newPassword, 
UserVO user, String currentPassword) {
+    public void validateUserPasswordAndUpdateIfNeeded(String newPassword, 
UserVO user, String currentPassword, boolean skipCurrentPassValidation) {
         if (newPassword == null) {
             logger.trace("No new password to update for user: " + 
user.getUuid());
             return;
@@ -1498,16 +1498,17 @@ public class AccountManagerImpl extends ManagerBase 
implements AccountManager, M
         boolean isRootAdminExecutingPasswordUpdate = callingAccount.getId() == 
Account.ACCOUNT_ID_SYSTEM || isRootAdmin(callingAccount.getId());
         boolean isDomainAdmin = isDomainAdmin(callingAccount.getId());
         boolean isAdmin = isDomainAdmin || isRootAdminExecutingPasswordUpdate;
+        boolean skipValidation = isAdmin || skipCurrentPassValidation;
         if (isAdmin) {
             logger.trace(String.format("Admin account [uuid=%s] executing 
password update for user [%s] ", callingAccount.getUuid(), user.getUuid()));
         }
-        if (!isAdmin && StringUtils.isBlank(currentPassword)) {
+        if (!skipValidation && StringUtils.isBlank(currentPassword)) {
             throw new InvalidParameterValueException("To set a new password 
the current password must be provided.");
         }
         if (CollectionUtils.isEmpty(_userPasswordEncoders)) {
             throw new CloudRuntimeException("No user authenticators 
configured!");
         }
-        if (!isAdmin) {
+        if (!skipValidation) {
             validateCurrentPassword(user, currentPassword);
         }
         UserAuthenticator userAuthenticator = _userPasswordEncoders.get(0);
diff --git 
a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManager.java 
b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManager.java
new file mode 100644
index 00000000000..a42faf2835a
--- /dev/null
+++ 
b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManager.java
@@ -0,0 +1,71 @@
+// 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 org.apache.cloudstack.user;
+
+import com.cloud.user.UserAccount;
+import org.apache.cloudstack.framework.config.ConfigKey;
+
+public interface UserPasswordResetManager {
+    ConfigKey<Boolean> UserPasswordResetEnabled = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Boolean.class,
+            "user.password.reset.enabled", "false",
+            "Setting this to true allows the ACS user to request an email to 
reset their password",
+            false,
+            ConfigKey.Scope.Global);
+
+    ConfigKey<Long> UserPasswordResetTtl = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED, Long.class,
+            "user.password.reset.ttl", "30",
+            "TTL in minutes for the token generated to reset the ACS user's 
password", true,
+            ConfigKey.Scope.Global);
+
+    ConfigKey<String> UserPasswordResetEmailSender = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            String.class, "user.password.reset.email.sender", null,
+            "Sender for emails sent to the user to reset ACS user's password 
", true,
+            ConfigKey.Scope.Global);
+
+    ConfigKey<String> UserPasswordResetSMTPHost = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            String.class, "user.password.reset.smtp.host", null,
+            "Host for SMTP server for sending emails for resetting password 
for ACS users",
+            false,
+            ConfigKey.Scope.Global);
+
+    ConfigKey<Integer> UserPasswordResetSMTPPort = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Integer.class, "user.password.reset.smtp.port", "25",
+            "Port for SMTP server for sending emails for resetting password 
for ACS users",
+            false,
+            ConfigKey.Scope.Global);
+
+    ConfigKey<Boolean> UserPasswordResetSMTPUseAuth = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Boolean.class, "user.password.reset.smtp.useAuth", "false",
+            "Use auth in the SMTP server for sending emails for resetting 
password for ACS users",
+            false, ConfigKey.Scope.Global);
+
+    ConfigKey<String> UserPasswordResetSMTPUsername = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            String.class, "user.password.reset.smtp.username", null,
+            "Username for SMTP server for sending emails for resetting 
password for ACS users",
+            false, ConfigKey.Scope.Global);
+
+    ConfigKey<String> UserPasswordResetSMTPPassword = new 
ConfigKey<>("Secure", String.class,
+            "user.password.reset.smtp.password", null,
+            "Password for SMTP server for sending emails for resetting 
password for ACS users",
+            false, ConfigKey.Scope.Global);
+
+    void setResetTokenAndSend(UserAccount userAccount);
+
+    boolean validateAndResetPassword(UserAccount user, String token, String 
password);
+}
diff --git 
a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java
 
b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java
new file mode 100644
index 00000000000..f35f69fb8bf
--- /dev/null
+++ 
b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java
@@ -0,0 +1,312 @@
+// 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 org.apache.cloudstack.user;
+
+import com.cloud.user.AccountManager;
+import com.cloud.user.UserAccount;
+import com.cloud.user.UserVO;
+import com.cloud.user.dao.UserDao;
+import com.cloud.utils.StringUtils;
+import com.cloud.utils.component.ManagerBase;
+import com.github.mustachejava.DefaultMustacheFactory;
+import com.github.mustachejava.Mustache;
+import com.github.mustachejava.MustacheFactory;
+import org.apache.cloudstack.api.ApiErrorCode;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.framework.config.Configurable;
+import org.apache.cloudstack.resourcedetail.UserDetailVO;
+import org.apache.cloudstack.resourcedetail.dao.UserDetailsDao;
+import org.apache.cloudstack.utils.mailing.MailAddress;
+import org.apache.cloudstack.utils.mailing.SMTPMailProperties;
+import org.apache.cloudstack.utils.mailing.SMTPMailSender;
+
+import javax.inject.Inject;
+import javax.naming.ConfigurationException;
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import static 
org.apache.cloudstack.config.ApiServiceConfiguration.ManagementServerAddresses;
+import static 
org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetToken;
+import static 
org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetTokenExpiryDate;
+
+public class UserPasswordResetManagerImpl extends ManagerBase implements 
UserPasswordResetManager, Configurable {
+
+    @Inject
+    private AccountManager accountManager;
+
+    @Inject
+    private UserDetailsDao userDetailsDao;
+
+    @Inject
+    private UserDao userDao;
+
+    private SMTPMailSender mailSender;
+
+    public static ConfigKey<String> PasswordResetMailTemplate =
+            new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED, String.class,
+            "user.password.reset.mail.template", "Hello {{username}}!\n" +
+            "You have requested to reset your password. Please click the 
following link to reset your password:\n" +
+            "http://{{{resetLink}}}\n"; +
+            "If you did not request a password reset, please ignore this 
email.\n" +
+            "\n" +
+            "Regards,\n" +
+            "The CloudStack Team",
+            "Password reset mail template. This uses mustache template engine. 
Available " +
+                    "variables are: username, firstName, lastName, resetLink, 
token",
+            true,
+            ConfigKey.Scope.Global);
+
+    @Override
+    public String getConfigComponentName() {
+        return UserPasswordResetManagerImpl.class.getSimpleName();
+    }
+
+    @Override
+    public ConfigKey<?>[] getConfigKeys() {
+        return new ConfigKey<?>[]{
+                UserPasswordResetEnabled,
+                UserPasswordResetTtl,
+                UserPasswordResetEmailSender,
+                UserPasswordResetSMTPHost,
+                UserPasswordResetSMTPPort,
+                UserPasswordResetSMTPUseAuth,
+                UserPasswordResetSMTPUsername,
+                UserPasswordResetSMTPPassword,
+                PasswordResetMailTemplate
+        };
+    }
+
+    @Override
+    public boolean configure(String name, Map<String, Object> params) throws 
ConfigurationException {
+        String smtpHost = UserPasswordResetSMTPHost.value();
+        Integer smtpPort = UserPasswordResetSMTPPort.value();
+        Boolean useAuth = UserPasswordResetSMTPUseAuth.value();
+        String username = UserPasswordResetSMTPUsername.value();
+        String password = UserPasswordResetSMTPPassword.value();
+
+        if (!StringUtils.isEmpty(smtpHost) && smtpPort != null && smtpPort > 
0) {
+            String namespace = "password.reset.smtp";
+
+            Map<String, String> configs = new HashMap<>();
+
+            configs.put(getKey(namespace, SMTPMailSender.CONFIG_HOST), 
smtpHost);
+            configs.put(getKey(namespace, SMTPMailSender.CONFIG_PORT), 
smtpPort.toString());
+            configs.put(getKey(namespace, SMTPMailSender.CONFIG_USE_AUTH), 
useAuth.toString());
+            configs.put(getKey(namespace, SMTPMailSender.CONFIG_USERNAME), 
username);
+            configs.put(getKey(namespace, SMTPMailSender.CONFIG_PASSWORD), 
password);
+
+            mailSender = new SMTPMailSender(configs, namespace);
+        }
+        return true;
+    }
+
+    private String getKey(String namespace, String config) {
+        return String.format("%s.%s", namespace, config);
+    }
+
+
+    protected boolean validateExistingToken(UserAccount userAccount) {
+
+        Map<String, String> details = 
userDetailsDao.listDetailsKeyPairs(userAccount.getId());
+
+        String resetToken = details.get(PasswordResetToken);
+        String resetTokenExpiryTimeString = 
details.getOrDefault(PasswordResetTokenExpiryDate, "0");
+
+
+        if (StringUtils.isNotEmpty(resetToken) && 
StringUtils.isNotEmpty(resetTokenExpiryTimeString)) {
+            final Date resetTokenExpiryTime = new 
Date(Long.parseLong(resetTokenExpiryTimeString));
+            final Date currentTime = new Date();
+            if (currentTime.after(resetTokenExpiryTime)) {
+                return true;
+            }
+        } else if (StringUtils.isEmpty(resetToken)) {
+            return true;
+        }
+        return false;
+    }
+
+    public void setResetTokenAndSend(UserAccount userAccount) {
+        if (mailSender == null) {
+            logger.debug("Failed to reset token and send email. SMTP mail 
sender is not configured.");
+            throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR,
+                    "Failed to reset token and send email. SMTP mail sender is 
not configured");
+        }
+
+        if (!validateExistingToken(userAccount)) {
+            logger.debug(String.format(
+                    "Failed to reset token and send email. Password reset 
token is already set for user %s in " +
+                            "domain id: %s with account %s and email %s",
+                    userAccount.getUsername(), userAccount.getDomainId(),
+                    userAccount.getAccountName(), userAccount.getEmail()));
+            return;
+        }
+
+        final String resetToken = UUID.randomUUID().toString();
+        final Date resetTokenExpiryTime = new Date(System.currentTimeMillis() 
+ UserPasswordResetTtl.value() * 60 * 1000);
+
+        userDetailsDao.addDetail(userAccount.getId(), PasswordResetToken, 
resetToken, false);
+        userDetailsDao.addDetail(userAccount.getId(), 
PasswordResetTokenExpiryDate, String.valueOf(resetTokenExpiryTime.getTime()), 
false);
+
+        final String email = userAccount.getEmail();
+        final String username = userAccount.getUsername();
+        final String subject = "Password Reset Request";
+
+        String resetLink = 
String.format("%s/client/#/user/resetPassword?username=%s&token=%s",
+                ManagementServerAddresses.value().split(",")[0], username, 
resetToken);
+        String content = getMessageBody(userAccount, resetToken, resetLink);
+
+        SMTPMailProperties mailProperties = new SMTPMailProperties();
+
+        mailProperties.setSender(new 
MailAddress(UserPasswordResetEmailSender.value()));
+        mailProperties.setSubject(subject);
+        mailProperties.setContent(content);
+        mailProperties.setContentType("text/html; charset=utf-8");
+
+        Set<MailAddress> addresses = new HashSet<>();
+
+        addresses.add(new MailAddress(email));
+
+        mailProperties.setRecipients(addresses);
+
+        mailSender.sendMail(mailProperties);
+        logger.debug(String.format(
+                "User password reset email for user id: %d username: %s 
account id: %d" +
+                        " domain id:%d sent to %s with token expiry at %s",
+                userAccount.getId(), username, userAccount.getAccountId(),
+                userAccount.getDomainId(), email, resetTokenExpiryTime));
+    }
+
+    @Override
+    public boolean validateAndResetPassword(UserAccount user, String token, 
String password) {
+        UserDetailVO resetTokenDetail = 
userDetailsDao.findDetail(user.getId(), PasswordResetToken);
+        UserDetailVO resetTokenExpiryDate = 
userDetailsDao.findDetail(user.getId(), PasswordResetTokenExpiryDate);
+
+        if (resetTokenDetail == null || resetTokenExpiryDate == null) {
+            logger.debug(String.format(
+                    "Failed to reset password. No reset token found for user 
id: %d username: %s account" +
+                            " id: %d domain id: %d",
+                    user.getId(), user.getUsername(), user.getAccountId(), 
user.getDomainId()));
+            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, 
String.format("No reset token found for user %s", user.getUsername()));
+        }
+
+        Date resetTokenExpiryTime = new 
Date(Long.parseLong(resetTokenExpiryDate.getValue()));
+
+        Date now = new Date();
+        String resetToken = resetTokenDetail.getValue();
+        if (StringUtils.isEmpty(resetToken)) {
+            logger.debug(String.format(
+                    "Failed to reset password. No reset token found for user 
id: %d username: %s account" +
+                            " id: %d domain id: %d",
+                    user.getId(), user.getUsername(), user.getAccountId(), 
user.getDomainId()));
+            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, 
String.format("No reset token found for user %s", user.getUsername()));
+        }
+        if (!resetToken.equals(token)) {
+            logger.debug(String.format(
+                    "Failed to reset password. Invalid reset token for user 
id: %d username: %s " +
+                            "account id: %d domain id: %d",
+                    user.getId(), user.getUsername(), user.getAccountId(), 
user.getDomainId()));
+            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, 
String.format("Invalid reset token for user %s", user.getUsername()));
+        }
+        if (now.after(resetTokenExpiryTime)) {
+            logger.debug(String.format(
+                    "Failed to reset password. Reset token has expired for 
user id: %d username: %s " +
+                            "account id: %d domain id: %d",
+                    user.getId(), user.getUsername(), user.getAccountId(), 
user.getDomainId()));
+            throw new ServerApiException(ApiErrorCode.PARAM_ERROR, 
String.format("Reset token has expired for user %s", user.getUsername()));
+        }
+
+        resetPassword(user, password);
+        logger.debug(String.format(
+                "Password reset successful for user id: %d username: %s 
account id: %d domain id: %d",
+                user.getId(), user.getUsername(), user.getAccountId(), 
user.getDomainId()));
+        return true;
+    }
+
+
+    void resetPassword(UserAccount userAccount, String password) {
+        UserVO user = userDao.getUser(userAccount.getId());
+
+        accountManager.validateUserPasswordAndUpdateIfNeeded(password, user, 
"", true);
+
+        userDetailsDao.removeDetail(userAccount.getId(), PasswordResetToken);
+        userDetailsDao.removeDetail(userAccount.getId(), 
PasswordResetTokenExpiryDate);
+
+        userDao.persist(user);
+    }
+
+    String getMessageBody(UserAccount userAccount, String token, String 
resetLink) {
+        MustacheFactory mf = new DefaultMustacheFactory();
+        Mustache mustache = mf.compile(new 
StringReader(PasswordResetMailTemplate.value()), "password.reset.mail");
+        StringWriter writer = new StringWriter();
+
+        PasswordResetMail values = new 
PasswordResetMail(userAccount.getUsername(), userAccount.getFirstname(), 
userAccount.getLastname(), resetLink, token);
+
+        try {
+            mustache.execute(writer, values).flush();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+        return writer.toString();
+
+    }
+
+    static class PasswordResetMail {
+        private String username;
+        private String firstName;
+        private String lastName;
+        private String resetLink;
+        private String token;
+
+
+        public PasswordResetMail(String username, String firstName, String 
lastName, String resetLink, String token) {
+            this.username = username;
+            this.firstName = firstName;
+            this.lastName = lastName;
+            this.resetLink = resetLink;
+            this.token = token;
+        }
+
+        public String getUsername() {
+            return username;
+        }
+
+        public String getFirstName() {
+            return firstName;
+        }
+
+        public String getLastName() {
+            return lastName;
+        }
+
+        public String getResetLink() {
+            return resetLink;
+        }
+
+        public String getToken() {
+            return token;
+        }
+    }
+}
diff --git 
a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml
 
b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml
index e9b1cad78d7..1bf921f625e 100644
--- 
a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml
+++ 
b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml
@@ -56,6 +56,7 @@
     </bean>
 
     <bean id="passwordPolicies" class="com.cloud.user.PasswordPolicyImpl" />
+    <bean id="passwordReset" 
class="org.apache.cloudstack.user.UserPasswordResetManagerImpl" />
 
     <bean id="managementServerImpl" 
class="com.cloud.server.ManagementServerImpl">
         <property name="lockControllerListener" ref="lockControllerListener" />
diff --git a/server/src/test/java/com/cloud/api/ApiServerTest.java 
b/server/src/test/java/com/cloud/api/ApiServerTest.java
index 7b0380f8e64..fed1d95a625 100644
--- a/server/src/test/java/com/cloud/api/ApiServerTest.java
+++ b/server/src/test/java/com/cloud/api/ApiServerTest.java
@@ -16,23 +16,53 @@
 // under the License.
 package com.cloud.api;
 
-import java.util.ArrayList;
-import java.util.List;
-
+import com.cloud.domain.Domain;
+import com.cloud.user.UserAccount;
+import com.cloud.utils.exception.CloudRuntimeException;
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.user.UserPasswordResetManager;
+import org.junit.AfterClass;
 import org.junit.Assert;
+import org.junit.BeforeClass;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.InjectMocks;
+import org.mockito.Mock;
 import org.mockito.MockedConstruction;
 import org.mockito.Mockito;
 import org.mockito.junit.MockitoJUnitRunner;
 
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+
+import static 
org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled;
+
 @RunWith(MockitoJUnitRunner.class)
 public class ApiServerTest {
 
     @InjectMocks
     ApiServer apiServer = new ApiServer();
 
+    @Mock
+    UserPasswordResetManager userPasswordResetManager;
+
+    @BeforeClass
+    public static void beforeClass() throws Exception {
+        overrideDefaultConfigValue(UserPasswordResetEnabled, "_value", true);
+    }
+
+    @AfterClass
+    public static void afterClass() throws Exception {
+        overrideDefaultConfigValue(UserPasswordResetEnabled, "_value", false);
+    }
+
+    private static void overrideDefaultConfigValue(final ConfigKey configKey, 
final String name, final Object o) throws IllegalAccessException, 
NoSuchFieldException {
+        Field f = ConfigKey.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(configKey, o);
+    }
+
     private void runTestSetupIntegrationPortListenerInvalidPorts(Integer port) 
{
         try (MockedConstruction<ApiServer.ListenerThread> mocked =
                      Mockito.mockConstruction(ApiServer.ListenerThread.class)) 
{
@@ -61,4 +91,60 @@ public class ApiServerTest {
             Mockito.verify(listenerThread).start();
         }
     }
+
+    @Test
+    public void testForgotPasswordSuccess() {
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Domain domain = Mockito.mock(Domain.class);
+
+        Mockito.when(userAccount.getEmail()).thenReturn("[email protected]");
+        Mockito.when(userAccount.getState()).thenReturn("ENABLED");
+        Mockito.when(userAccount.getAccountState()).thenReturn("ENABLED");
+        Mockito.when(domain.getState()).thenReturn(Domain.State.Active);
+        
Mockito.doNothing().when(userPasswordResetManager).setResetTokenAndSend(userAccount);
+        Assert.assertTrue(apiServer.forgotPassword(userAccount, domain));
+        
Mockito.verify(userPasswordResetManager).setResetTokenAndSend(userAccount);
+    }
+
+    @Test(expected = CloudRuntimeException.class)
+    public void testForgotPasswordFailureNoEmail() {
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Domain domain = Mockito.mock(Domain.class);
+
+        Mockito.when(userAccount.getEmail()).thenReturn("");
+        apiServer.forgotPassword(userAccount, domain);
+    }
+
+    @Test(expected = CloudRuntimeException.class)
+    public void testForgotPasswordFailureDisabledUser() {
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Domain domain = Mockito.mock(Domain.class);
+
+        Mockito.when(userAccount.getEmail()).thenReturn("[email protected]");
+        Mockito.when(userAccount.getState()).thenReturn("DISABLED");
+        apiServer.forgotPassword(userAccount, domain);
+    }
+
+    @Test(expected = CloudRuntimeException.class)
+    public void testForgotPasswordFailureDisabledAccount() {
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Domain domain = Mockito.mock(Domain.class);
+
+        Mockito.when(userAccount.getEmail()).thenReturn("[email protected]");
+        Mockito.when(userAccount.getState()).thenReturn("ENABLED");
+        Mockito.when(userAccount.getAccountState()).thenReturn("DISABLED");
+        apiServer.forgotPassword(userAccount, domain);
+    }
+
+    @Test(expected = CloudRuntimeException.class)
+    public void testForgotPasswordFailureInactiveDomain() {
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Domain domain = Mockito.mock(Domain.class);
+
+        Mockito.when(userAccount.getEmail()).thenReturn("[email protected]");
+        Mockito.when(userAccount.getState()).thenReturn("ENABLED");
+        Mockito.when(userAccount.getAccountState()).thenReturn("ENABLED");
+        Mockito.when(domain.getState()).thenReturn(Domain.State.Inactive);
+        apiServer.forgotPassword(userAccount, domain);
+    }
 }
diff --git a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java 
b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java
index db4fbed5320..9daa19206fa 100644
--- a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java
+++ b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java
@@ -405,7 +405,7 @@ public class AccountManagerImplTest extends 
AccountManagetImplTestBase {
         
Mockito.doNothing().when(accountManagerImpl).validateAndUpdateFirstNameIfNeeded(UpdateUserCmdMock,
 userVoMock);
         
Mockito.doNothing().when(accountManagerImpl).validateAndUpdateLastNameIfNeeded(UpdateUserCmdMock,
 userVoMock);
         
Mockito.doNothing().when(accountManagerImpl).validateAndUpdateUsernameIfNeeded(UpdateUserCmdMock,
 userVoMock, accountMock);
-        
Mockito.doNothing().when(accountManagerImpl).validateUserPasswordAndUpdateIfNeeded(Mockito.anyString(),
 Mockito.eq(userVoMock), Mockito.anyString());
+        
Mockito.doNothing().when(accountManagerImpl).validateUserPasswordAndUpdateIfNeeded(Mockito.anyString(),
 Mockito.eq(userVoMock), Mockito.anyString(), Mockito.eq(false));
 
         Mockito.doReturn(true).when(userDaoMock).update(Mockito.anyLong(), 
Mockito.eq(userVoMock));
         
Mockito.doReturn(Mockito.mock(UserAccountVO.class)).when(userAccountDaoMock).findById(Mockito.anyLong());
@@ -421,7 +421,7 @@ public class AccountManagerImplTest extends 
AccountManagetImplTestBase {
         
inOrder.verify(accountManagerImpl).validateAndUpdateFirstNameIfNeeded(UpdateUserCmdMock,
 userVoMock);
         
inOrder.verify(accountManagerImpl).validateAndUpdateLastNameIfNeeded(UpdateUserCmdMock,
 userVoMock);
         
inOrder.verify(accountManagerImpl).validateAndUpdateUsernameIfNeeded(UpdateUserCmdMock,
 userVoMock, accountMock);
-        
inOrder.verify(accountManagerImpl).validateUserPasswordAndUpdateIfNeeded(UpdateUserCmdMock.getPassword(),
 userVoMock, UpdateUserCmdMock.getCurrentPassword());
+        
inOrder.verify(accountManagerImpl).validateUserPasswordAndUpdateIfNeeded(UpdateUserCmdMock.getPassword(),
 userVoMock, UpdateUserCmdMock.getCurrentPassword(), false);
 
         inOrder.verify(userVoMock, 
Mockito.times(numberOfExpectedCallsForSetEmailAndSetTimeZone)).setEmail(Mockito.anyString());
         inOrder.verify(userVoMock, 
Mockito.times(numberOfExpectedCallsForSetEmailAndSetTimeZone)).setTimezone(Mockito.anyString());
@@ -707,14 +707,14 @@ public class AccountManagerImplTest extends 
AccountManagetImplTestBase {
 
     @Test
     public void valiateUserPasswordAndUpdateIfNeededTestPasswordNull() {
-        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(null, 
userVoMock, null);
+        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(null, 
userVoMock, null, false);
 
         Mockito.verify(userVoMock, 
Mockito.times(0)).setPassword(Mockito.anyString());
     }
 
     @Test(expected = InvalidParameterValueException.class)
     public void valiateUserPasswordAndUpdateIfNeededTestBlankPassword() {
-        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded("       ", 
userVoMock, null);
+        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded("       ", 
userVoMock, null, false);
     }
 
     @Test(expected = InvalidParameterValueException.class)
@@ -728,7 +728,7 @@ public class AccountManagerImplTest extends 
AccountManagetImplTestBase {
 
         
Mockito.lenient().doNothing().when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(),
 Mockito.anyString(), Mockito.anyLong());
 
-        
accountManagerImpl.validateUserPasswordAndUpdateIfNeeded("newPassword", 
userVoMock, "  ");
+        
accountManagerImpl.validateUserPasswordAndUpdateIfNeeded("newPassword", 
userVoMock, "  ", false);
     }
 
     @Test(expected = CloudRuntimeException.class)
@@ -743,7 +743,7 @@ public class AccountManagerImplTest extends 
AccountManagetImplTestBase {
 
         
Mockito.lenient().doNothing().when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(),
 Mockito.anyString(), Mockito.anyLong());
 
-        
accountManagerImpl.validateUserPasswordAndUpdateIfNeeded("newPassword", 
userVoMock, null);
+        
accountManagerImpl.validateUserPasswordAndUpdateIfNeeded("newPassword", 
userVoMock, null, false);
     }
 
     @Test
@@ -762,7 +762,7 @@ public class AccountManagerImplTest extends 
AccountManagetImplTestBase {
 
         
Mockito.lenient().doNothing().when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(),
 Mockito.anyString(), Mockito.anyLong());
 
-        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, 
userVoMock, null);
+        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, 
userVoMock, null, false);
 
         Mockito.verify(accountManagerImpl, 
Mockito.times(0)).validateCurrentPassword(Mockito.eq(userVoMock), 
Mockito.anyString());
         Mockito.verify(userVoMock, 
Mockito.times(1)).setPassword(expectedUserPasswordAfterEncoded);
@@ -784,7 +784,7 @@ public class AccountManagerImplTest extends 
AccountManagetImplTestBase {
 
         
Mockito.lenient().doNothing().when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(),
 Mockito.anyString(), Mockito.anyLong());
 
-        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, 
userVoMock, null);
+        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, 
userVoMock, null, false);
 
         Mockito.verify(accountManagerImpl, 
Mockito.times(0)).validateCurrentPassword(Mockito.eq(userVoMock), 
Mockito.anyString());
         Mockito.verify(userVoMock, 
Mockito.times(1)).setPassword(expectedUserPasswordAfterEncoded);
@@ -807,7 +807,7 @@ public class AccountManagerImplTest extends 
AccountManagetImplTestBase {
 
         
Mockito.lenient().doNothing().when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(),
 Mockito.anyString(), Mockito.anyLong());
 
-        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, 
userVoMock, currentPassword);
+        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, 
userVoMock, currentPassword, false);
 
         Mockito.verify(accountManagerImpl, 
Mockito.times(1)).validateCurrentPassword(userVoMock, currentPassword);
         Mockito.verify(userVoMock, 
Mockito.times(1)).setPassword(expectedUserPasswordAfterEncoded);
@@ -826,7 +826,7 @@ public class AccountManagerImplTest extends 
AccountManagetImplTestBase {
         Mockito.doThrow(new 
InvalidParameterValueException("")).when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(),
 Mockito.anyString(),
                 Mockito.anyLong());
 
-        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, 
userVoMock, currentPassword);
+        accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, 
userVoMock, currentPassword, false);
     }
 
     private String configureUserMockAuthenticators(String newPassword) {
diff --git a/server/src/test/java/com/cloud/user/MockAccountManagerImpl.java 
b/server/src/test/java/com/cloud/user/MockAccountManagerImpl.java
index b4c2dafd664..4cf7413f3f3 100644
--- a/server/src/test/java/com/cloud/user/MockAccountManagerImpl.java
+++ b/server/src/test/java/com/cloud/user/MockAccountManagerImpl.java
@@ -487,4 +487,8 @@ public class MockAccountManagerImpl extends ManagerBase 
implements Manager, Acco
     public List<String> getApiNameList() {
         return null;
     }
+
+    @Override
+    public void validateUserPasswordAndUpdateIfNeeded(String newPassword, 
UserVO user, String currentPassword, boolean skipCurrentPassValidation) {
+    }
 }
diff --git 
a/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java
 
b/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java
new file mode 100644
index 00000000000..17092e6311d
--- /dev/null
+++ 
b/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java
@@ -0,0 +1,150 @@
+// 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 org.apache.cloudstack.user;
+
+import com.cloud.user.UserAccount;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.resourcedetail.UserDetailVO;
+import org.apache.cloudstack.resourcedetail.dao.UserDetailsDao;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.Collections;
+import java.util.Map;
+
+import static 
org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetToken;
+import static 
org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetTokenExpiryDate;
+
+@RunWith(MockitoJUnitRunner.class)
+public class UserPasswordResetManagerImplTest {
+    @Spy
+    @InjectMocks
+    UserPasswordResetManagerImpl passwordReset;
+
+    @Mock
+    private UserDetailsDao userDetailsDao;
+
+    @Test
+    public void testGetMessageBody() {
+        ConfigKey<String> passwordResetMailTemplate = 
Mockito.mock(ConfigKey.class);
+        UserPasswordResetManagerImpl.PasswordResetMailTemplate = 
passwordResetMailTemplate;
+        Mockito.when(passwordResetMailTemplate.value()).thenReturn("Hello 
{{username}}!\n" +
+                "You have requested to reset your password. Please click the 
following link to reset your password:\n" +
+                "{{{resetLink}}}\n" +
+                "If you did not request a password reset, please ignore this 
email.\n" +
+                "\n" +
+                "Regards,\n" +
+                "The CloudStack Team");
+
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Mockito.when(userAccount.getUsername()).thenReturn("test_user");
+
+        String messageBody = passwordReset.getMessageBody(userAccount, 
"reset_token", "reset_link");
+        String expectedMessageBody = "Hello test_user!\n" +
+                "You have requested to reset your password. Please click the 
following link to reset your password:\n" +
+                "reset_link\n" +
+                "If you did not request a password reset, please ignore this 
email.\n" +
+                "\n" +
+                "Regards,\n" +
+                "The CloudStack Team";
+        Assert.assertEquals("Message body doesn't match", expectedMessageBody, 
messageBody);
+    }
+
+    @Test
+    public void testValidateAndResetPassword() {
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Mockito.when(userAccount.getId()).thenReturn(1L);
+        Mockito.when(userAccount.getUsername()).thenReturn("test_user");
+
+        Mockito.doNothing().when(passwordReset).resetPassword(userAccount, 
"new_password");
+
+        UserDetailVO resetTokenDetail = Mockito.mock(UserDetailVO.class);
+        UserDetailVO resetTokenExpiryDate = Mockito.mock(UserDetailVO.class);
+        Mockito.when(userDetailsDao.findDetail(1L, 
PasswordResetToken)).thenReturn(resetTokenDetail);
+        Mockito.when(userDetailsDao.findDetail(1L, 
PasswordResetTokenExpiryDate)).thenReturn(resetTokenExpiryDate);
+        
Mockito.when(resetTokenExpiryDate.getValue()).thenReturn(String.valueOf(System.currentTimeMillis()
 - 5 * 60 * 1000));
+
+        try {
+            passwordReset.validateAndResetPassword(userAccount, "reset_token", 
"new_password");
+            Assert.fail("Should have thrown exception");
+        } catch (ServerApiException e) {
+            Assert.assertEquals("No reset token found for user test_user", 
e.getMessage());
+        }
+
+        
Mockito.when(resetTokenDetail.getValue()).thenReturn("reset_token_XXX");
+
+        try {
+            passwordReset.validateAndResetPassword(userAccount, "reset_token", 
"new_password");
+            Assert.fail("Should have thrown exception");
+        } catch (ServerApiException e) {
+            Assert.assertEquals("Invalid reset token for user test_user", 
e.getMessage());
+        }
+
+        Mockito.when(resetTokenDetail.getValue()).thenReturn("reset_token");
+
+        try {
+            passwordReset.validateAndResetPassword(userAccount, "reset_token", 
"new_password");
+            Assert.fail("Should have thrown exception");
+        } catch (ServerApiException e) {
+            Assert.assertEquals("Reset token has expired for user test_user", 
e.getMessage());
+        }
+
+        
Mockito.when(resetTokenExpiryDate.getValue()).thenReturn(String.valueOf(System.currentTimeMillis()
 + 5 * 60 * 1000));
+
+        Assert.assertTrue(passwordReset.validateAndResetPassword(userAccount, 
"reset_token", "new_password"));
+        Mockito.verify(passwordReset, 
Mockito.times(1)).resetPassword(userAccount, "new_password");
+    }
+
+    @Test
+    public void testValidateExistingTokenFirstRequest() {
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Mockito.when(userAccount.getId()).thenReturn(1L);
+        
Mockito.when(userDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Collections.emptyMap());
+
+        Assert.assertTrue(passwordReset.validateExistingToken(userAccount));
+    }
+
+    @Test
+    public void testValidateExistingTokenSecondRequestExpired() {
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Mockito.when(userAccount.getId()).thenReturn(1L);
+        Mockito.when(userDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Map.of(
+                PasswordResetToken, "reset_token",
+                PasswordResetTokenExpiryDate, 
String.valueOf(System.currentTimeMillis() - 5 * 60 * 1000)));
+
+        Assert.assertTrue(passwordReset.validateExistingToken(userAccount));
+    }
+
+
+    @Test
+    public void testValidateExistingTokenSecondRequestUnexpired() {
+        UserAccount userAccount = Mockito.mock(UserAccount.class);
+        Mockito.when(userAccount.getId()).thenReturn(1L);
+        Mockito.when(userDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Map.of(
+                PasswordResetToken, "reset_token",
+                PasswordResetTokenExpiryDate, 
String.valueOf(System.currentTimeMillis() + 5 * 60 * 1000)));
+
+        Assert.assertFalse(passwordReset.validateExistingToken(userAccount));
+    }
+}
diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py
index ace0dbb33f3..aea803035ce 100644
--- a/tools/apidoc/gen_toc.py
+++ b/tools/apidoc/gen_toc.py
@@ -282,12 +282,14 @@ known_categories = {
     'Webhook': 'Webhook',
     'Webhooks': 'Webhook',
     'purgeExpungedResources': 'Resource',
+    'forgotPassword': 'Authentication',
+    'resetPassword': 'Authentication',
     'BgpPeer': 'BGP Peer',
     'createASNRange': 'AS Number Range',
     'listASNRange': 'AS Number Range',
     'deleteASNRange': 'AS Number Range',
     'listASNumbers': 'AS Number',
-    'releaseASNumber': 'AS Number'
+    'releaseASNumber': 'AS Number',
 }
 
 
diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 7336c038c89..f709c73e51a 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -417,6 +417,7 @@
 "label.availableprocessors": "Available processor cores",
 "label.availablevirtualmachinecount": "Available Instances",
 "label.back": "Back",
+"label.back.login": "Back to login",
 "label.backup": "Backups",
 "label.backup.attach.restore": "Restore and attach backup volume",
 "label.backup.configure.schedule": "Configure Backup Schedule",
@@ -1002,6 +1003,7 @@
 "label.force.reboot": "Force reboot",
 "label.forceencap": "Force UDP encapsulation of ESP packets",
 "label.forgedtransmits": "Forged transmits",
+"label.forgot.password": "Forgot password?",
 "label.format": "Format",
 "label.fornsx": "NSX",
 "label.forvpc": "VPC",
@@ -3174,6 +3176,7 @@
 "message.failed.to.add": "Failed to add",
 "message.failed.to.assign.vms": "Failed to assign Instances",
 "message.failed.to.remove": "Failed to remove",
+"message.forgot.password.success": "An email has been sent to your email 
address with instructions on how to reset your password.",
 "message.generate.keys": "Please confirm that you would like to generate new 
API/Secret keys for this User.",
 "message.chart.statistic.info": "The shown charts are self-adjustable, that 
means, if the value gets close to the limit or overpass it, it will grow to 
adjust the shown value",
 "message.chart.statistic.info.hypervisor.additionals": "The metrics data 
depend on the hypervisor plugin used for each hypervisor. The behavior can vary 
across different hypervisors. For instance, with KVM, metrics are real-time 
statistics provided by libvirt. In contrast, with VMware, the metrics are 
averaged data for a given time interval controlled by configuration.",
@@ -3301,6 +3304,8 @@
 "message.offering.internet.protocol.warning": "WARNING: IPv6 supported 
Networks use static routing and will require upstream routes to be configured 
manually.",
 "message.offering.ipv6.warning": "Please refer documentation for creating IPv6 
enabled Network/VPC offering <a 
href='http://docs.cloudstack.apache.org/en/latest/plugins/ipv6.html#isolated-network-and-vpc-tier'>IPv6
 support in CloudStack - Isolated Networks and VPC Network Tiers</a>",
 "message.ovf.configurations": "OVF configurations available for the selected 
appliance. Please select the desired value. Incompatible compute offerings will 
get disabled.",
+"message.password.reset.failed": "Failed to reset password.",
+"message.password.reset.success": "Password has been reset successfully. 
Please login using your new credentials.",
 "message.path": "Path : ",
 "message.path.description": "NFS: exported path from the server. VMFS: 
/datacenter name/datastore name. SharedMountPoint: path where primary storage 
is mounted, such as /mnt/primary.",
 "message.please.confirm.remove.ssh.key.pair": "Please confirm that you want to 
remove this SSH key pair.",
diff --git a/ui/src/config/router.js b/ui/src/config/router.js
index 0d0783a0906..16599a0c367 100644
--- a/ui/src/config/router.js
+++ b/ui/src/config/router.js
@@ -300,6 +300,16 @@ export const constantRouterMap = [
         path: 'login',
         name: 'login',
         component: () => import(/* webpackChunkName: "auth" */ 
'@/views/auth/Login')
+      },
+      {
+        path: 'forgotPassword',
+        name: 'forgotPassword',
+        component: () => import(/* webpackChunkName: "auth" */ 
'@/views/auth/ForgotPassword')
+      },
+      {
+        path: 'resetPassword',
+        name: 'resetPassword',
+        component: () => import(/* webpackChunkName: "auth" */ 
'@/views/auth/ResetPassword')
       }
     ]
   },
diff --git a/ui/src/permission.js b/ui/src/permission.js
index 4380c7660d8..266dc992c8d 100644
--- a/ui/src/permission.js
+++ b/ui/src/permission.js
@@ -30,7 +30,7 @@ import { ACCESS_TOKEN, APIS, SERVER_MANAGER, CURRENT_PROJECT 
} from '@/store/mut
 
 NProgress.configure({ showSpinner: false }) // NProgress Configuration
 
-const allowList = ['login', 'VerifyOauth'] // no redirect allowlist
+const allowList = ['login', 'VerifyOauth', 'forgotPassword', 'resetPassword'] 
// no redirect allowlist
 
 router.beforeEach((to, from, next) => {
   // start progress bar
diff --git a/ui/src/utils/request.js b/ui/src/utils/request.js
index c2fe04ab9d1..7c757691f2b 100644
--- a/ui/src/utils/request.js
+++ b/ui/src/utils/request.js
@@ -51,7 +51,7 @@ const err = (error) => {
       })
     }
     if (response.status === 401) {
-      if (response.config && response.config.params && ['listIdps', 
'cloudianIsEnabled'].includes(response.config.params.command)) {
+      if (response.config && response.config.params && ['forgotPassword', 
'listIdps', 'cloudianIsEnabled'].includes(response.config.params.command)) {
         return
       }
       const originalPath = router.currentRoute.value.fullPath
diff --git a/ui/src/views/auth/ForgotPassword.vue 
b/ui/src/views/auth/ForgotPassword.vue
new file mode 100644
index 00000000000..2d45938417f
--- /dev/null
+++ b/ui/src/views/auth/ForgotPassword.vue
@@ -0,0 +1,260 @@
+// 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.
+
+<template>
+  <a-form
+    id="formForgotPassword"
+    class="user-layout-forgot-password"
+    :ref="formRef"
+    :model="form"
+    :rules="rules"
+    @finish="handleSubmit"
+    v-ctrl-enter="handleSubmit"
+  >
+        <a-form-item v-if="$config.multipleServer" name="server" ref="server">
+          <a-select
+            size="large"
+            :placeholder="$t('server')"
+            v-model:value="form.server"
+            @change="onChangeServer"
+            showSearch
+            optionFilterProp="label"
+            :filterOption="(input, option) => {
+              return option.label.toLowerCase().indexOf(input.toLowerCase()) 
>= 0
+            }">
+            <a-select-option v-for="item in $config.servers" 
:key="(item.apiHost || '') + item.apiBase" :label="item.name">
+              <template #prefix>
+                <database-outlined />
+              </template>
+              {{ item.name }}
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item ref="username" name="username">
+          <a-input
+            size="large"
+            type="text"
+            v-focus="true"
+            :placeholder="$t('label.username')"
+            v-model:value="form.username"
+          >
+            <template #prefix>
+              <user-outlined />
+            </template>
+          </a-input>
+        </a-form-item>
+        <a-form-item ref="domain" name="domain">
+          <a-input
+            size="large"
+            type="text"
+            :placeholder="$t('label.domain')"
+            v-model:value="form.domain"
+          >
+            <template #prefix>
+              <block-outlined />
+            </template>
+          </a-input>
+        </a-form-item>
+
+    <a-form-item>
+      <a-button
+        size="large"
+        type="primary"
+        html-type="submit"
+        class="submit-button"
+        :loading="submitBtn"
+        :disabled="submitBtn"
+        ref="submit"
+        @click="handleSubmit"
+      >{{ $t('label.submit') }}</a-button>
+    </a-form-item>
+    <a-row justify="space-between">
+      <a-col>
+      <translation-menu/>
+      </a-col>
+      <a-col>
+        <router-link :to="{ name: 'login' }">
+          {{ $t('label.back.login') }}
+        </router-link>
+
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script>
+import { ref, reactive, toRaw } from 'vue'
+import { api } from '@/api'
+import store from '@/store'
+import { SERVER_MANAGER } from '@/store/mutation-types'
+import TranslationMenu from '@/components/header/TranslationMenu'
+
+export default {
+  components: {
+    TranslationMenu
+  },
+  data () {
+    return {
+      idps: [],
+      customActiveKey: 'cs',
+      customActiveKeyOauth: false,
+      submitBtn: false,
+      email: '',
+      secretcode: '',
+      oauthexclude: '',
+      server: ''
+    }
+  },
+  created () {
+    if (this.$config.multipleServer) {
+      this.server = this.$localStorage.get(SERVER_MANAGER) || 
this.$config.servers[0]
+    }
+    this.initForm()
+  },
+  methods: {
+    initForm () {
+      this.formRef = ref()
+      this.form = reactive({
+        server: (this.server.apiHost || '') + this.server.apiBase
+      })
+      this.rules = {
+        username: [{
+          required: true,
+          message: this.$t('message.error.username'),
+          trigger: 'change'
+        }]
+      }
+    },
+    handleSubmit (e) {
+      e.preventDefault()
+      if (this.submitBtn) return
+      this.formRef.value.validate().then(() => {
+        this.submitBtn = true
+
+        const values = toRaw(this.form)
+        if (this.$config.multipleServer) {
+          this.axios.defaults.baseURL = (this.server.apiHost || '') + 
this.server.apiBase
+          store.dispatch('SetServer', this.server)
+        }
+        const loginParams = { ...values }
+        delete loginParams.username
+        loginParams.username = values.username
+        loginParams.domain = values.domain
+        if (!loginParams.domain) {
+          loginParams.domain = '/'
+        }
+        api('forgotPassword', {}, 'POST', loginParams)
+          .finally(() => {
+            this.$message.success(this.$t('message.forgot.password.success'))
+            this.$router.push({ path: '/login' }).catch(() => {})
+          })
+      }).catch(error => {
+        this.formRef.value.scrollToField(error.errorFields[0].name)
+      })
+    },
+    requestFailed (err) {
+      if (err && err.response && err.response.data && 
err.response.data.forgotpasswordresponse) {
+        const error = err.response.data.forgotpasswordresponse.errorcode + ': 
' + err.response.data.forgotpasswordresponse.errortext
+        this.$message.error(`${this.$t('label.error')} ${error}`)
+      } else {
+        this.$message.error(this.$t('message.password.reset.failed'))
+      }
+    },
+    onChangeServer (server) {
+      const servers = this.$config.servers || []
+      const serverFilter = servers.filter(ser => (ser.apiHost || '') + 
ser.apiBase === server)
+      this.server = serverFilter[0] || {}
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.user-layout-forgot-password {
+  min-width: 260px;
+  width: 368px;
+  margin: 0 auto;
+
+  .mobile & {
+    max-width: 368px;
+    width: 98%;
+  }
+
+  label {
+    font-size: 14px;
+  }
+
+  button.submit-button {
+    margin-top: 8px;
+    padding: 0 15px;
+    font-size: 16px;
+    height: 40px;
+    width: 100%;
+  }
+
+  .user-login-other {
+    text-align: left;
+    margin-top: 24px;
+    line-height: 22px;
+
+    .item-icon {
+      font-size: 24px;
+      color: rgba(0, 0, 0, 0.2);
+      margin-left: 16px;
+      vertical-align: middle;
+      cursor: pointer;
+      transition: color 0.3s;
+
+      &:hover {
+        color: #1890ff;
+      }
+    }
+
+    .register {
+      float: right;
+    }
+
+    .g-btn-wrapper {
+      background-color: rgb(221, 75, 57);
+      height: 40px;
+      width: 80px;
+    }
+  }
+    .center {
+     display: flex;
+     flex-direction: column;
+     justify-content: center;
+     align-items: center;
+     height: 100px;
+    }
+
+    .content {
+      margin: 10px auto;
+      width: 300px;
+    }
+
+    .or {
+      text-align: center;
+      font-size: 16px;
+      background:
+        linear-gradient(#CCC 0 0) left,
+        linear-gradient(#CCC 0 0) right;
+      background-size: 40% 1px;
+      background-repeat: no-repeat;
+    }
+}
+</style>
diff --git a/ui/src/views/auth/Login.vue b/ui/src/views/auth/Login.vue
index 8503f71082b..13645565557 100644
--- a/ui/src/views/auth/Login.vue
+++ b/ui/src/views/auth/Login.vue
@@ -152,7 +152,16 @@
         @click="handleSubmit"
       >{{ $t('label.login') }}</a-button>
     </a-form-item>
-    <translation-menu/>
+    <a-row justify="space-between">
+      <a-col>
+      <translation-menu/>
+      </a-col>
+      <a-col v-if="forgotPasswordEnabled">
+        <router-link :to="{ name: 'forgotPassword' }">
+          {{ $t('label.forgot.password') }}
+        </router-link>
+      </a-col>
+    </a-row>
     <div class="content" v-if="socialLogin">
       <p class="or">or</p>
     </div>
@@ -220,7 +229,8 @@ export default {
         loginBtn: false,
         loginType: 0
       },
-      server: ''
+      server: '',
+      forgotPasswordEnabled: false
     }
   },
   created () {
@@ -303,6 +313,15 @@ export default {
           })
         }
       })
+      api('forgotPassword', {}).then(response => {
+        this.forgotPasswordEnabled = response.forgotpasswordresponse.enabled
+      }).catch((err) => {
+        if (err?.response?.data === null) {
+          this.forgotPasswordEnabled = true
+        } else {
+          this.forgotPasswordEnabled = false
+        }
+      })
     },
     // handler
     async handleUsernameOrEmail (rule, value) {
diff --git a/ui/src/views/auth/ResetPassword.vue 
b/ui/src/views/auth/ResetPassword.vue
new file mode 100644
index 00000000000..8a9047c5d3e
--- /dev/null
+++ b/ui/src/views/auth/ResetPassword.vue
@@ -0,0 +1,318 @@
+// 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.
+
+<template>
+  <a-form
+    id="formResetPassword"
+    class="user-layout-reset-password"
+    :ref="formRef"
+    :model="form"
+    :rules="rules"
+    @finish="handleSubmit"
+    v-ctrl-enter="handleSubmit"
+  >
+        <a-form-item v-if="$config.multipleServer" name="server" ref="server">
+          <a-select
+            size="large"
+            :placeholder="$t('server')"
+            v-model:value="form.server"
+            @change="onChangeServer"
+            showSearch
+            optionFilterProp="label"
+            :filterOption="(input, option) => {
+              return option.label.toLowerCase().indexOf(input.toLowerCase()) 
>= 0
+            }">
+            <a-select-option v-for="item in $config.servers" 
:key="(item.apiHost || '') + item.apiBase" :label="item.name">
+              <template #prefix>
+                <database-outlined />
+              </template>
+              {{ item.name }}
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item ref="username" name="username">
+          <a-input
+            size="large"
+            type="text"
+            v-focus="true"
+            :placeholder="$t('label.username')"
+            v-model:value="form.username"
+          >
+            <template #prefix>
+              <user-outlined />
+            </template>
+          </a-input>
+        </a-form-item>
+        <a-form-item ref="domain" name="domain">
+          <a-input
+            size="large"
+            type="text"
+            :placeholder="$t('label.domain')"
+            v-model:value="form.domain"
+          >
+            <template #prefix>
+              <block-outlined />
+            </template>
+          </a-input>
+        </a-form-item>
+        <a-form-item ref="password" name="password">
+          <a-input-password
+            size="large"
+            type="password"
+            autocomplete="false"
+            :placeholder="$t('label.password')"
+            v-model:value="form.password"
+          >
+            <template #prefix>
+              <lock-outlined />
+            </template>
+          </a-input-password>
+        </a-form-item>
+        <a-form-item ref="confirmpassword" name="confirmpassword">
+          <a-input-password
+            size="large"
+            type="password"
+            autocomplete="false"
+            :placeholder="$t('label.confirmpassword.description')"
+            v-model:value="form.confirmpassword"
+          >
+            <template #prefix>
+              <lock-outlined />
+            </template>
+          </a-input-password>
+        </a-form-item>
+
+    <a-form-item>
+      <a-button
+        size="large"
+        type="primary"
+        html-type="submit"
+        class="reset-button"
+        :loading="resetBtn"
+        :disabled="resetBtn"
+        ref="submit"
+        @click="handleSubmit"
+      >{{ $t('label.action.reset.password') }}</a-button>
+    </a-form-item>
+    <a-row justify="space-between">
+      <a-col>
+      <translation-menu/>
+      </a-col>
+      <a-col>
+        <router-link :to="{ name: 'login' }">
+          {{ $t('label.back.login') }}
+        </router-link>
+
+      </a-col>
+    </a-row>
+  </a-form>
+</template>
+
+<script>
+import { ref, reactive, toRaw } from 'vue'
+import { api } from '@/api'
+import store from '@/store'
+import { SERVER_MANAGER } from '@/store/mutation-types'
+import TranslationMenu from '@/components/header/TranslationMenu'
+
+export default {
+  components: {
+    TranslationMenu
+  },
+  data () {
+    return {
+      idps: [],
+      customActiveKey: 'cs',
+      customActiveKeyOauth: false,
+      resetBtn: false,
+      email: '',
+      secretcode: '',
+      oauthexclude: '',
+      server: ''
+    }
+  },
+  created () {
+    if (this.$config.multipleServer) {
+      this.server = this.$localStorage.get(SERVER_MANAGER) || 
this.$config.servers[0]
+    }
+    this.initForm()
+  },
+  methods: {
+    initForm () {
+      this.formRef = ref()
+      this.form = reactive({
+        server: (this.server.apiHost || '') + this.server.apiBase,
+        username: this.$route.query?.username || '',
+        token: this.$route.query?.token || ''
+      })
+      this.rules = {
+        username: [{
+          required: true,
+          message: this.$t('message.error.username'),
+          trigger: 'change'
+        }],
+        password: [{
+          required: true,
+          message: this.$t('message.error.password'),
+          trigger: 'change'
+        }],
+        confirmpassword: [{
+          required: true,
+          message: this.$t('message.error.password'),
+          trigger: 'change'
+        },
+        {
+          validator: this.validateConfirmPassword,
+          trigger: 'change'
+        }]
+      }
+    },
+    handleSubmit (e) {
+      e.preventDefault()
+      if (this.resetBtn) return
+      this.formRef.value.validate().then(() => {
+        this.resetBtn = true
+
+        const values = toRaw(this.form)
+        if (this.$config.multipleServer) {
+          this.axios.defaults.baseURL = (this.server.apiHost || '') + 
this.server.apiBase
+          store.dispatch('SetServer', this.server)
+        }
+        const loginParams = { ...values }
+        loginParams.username = values.username
+        loginParams.domain = values.domain
+        if (!loginParams.domain) {
+          loginParams.domain = '/'
+        }
+
+        api('resetPassword', {}, 'POST', loginParams)
+          .then((res) => {
+            if (res?.resetpasswordresponse?.success) {
+              this.$message.success(this.$t('message.password.reset.success'))
+              this.$router.push({ name: 'login' })
+            } else {
+              this.$message.error(this.$t('message.password.reset.failed'))
+            }
+          })
+          .catch(err => {
+            this.$message.error(`${this.$t('message.password.reset.failed')} 
${err?.response?.data}`)
+          }).finally(() => {
+            this.resetBtn = false
+          })
+      }).catch(error => {
+        this.formRef.value.scrollToField(error.errorFields[0].name)
+      })
+    },
+    onChangeServer (server) {
+      const servers = this.$config.servers || []
+      const serverFilter = servers.filter(ser => (ser.apiHost || '') + 
ser.apiBase === server)
+      this.server = serverFilter[0] || {}
+    },
+    async validateConfirmPassword (rule, value) {
+      if (!value || value.length === 0) {
+        return Promise.resolve()
+      } else if (rule.field === 'confirmpassword') {
+        const messageConfirm = this.$t('error.password.not.match')
+        const passwordVal = this.form.password
+        if (passwordVal && passwordVal !== value) {
+          return Promise.reject(messageConfirm)
+        } else {
+          return Promise.resolve()
+        }
+      } else {
+        return Promise.resolve()
+      }
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.user-layout-reset-password {
+  min-width: 260px;
+  width: 368px;
+  margin: 0 auto;
+
+  .mobile & {
+    max-width: 368px;
+    width: 98%;
+  }
+
+  label {
+    font-size: 14px;
+  }
+
+  button.reset-button {
+    margin-top: 8px;
+    padding: 0 15px;
+    font-size: 16px;
+    height: 40px;
+    width: 100%;
+  }
+
+  .user-login-other {
+    text-align: left;
+    margin-top: 24px;
+    line-height: 22px;
+
+    .item-icon {
+      font-size: 24px;
+      color: rgba(0, 0, 0, 0.2);
+      margin-left: 16px;
+      vertical-align: middle;
+      cursor: pointer;
+      transition: color 0.3s;
+
+      &:hover {
+        color: #1890ff;
+      }
+    }
+
+    .register {
+      float: right;
+    }
+
+    .g-btn-wrapper {
+      background-color: rgb(221, 75, 57);
+      height: 40px;
+      width: 80px;
+    }
+  }
+    .center {
+     display: flex;
+     flex-direction: column;
+     justify-content: center;
+     align-items: center;
+     height: 100px;
+    }
+
+    .content {
+      margin: 10px auto;
+      width: 300px;
+    }
+
+    .or {
+      text-align: center;
+      font-size: 16px;
+      background:
+        linear-gradient(#CCC 0 0) left,
+        linear-gradient(#CCC 0 0) right;
+      background-size: 40% 1px;
+      background-repeat: no-repeat;
+    }
+}
+</style>
diff --git 
a/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java 
b/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java
index 4afa3c9100b..b354772fde0 100644
--- 
a/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java
+++ 
b/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java
@@ -48,16 +48,16 @@ public class SMTPMailSender {
     protected Session session = null;
     protected SMTPSessionProperties sessionProps;
 
-    protected static final String CONFIG_HOST = "host";
-    protected static final String CONFIG_PORT = "port";
-    protected static final String CONFIG_USE_AUTH = "useAuth";
-    protected static final String CONFIG_USERNAME = "username";
-    protected static final String CONFIG_PASSWORD = "password";
-    protected static final String CONFIG_DEBUG_MODE = "debug";
-    protected static final String CONFIG_USE_STARTTLS = "useStartTLS";
-    protected static final String CONFIG_ENABLED_SECURITY_PROTOCOLS = 
"enabledSecurityProtocols";
-    protected static final String CONFIG_TIMEOUT = "timeout";
-    protected static final String CONFIG_CONNECTION_TIMEOUT = 
"connectiontimeout";
+    public static final String CONFIG_HOST = "host";
+    public static final String CONFIG_PORT = "port";
+    public static final String CONFIG_USE_AUTH = "useAuth";
+    public static final String CONFIG_USERNAME = "username";
+    public static final String CONFIG_PASSWORD = "password";
+    public static final String CONFIG_DEBUG_MODE = "debug";
+    public static final String CONFIG_USE_STARTTLS = "useStartTLS";
+    public static final String CONFIG_ENABLED_SECURITY_PROTOCOLS = 
"enabledSecurityProtocols";
+    public static final String CONFIG_TIMEOUT = "timeout";
+    public static final String CONFIG_CONNECTION_TIMEOUT = "connectiontimeout";
 
     protected Map<String, String> configs;
     protected String namespace;

Reply via email to