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;