Feature: Forgot password (#9509)

* Feature: Forgot password

* Address comments

* fixups

* Make forgot password disabled by default

* Apply suggestions from code review

* Address comments
This commit is contained in:
Vishesh 2024-09-10 21:25:28 +05:30 committed by GitHub
parent 638c1526d0
commit 0655075f51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1726 additions and 41 deletions

View File

@ -21,7 +21,9 @@ import java.util.Map;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
import com.cloud.domain.Domain;
import com.cloud.exception.CloudAuthenticationException; import com.cloud.exception.CloudAuthenticationException;
import com.cloud.user.UserAccount;
public interface ApiServerService { public interface ApiServerService {
public boolean verifyRequest(Map<String, Object[]> requestParameters, Long userId, InetAddress remoteAddress) throws ServerApiException; 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 String handleRequest(Map<String, Object[]> params, String responseType, StringBuilder auditTrailSb) throws ServerApiException;
public Class<?> getCmdClass(String cmdName); public Class<?> getCmdClass(String cmdName);
boolean forgotPassword(UserAccount userAccount, Domain domain);
boolean resetPassword(UserAccount userAccount, String token, String password);
} }

View File

@ -17,5 +17,5 @@
package org.apache.cloudstack.api.auth; package org.apache.cloudstack.api.auth;
public enum APIAuthenticationType { public enum APIAuthenticationType {
LOGIN_API, LOGOUT_API, READONLY_API, LOGIN_2FA_API LOGIN_API, LOGOUT_API, READONLY_API, LOGIN_2FA_API, PASSWORD_RESET
} }

View File

@ -17,6 +17,7 @@
package com.cloud.user; package com.cloud.user;
import java.util.Date; import java.util.Date;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import javax.persistence.Column; import javax.persistence.Column;
@ -361,6 +362,9 @@ public class UserAccountVO implements UserAccount, InternalIdentity {
@Override @Override
public Map<String, String> getDetails() { public Map<String, String> getDetails() {
if (details == null) {
details = new HashMap<>();
}
return details; return details;
} }

View File

@ -46,6 +46,8 @@ public class UserDetailVO implements ResourceDetail {
private boolean display = true; private boolean display = true;
public static final String Setup2FADetail = "2FASetupStatus"; public static final String Setup2FADetail = "2FASetupStatus";
public static final String PasswordResetToken = "PasswordResetToken";
public static final String PasswordResetTokenExpiryDate = "PasswordResetTokenExpiryDate";
public UserDetailVO() { public UserDetailVO() {
} }

View File

@ -515,6 +515,11 @@ public class MockAccountManager extends ManagerBase implements AccountManager {
return null; return null;
} }
public void validateUserPasswordAndUpdateIfNeeded(String newPassword, UserVO user,
String currentPassword,
boolean skipCurrentPassValidation) {
}
@Override @Override
public void checkApiAccess(Account account, String command) throws PermissionDeniedException { public void checkApiAccess(Account account, String command) throws PermissionDeniedException {

View File

@ -169,6 +169,7 @@
<cs.kafka-clients.version>2.7.0</cs.kafka-clients.version> <cs.kafka-clients.version>2.7.0</cs.kafka-clients.version>
<cs.libvirt-java.version>0.5.3</cs.libvirt-java.version> <cs.libvirt-java.version>0.5.3</cs.libvirt-java.version>
<cs.mail.version>1.5.0-b01</cs.mail.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.mysql.version>8.0.33</cs.mysql.version>
<cs.neethi.version>2.0.4</cs.neethi.version> <cs.neethi.version>2.0.4</cs.neethi.version>
<cs.nitro.version>10.1</cs.nitro.version> <cs.nitro.version>10.1</cs.nitro.version>

View File

@ -101,6 +101,11 @@
<artifactId>commons-math3</artifactId> <artifactId>commons-math3</artifactId>
<version>${cs.commons-math3.version}</version> <version>${cs.commons-math3.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.github.spullara.mustache.java</groupId>
<artifactId>compiler</artifactId>
<version>${cs.mustache.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.apache.cloudstack</groupId> <groupId>org.apache.cloudstack</groupId>
<artifactId>cloud-utils</artifactId> <artifactId>cloud-utils</artifactId>

View File

@ -55,6 +55,13 @@ import javax.naming.ConfigurationException;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession; 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.acl.APIChecker;
import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiConstants; 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.MessageDispatcher;
import org.apache.cloudstack.framework.messagebus.MessageHandler; import org.apache.cloudstack.framework.messagebus.MessageHandler;
import org.apache.cloudstack.managed.context.ManagedContextRunnable; import org.apache.cloudstack.managed.context.ManagedContextRunnable;
import org.apache.cloudstack.user.UserPasswordResetManager;
import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.EnumUtils;
import org.apache.http.ConnectionClosedException; import org.apache.http.ConnectionClosedException;
import org.apache.http.HttpException; import org.apache.http.HttpException;
import org.apache.http.HttpRequest; import org.apache.http.HttpRequest;
@ -157,13 +166,6 @@ import com.cloud.exception.ResourceUnavailableException;
import com.cloud.exception.UnavailableCommandException; import com.cloud.exception.UnavailableCommandException;
import com.cloud.projects.dao.ProjectDao; import com.cloud.projects.dao.ProjectDao;
import com.cloud.storage.VolumeApiService; 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.ConstantTimeComparator;
import com.cloud.utils.DateUtil; import com.cloud.utils.DateUtil;
import com.cloud.utils.HttpUtils; import com.cloud.utils.HttpUtils;
@ -182,6 +184,8 @@ import com.cloud.utils.exception.ExceptionProxyObject;
import com.cloud.utils.net.NetUtils; import com.cloud.utils.net.NetUtils;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled;
@Component @Component
public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiServerService, Configurable { public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiServerService, Configurable {
@ -214,6 +218,8 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
private ProjectDao projectDao; private ProjectDao projectDao;
@Inject @Inject
private UUIDManager uuidMgr; private UUIDManager uuidMgr;
@Inject
private UserPasswordResetManager userPasswordResetManager;
private List<PluggableService> pluggableServices; private List<PluggableService> pluggableServices;
@ -1223,6 +1229,57 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
return true; 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 { private void checkCommandAvailable(final User user, final String commandName, final InetAddress remoteAddress) throws PermissionDeniedException {
if (user == null) { if (user == null) {
throw new PermissionDeniedException("User is null for role based API access check for command" + commandName); throw new PermissionDeniedException("User is null for role based API access check for command" + commandName);

View File

@ -31,6 +31,8 @@ import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator;
import com.cloud.utils.component.ComponentContext; import com.cloud.utils.component.ComponentContext;
import com.cloud.utils.component.ManagerBase; import com.cloud.utils.component.ManagerBase;
import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled;
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public class APIAuthenticationManagerImpl extends ManagerBase implements APIAuthenticationManager { public class APIAuthenticationManagerImpl extends ManagerBase implements APIAuthenticationManager {
@ -75,6 +77,10 @@ public class APIAuthenticationManagerImpl extends ManagerBase implements APIAuth
List<Class<?>> cmdList = new ArrayList<Class<?>>(); List<Class<?>> cmdList = new ArrayList<Class<?>>();
cmdList.add(DefaultLoginAPIAuthenticatorCmd.class); cmdList.add(DefaultLoginAPIAuthenticatorCmd.class);
cmdList.add(DefaultLogoutAPIAuthenticatorCmd.class); cmdList.add(DefaultLogoutAPIAuthenticatorCmd.class);
if (UserPasswordResetEnabled.value()) {
cmdList.add(DefaultForgotPasswordAPIAuthenticatorCmd.class);
cmdList.add(DefaultResetPasswordAPIAuthenticatorCmd.class);
}
cmdList.add(ListUserTwoFactorAuthenticatorProvidersCmd.class); cmdList.add(ListUserTwoFactorAuthenticatorProvidersCmd.class);
cmdList.add(ValidateUserTwoFactorAuthenticationCodeCmd.class); cmdList.add(ValidateUserTwoFactorAuthenticationCodeCmd.class);

View File

@ -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) {
}
}

View File

@ -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) {
}
}

View File

@ -200,5 +200,7 @@ public interface AccountManager extends AccountService, Configurable {
List<String> getApiNameList(); List<String> getApiNameList();
void validateUserPasswordAndUpdateIfNeeded(String newPassword, UserVO user, String currentPassword, boolean skipCurrentPassValidation);
void checkApiAccess(Account caller, String command); void checkApiAccess(Account caller, String command);
} }

View File

@ -1455,7 +1455,7 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M
validateAndUpdateLastNameIfNeeded(updateUserCmd, user); validateAndUpdateLastNameIfNeeded(updateUserCmd, user);
validateAndUpdateUsernameIfNeeded(updateUserCmd, user, account); validateAndUpdateUsernameIfNeeded(updateUserCmd, user, account);
validateUserPasswordAndUpdateIfNeeded(updateUserCmd.getPassword(), user, updateUserCmd.getCurrentPassword()); validateUserPasswordAndUpdateIfNeeded(updateUserCmd.getPassword(), user, updateUserCmd.getCurrentPassword(), false);
String email = updateUserCmd.getEmail(); String email = updateUserCmd.getEmail();
if (StringUtils.isNotBlank(email)) { if (StringUtils.isNotBlank(email)) {
user.setEmail(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}. * 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) { if (newPassword == null) {
logger.trace("No new password to update for user: " + user.getUuid()); logger.trace("No new password to update for user: " + user.getUuid());
return; 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 isRootAdminExecutingPasswordUpdate = callingAccount.getId() == Account.ACCOUNT_ID_SYSTEM || isRootAdmin(callingAccount.getId());
boolean isDomainAdmin = isDomainAdmin(callingAccount.getId()); boolean isDomainAdmin = isDomainAdmin(callingAccount.getId());
boolean isAdmin = isDomainAdmin || isRootAdminExecutingPasswordUpdate; boolean isAdmin = isDomainAdmin || isRootAdminExecutingPasswordUpdate;
boolean skipValidation = isAdmin || skipCurrentPassValidation;
if (isAdmin) { if (isAdmin) {
logger.trace(String.format("Admin account [uuid=%s] executing password update for user [%s] ", callingAccount.getUuid(), user.getUuid())); 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."); throw new InvalidParameterValueException("To set a new password the current password must be provided.");
} }
if (CollectionUtils.isEmpty(_userPasswordEncoders)) { if (CollectionUtils.isEmpty(_userPasswordEncoders)) {
throw new CloudRuntimeException("No user authenticators configured!"); throw new CloudRuntimeException("No user authenticators configured!");
} }
if (!isAdmin) { if (!skipValidation) {
validateCurrentPassword(user, currentPassword); validateCurrentPassword(user, currentPassword);
} }
UserAuthenticator userAuthenticator = _userPasswordEncoders.get(0); UserAuthenticator userAuthenticator = _userPasswordEncoders.get(0);

View File

@ -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);
}

View File

@ -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;
}
}
}

View File

@ -56,6 +56,7 @@
</bean> </bean>
<bean id="passwordPolicies" class="com.cloud.user.PasswordPolicyImpl" /> <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"> <bean id="managementServerImpl" class="com.cloud.server.ManagementServerImpl">
<property name="lockControllerListener" ref="lockControllerListener" /> <property name="lockControllerListener" ref="lockControllerListener" />

View File

@ -16,23 +16,53 @@
// under the License. // under the License.
package com.cloud.api; package com.cloud.api;
import java.util.ArrayList; import com.cloud.domain.Domain;
import java.util.List; 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.Assert;
import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedConstruction; import org.mockito.MockedConstruction;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner; 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) @RunWith(MockitoJUnitRunner.class)
public class ApiServerTest { public class ApiServerTest {
@InjectMocks @InjectMocks
ApiServer apiServer = new ApiServer(); 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) { private void runTestSetupIntegrationPortListenerInvalidPorts(Integer port) {
try (MockedConstruction<ApiServer.ListenerThread> mocked = try (MockedConstruction<ApiServer.ListenerThread> mocked =
Mockito.mockConstruction(ApiServer.ListenerThread.class)) { Mockito.mockConstruction(ApiServer.ListenerThread.class)) {
@ -61,4 +91,60 @@ public class ApiServerTest {
Mockito.verify(listenerThread).start(); 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("test@test.com");
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("test@test.com");
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("test@test.com");
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("test@test.com");
Mockito.when(userAccount.getState()).thenReturn("ENABLED");
Mockito.when(userAccount.getAccountState()).thenReturn("ENABLED");
Mockito.when(domain.getState()).thenReturn(Domain.State.Inactive);
apiServer.forgotPassword(userAccount, domain);
}
} }

View File

@ -405,7 +405,7 @@ public class AccountManagerImplTest extends AccountManagetImplTestBase {
Mockito.doNothing().when(accountManagerImpl).validateAndUpdateFirstNameIfNeeded(UpdateUserCmdMock, userVoMock); Mockito.doNothing().when(accountManagerImpl).validateAndUpdateFirstNameIfNeeded(UpdateUserCmdMock, userVoMock);
Mockito.doNothing().when(accountManagerImpl).validateAndUpdateLastNameIfNeeded(UpdateUserCmdMock, userVoMock); Mockito.doNothing().when(accountManagerImpl).validateAndUpdateLastNameIfNeeded(UpdateUserCmdMock, userVoMock);
Mockito.doNothing().when(accountManagerImpl).validateAndUpdateUsernameIfNeeded(UpdateUserCmdMock, userVoMock, accountMock); 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(true).when(userDaoMock).update(Mockito.anyLong(), Mockito.eq(userVoMock));
Mockito.doReturn(Mockito.mock(UserAccountVO.class)).when(userAccountDaoMock).findById(Mockito.anyLong()); 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).validateAndUpdateFirstNameIfNeeded(UpdateUserCmdMock, userVoMock);
inOrder.verify(accountManagerImpl).validateAndUpdateLastNameIfNeeded(UpdateUserCmdMock, userVoMock); inOrder.verify(accountManagerImpl).validateAndUpdateLastNameIfNeeded(UpdateUserCmdMock, userVoMock);
inOrder.verify(accountManagerImpl).validateAndUpdateUsernameIfNeeded(UpdateUserCmdMock, userVoMock, accountMock); 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)).setEmail(Mockito.anyString());
inOrder.verify(userVoMock, Mockito.times(numberOfExpectedCallsForSetEmailAndSetTimeZone)).setTimezone(Mockito.anyString()); inOrder.verify(userVoMock, Mockito.times(numberOfExpectedCallsForSetEmailAndSetTimeZone)).setTimezone(Mockito.anyString());
@ -707,14 +707,14 @@ public class AccountManagerImplTest extends AccountManagetImplTestBase {
@Test @Test
public void valiateUserPasswordAndUpdateIfNeededTestPasswordNull() { public void valiateUserPasswordAndUpdateIfNeededTestPasswordNull() {
accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(null, userVoMock, null); accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(null, userVoMock, null, false);
Mockito.verify(userVoMock, Mockito.times(0)).setPassword(Mockito.anyString()); Mockito.verify(userVoMock, Mockito.times(0)).setPassword(Mockito.anyString());
} }
@Test(expected = InvalidParameterValueException.class) @Test(expected = InvalidParameterValueException.class)
public void valiateUserPasswordAndUpdateIfNeededTestBlankPassword() { public void valiateUserPasswordAndUpdateIfNeededTestBlankPassword() {
accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(" ", userVoMock, null); accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(" ", userVoMock, null, false);
} }
@Test(expected = InvalidParameterValueException.class) @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()); 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) @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()); Mockito.lenient().doNothing().when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(), Mockito.anyString(), Mockito.anyLong());
accountManagerImpl.validateUserPasswordAndUpdateIfNeeded("newPassword", userVoMock, null); accountManagerImpl.validateUserPasswordAndUpdateIfNeeded("newPassword", userVoMock, null, false);
} }
@Test @Test
@ -762,7 +762,7 @@ public class AccountManagerImplTest extends AccountManagetImplTestBase {
Mockito.lenient().doNothing().when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(), Mockito.anyString(), Mockito.anyLong()); 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(accountManagerImpl, Mockito.times(0)).validateCurrentPassword(Mockito.eq(userVoMock), Mockito.anyString());
Mockito.verify(userVoMock, Mockito.times(1)).setPassword(expectedUserPasswordAfterEncoded); 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()); 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(accountManagerImpl, Mockito.times(0)).validateCurrentPassword(Mockito.eq(userVoMock), Mockito.anyString());
Mockito.verify(userVoMock, Mockito.times(1)).setPassword(expectedUserPasswordAfterEncoded); 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()); 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(accountManagerImpl, Mockito.times(1)).validateCurrentPassword(userVoMock, currentPassword);
Mockito.verify(userVoMock, Mockito.times(1)).setPassword(expectedUserPasswordAfterEncoded); 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.doThrow(new InvalidParameterValueException("")).when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(), Mockito.anyString(),
Mockito.anyLong()); Mockito.anyLong());
accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, userVoMock, currentPassword); accountManagerImpl.validateUserPasswordAndUpdateIfNeeded(newPassword, userVoMock, currentPassword, false);
} }
private String configureUserMockAuthenticators(String newPassword) { private String configureUserMockAuthenticators(String newPassword) {

View File

@ -487,4 +487,8 @@ public class MockAccountManagerImpl extends ManagerBase implements Manager, Acco
public List<String> getApiNameList() { public List<String> getApiNameList() {
return null; return null;
} }
@Override
public void validateUserPasswordAndUpdateIfNeeded(String newPassword, UserVO user, String currentPassword, boolean skipCurrentPassValidation) {
}
} }

View File

@ -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));
}
}

View File

@ -282,12 +282,14 @@ known_categories = {
'Webhook': 'Webhook', 'Webhook': 'Webhook',
'Webhooks': 'Webhook', 'Webhooks': 'Webhook',
'purgeExpungedResources': 'Resource', 'purgeExpungedResources': 'Resource',
'forgotPassword': 'Authentication',
'resetPassword': 'Authentication',
'BgpPeer': 'BGP Peer', 'BgpPeer': 'BGP Peer',
'createASNRange': 'AS Number Range', 'createASNRange': 'AS Number Range',
'listASNRange': 'AS Number Range', 'listASNRange': 'AS Number Range',
'deleteASNRange': 'AS Number Range', 'deleteASNRange': 'AS Number Range',
'listASNumbers': 'AS Number', 'listASNumbers': 'AS Number',
'releaseASNumber': 'AS Number' 'releaseASNumber': 'AS Number',
} }

View File

@ -417,6 +417,7 @@
"label.availableprocessors": "Available processor cores", "label.availableprocessors": "Available processor cores",
"label.availablevirtualmachinecount": "Available Instances", "label.availablevirtualmachinecount": "Available Instances",
"label.back": "Back", "label.back": "Back",
"label.back.login": "Back to login",
"label.backup": "Backups", "label.backup": "Backups",
"label.backup.attach.restore": "Restore and attach backup volume", "label.backup.attach.restore": "Restore and attach backup volume",
"label.backup.configure.schedule": "Configure Backup Schedule", "label.backup.configure.schedule": "Configure Backup Schedule",
@ -1002,6 +1003,7 @@
"label.force.reboot": "Force reboot", "label.force.reboot": "Force reboot",
"label.forceencap": "Force UDP encapsulation of ESP packets", "label.forceencap": "Force UDP encapsulation of ESP packets",
"label.forgedtransmits": "Forged transmits", "label.forgedtransmits": "Forged transmits",
"label.forgot.password": "Forgot password?",
"label.format": "Format", "label.format": "Format",
"label.fornsx": "NSX", "label.fornsx": "NSX",
"label.forvpc": "VPC", "label.forvpc": "VPC",
@ -3174,6 +3176,7 @@
"message.failed.to.add": "Failed to add", "message.failed.to.add": "Failed to add",
"message.failed.to.assign.vms": "Failed to assign Instances", "message.failed.to.assign.vms": "Failed to assign Instances",
"message.failed.to.remove": "Failed to remove", "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.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": "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.", "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.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.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.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": "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.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.", "message.please.confirm.remove.ssh.key.pair": "Please confirm that you want to remove this SSH key pair.",

View File

@ -300,6 +300,16 @@ export const constantRouterMap = [
path: 'login', path: 'login',
name: 'login', name: 'login',
component: () => import(/* webpackChunkName: "auth" */ '@/views/auth/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')
} }
] ]
}, },

View File

@ -30,7 +30,7 @@ import { ACCESS_TOKEN, APIS, SERVER_MANAGER, CURRENT_PROJECT } from '@/store/mut
NProgress.configure({ showSpinner: false }) // NProgress Configuration 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) => { router.beforeEach((to, from, next) => {
// start progress bar // start progress bar

View File

@ -51,7 +51,7 @@ const err = (error) => {
}) })
} }
if (response.status === 401) { 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 return
} }
const originalPath = router.currentRoute.value.fullPath const originalPath = router.currentRoute.value.fullPath

View File

@ -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>

View File

@ -152,7 +152,16 @@
@click="handleSubmit" @click="handleSubmit"
>{{ $t('label.login') }}</a-button> >{{ $t('label.login') }}</a-button>
</a-form-item> </a-form-item>
<a-row justify="space-between">
<a-col>
<translation-menu/> <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"> <div class="content" v-if="socialLogin">
<p class="or">or</p> <p class="or">or</p>
</div> </div>
@ -220,7 +229,8 @@ export default {
loginBtn: false, loginBtn: false,
loginType: 0 loginType: 0
}, },
server: '' server: '',
forgotPasswordEnabled: false
} }
}, },
created () { 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 // handler
async handleUsernameOrEmail (rule, value) { async handleUsernameOrEmail (rule, value) {

View File

@ -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>

View File

@ -48,16 +48,16 @@ public class SMTPMailSender {
protected Session session = null; protected Session session = null;
protected SMTPSessionProperties sessionProps; protected SMTPSessionProperties sessionProps;
protected static final String CONFIG_HOST = "host"; public static final String CONFIG_HOST = "host";
protected static final String CONFIG_PORT = "port"; public static final String CONFIG_PORT = "port";
protected static final String CONFIG_USE_AUTH = "useAuth"; public static final String CONFIG_USE_AUTH = "useAuth";
protected static final String CONFIG_USERNAME = "username"; public static final String CONFIG_USERNAME = "username";
protected static final String CONFIG_PASSWORD = "password"; public static final String CONFIG_PASSWORD = "password";
protected static final String CONFIG_DEBUG_MODE = "debug"; public static final String CONFIG_DEBUG_MODE = "debug";
protected static final String CONFIG_USE_STARTTLS = "useStartTLS"; public static final String CONFIG_USE_STARTTLS = "useStartTLS";
protected static final String CONFIG_ENABLED_SECURITY_PROTOCOLS = "enabledSecurityProtocols"; public static final String CONFIG_ENABLED_SECURITY_PROTOCOLS = "enabledSecurityProtocols";
protected static final String CONFIG_TIMEOUT = "timeout"; public static final String CONFIG_TIMEOUT = "timeout";
protected static final String CONFIG_CONNECTION_TIMEOUT = "connectiontimeout"; public static final String CONFIG_CONNECTION_TIMEOUT = "connectiontimeout";
protected Map<String, String> configs; protected Map<String, String> configs;
protected String namespace; protected String namespace;