Skip to content

Commit 0655075

Browse files
authored
Feature: Forgot password (#9509)
* Feature: Forgot password * Address comments * fixups * Make forgot password disabled by default * Apply suggestions from code review * Address comments
1 parent 638c152 commit 0655075

File tree

29 files changed

+1726
-41
lines changed

29 files changed

+1726
-41
lines changed

api/src/main/java/org/apache/cloudstack/api/ApiServerService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121

2222
import javax.servlet.http.HttpSession;
2323

24+
import com.cloud.domain.Domain;
2425
import com.cloud.exception.CloudAuthenticationException;
26+
import com.cloud.user.UserAccount;
2527

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

4446
public Class<?> getCmdClass(String cmdName);
47+
48+
boolean forgotPassword(UserAccount userAccount, Domain domain);
49+
50+
boolean resetPassword(UserAccount userAccount, String token, String password);
4551
}

api/src/main/java/org/apache/cloudstack/api/auth/APIAuthenticationType.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,5 @@
1717
package org.apache.cloudstack.api.auth;
1818

1919
public enum APIAuthenticationType {
20-
LOGIN_API, LOGOUT_API, READONLY_API, LOGIN_2FA_API
20+
LOGIN_API, LOGOUT_API, READONLY_API, LOGIN_2FA_API, PASSWORD_RESET
2121
}

engine/schema/src/main/java/com/cloud/user/UserAccountVO.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.cloud.user;
1818

1919
import java.util.Date;
20+
import java.util.HashMap;
2021
import java.util.Map;
2122

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

362363
@Override
363364
public Map<String, String> getDetails() {
365+
if (details == null) {
366+
details = new HashMap<>();
367+
}
364368
return details;
365369
}
366370

engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public class UserDetailVO implements ResourceDetail {
4646
private boolean display = true;
4747

4848
public static final String Setup2FADetail = "2FASetupStatus";
49+
public static final String PasswordResetToken = "PasswordResetToken";
50+
public static final String PasswordResetTokenExpiryDate = "PasswordResetTokenExpiryDate";
4951

5052
public UserDetailVO() {
5153
}

plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,11 @@ public ConfigKey<?>[] getConfigKeys() {
515515
return null;
516516
}
517517

518+
public void validateUserPasswordAndUpdateIfNeeded(String newPassword, UserVO user,
519+
String currentPassword,
520+
boolean skipCurrentPassValidation) {
521+
}
522+
518523
@Override
519524
public void checkApiAccess(Account account, String command) throws PermissionDeniedException {
520525

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@
169169
<cs.kafka-clients.version>2.7.0</cs.kafka-clients.version>
170170
<cs.libvirt-java.version>0.5.3</cs.libvirt-java.version>
171171
<cs.mail.version>1.5.0-b01</cs.mail.version>
172+
<cs.mustache.version>0.9.14</cs.mustache.version>
172173
<cs.mysql.version>8.0.33</cs.mysql.version>
173174
<cs.neethi.version>2.0.4</cs.neethi.version>
174175
<cs.nitro.version>10.1</cs.nitro.version>

server/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@
101101
<artifactId>commons-math3</artifactId>
102102
<version>${cs.commons-math3.version}</version>
103103
</dependency>
104+
<dependency>
105+
<groupId>com.github.spullara.mustache.java</groupId>
106+
<artifactId>compiler</artifactId>
107+
<version>${cs.mustache.version}</version>
108+
</dependency>
104109
<dependency>
105110
<groupId>org.apache.cloudstack</groupId>
106111
<artifactId>cloud-utils</artifactId>

server/src/main/java/com/cloud/api/ApiServer.java

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@
5555
import javax.servlet.http.HttpServletResponse;
5656
import javax.servlet.http.HttpSession;
5757

58+
import com.cloud.user.Account;
59+
import com.cloud.user.AccountManager;
60+
import com.cloud.user.AccountManagerImpl;
61+
import com.cloud.user.DomainManager;
62+
import com.cloud.user.User;
63+
import com.cloud.user.UserAccount;
64+
import com.cloud.user.UserVO;
5865
import org.apache.cloudstack.acl.APIChecker;
5966
import org.apache.cloudstack.api.APICommand;
6067
import org.apache.cloudstack.api.ApiConstants;
@@ -103,7 +110,9 @@
103110
import org.apache.cloudstack.framework.messagebus.MessageDispatcher;
104111
import org.apache.cloudstack.framework.messagebus.MessageHandler;
105112
import org.apache.cloudstack.managed.context.ManagedContextRunnable;
113+
import org.apache.cloudstack.user.UserPasswordResetManager;
106114
import org.apache.commons.codec.binary.Base64;
115+
import org.apache.commons.lang3.EnumUtils;
107116
import org.apache.http.ConnectionClosedException;
108117
import org.apache.http.HttpException;
109118
import org.apache.http.HttpRequest;
@@ -157,13 +166,6 @@
157166
import com.cloud.exception.UnavailableCommandException;
158167
import com.cloud.projects.dao.ProjectDao;
159168
import com.cloud.storage.VolumeApiService;
160-
import com.cloud.user.Account;
161-
import com.cloud.user.AccountManager;
162-
import com.cloud.user.AccountManagerImpl;
163-
import com.cloud.user.DomainManager;
164-
import com.cloud.user.User;
165-
import com.cloud.user.UserAccount;
166-
import com.cloud.user.UserVO;
167169
import com.cloud.utils.ConstantTimeComparator;
168170
import com.cloud.utils.DateUtil;
169171
import com.cloud.utils.HttpUtils;
@@ -182,6 +184,8 @@
182184
import com.cloud.utils.net.NetUtils;
183185
import com.google.gson.reflect.TypeToken;
184186

187+
import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled;
188+
185189
@Component
186190
public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiServerService, Configurable {
187191

@@ -214,6 +218,8 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
214218
private ProjectDao projectDao;
215219
@Inject
216220
private UUIDManager uuidMgr;
221+
@Inject
222+
private UserPasswordResetManager userPasswordResetManager;
217223

218224
private List<PluggableService> pluggableServices;
219225

@@ -1223,6 +1229,57 @@ public boolean verifyUser(final Long userId) {
12231229
return true;
12241230
}
12251231

1232+
@Override
1233+
public boolean forgotPassword(UserAccount userAccount, Domain domain) {
1234+
if (!UserPasswordResetEnabled.value()) {
1235+
String errorMessage = String.format("%s is false. Password reset for the user is not allowed.",
1236+
UserPasswordResetEnabled.key());
1237+
logger.error(errorMessage);
1238+
throw new CloudRuntimeException(errorMessage);
1239+
}
1240+
if (StringUtils.isBlank(userAccount.getEmail())) {
1241+
logger.error(String.format(
1242+
"Email is not set. username: %s account id: %d domain id: %d",
1243+
userAccount.getUsername(), userAccount.getAccountId(), userAccount.getDomainId()));
1244+
throw new CloudRuntimeException("Email is not set for the user.");
1245+
}
1246+
1247+
if (!EnumUtils.getEnumIgnoreCase(Account.State.class, userAccount.getState()).equals(Account.State.ENABLED)) {
1248+
logger.error(String.format(
1249+
"User is not enabled. username: %s account id: %d domain id: %s",
1250+
userAccount.getUsername(), userAccount.getAccountId(), domain.getUuid()));
1251+
throw new CloudRuntimeException("User is not enabled.");
1252+
}
1253+
1254+
if (!EnumUtils.getEnumIgnoreCase(Account.State.class, userAccount.getAccountState()).equals(Account.State.ENABLED)) {
1255+
logger.error(String.format(
1256+
"Account is not enabled. username: %s account id: %d domain id: %s",
1257+
userAccount.getUsername(), userAccount.getAccountId(), domain.getUuid()));
1258+
throw new CloudRuntimeException("Account is not enabled.");
1259+
}
1260+
1261+
if (!domain.getState().equals(Domain.State.Active)) {
1262+
logger.error(String.format(
1263+
"Domain is not active. username: %s account id: %d domain id: %s",
1264+
userAccount.getUsername(), userAccount.getAccountId(), domain.getUuid()));
1265+
throw new CloudRuntimeException("Domain is not active.");
1266+
}
1267+
1268+
userPasswordResetManager.setResetTokenAndSend(userAccount);
1269+
return true;
1270+
}
1271+
1272+
@Override
1273+
public boolean resetPassword(UserAccount userAccount, String token, String password) {
1274+
if (!UserPasswordResetEnabled.value()) {
1275+
String errorMessage = String.format("%s is false. Password reset for the user is not allowed.",
1276+
UserPasswordResetEnabled.key());
1277+
logger.error(errorMessage);
1278+
throw new CloudRuntimeException(errorMessage);
1279+
}
1280+
return userPasswordResetManager.validateAndResetPassword(userAccount, token, password);
1281+
}
1282+
12261283
private void checkCommandAvailable(final User user, final String commandName, final InetAddress remoteAddress) throws PermissionDeniedException {
12271284
if (user == null) {
12281285
throw new PermissionDeniedException("User is null for role based API access check for command" + commandName);

server/src/main/java/com/cloud/api/auth/APIAuthenticationManagerImpl.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
import com.cloud.utils.component.ComponentContext;
3232
import com.cloud.utils.component.ManagerBase;
3333

34+
import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled;
35+
3436
@SuppressWarnings("unchecked")
3537
public class APIAuthenticationManagerImpl extends ManagerBase implements APIAuthenticationManager {
3638

@@ -75,6 +77,10 @@ public List<Class<?>> getCommands() {
7577
List<Class<?>> cmdList = new ArrayList<Class<?>>();
7678
cmdList.add(DefaultLoginAPIAuthenticatorCmd.class);
7779
cmdList.add(DefaultLogoutAPIAuthenticatorCmd.class);
80+
if (UserPasswordResetEnabled.value()) {
81+
cmdList.add(DefaultForgotPasswordAPIAuthenticatorCmd.class);
82+
cmdList.add(DefaultResetPasswordAPIAuthenticatorCmd.class);
83+
}
7884

7985
cmdList.add(ListUserTwoFactorAuthenticatorProvidersCmd.class);
8086
cmdList.add(ValidateUserTwoFactorAuthenticationCodeCmd.class);
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
package com.cloud.api.auth;
18+
19+
import com.cloud.api.ApiServlet;
20+
import com.cloud.api.response.ApiResponseSerializer;
21+
import com.cloud.domain.Domain;
22+
import com.cloud.user.Account;
23+
import com.cloud.user.User;
24+
import com.cloud.user.UserAccount;
25+
import com.cloud.utils.exception.CloudRuntimeException;
26+
import org.apache.cloudstack.api.APICommand;
27+
import org.apache.cloudstack.api.ApiConstants;
28+
import org.apache.cloudstack.api.ApiErrorCode;
29+
import org.apache.cloudstack.api.ApiServerService;
30+
import org.apache.cloudstack.api.BaseCmd;
31+
import org.apache.cloudstack.api.Parameter;
32+
import org.apache.cloudstack.api.ServerApiException;
33+
import org.apache.cloudstack.api.auth.APIAuthenticationType;
34+
import org.apache.cloudstack.api.auth.APIAuthenticator;
35+
import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator;
36+
import org.apache.cloudstack.api.response.SuccessResponse;
37+
import org.jetbrains.annotations.Nullable;
38+
39+
import javax.inject.Inject;
40+
import javax.servlet.http.HttpServletRequest;
41+
import javax.servlet.http.HttpServletResponse;
42+
import javax.servlet.http.HttpSession;
43+
import java.net.InetAddress;
44+
import java.util.List;
45+
import java.util.Map;
46+
47+
@APICommand(name = "forgotPassword",
48+
description = "Sends an email to the user with a token to reset the password using resetPassword command.",
49+
since = "4.20.0.0",
50+
requestHasSensitiveInfo = true,
51+
responseObject = SuccessResponse.class)
52+
public class DefaultForgotPasswordAPIAuthenticatorCmd extends BaseCmd implements APIAuthenticator {
53+
54+
55+
/////////////////////////////////////////////////////
56+
//////////////// API parameters /////////////////////
57+
/////////////////////////////////////////////////////
58+
@Parameter(name = ApiConstants.USERNAME, type = CommandType.STRING, description = "Username", required = true)
59+
private String username;
60+
61+
@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.")
62+
private String domain;
63+
64+
@Inject
65+
ApiServerService _apiServer;
66+
67+
/////////////////////////////////////////////////////
68+
/////////////////// Accessors ///////////////////////
69+
/////////////////////////////////////////////////////
70+
71+
public String getUsername() {
72+
return username;
73+
}
74+
75+
public String getDomainName() {
76+
return domain;
77+
}
78+
79+
80+
/////////////////////////////////////////////////////
81+
/////////////// API Implementation///////////////////
82+
/////////////////////////////////////////////////////
83+
84+
@Override
85+
public long getEntityOwnerId() {
86+
return Account.Type.NORMAL.ordinal();
87+
}
88+
89+
@Override
90+
public void execute() throws ServerApiException {
91+
// We should never reach here
92+
throw new ServerApiException(ApiErrorCode.METHOD_NOT_ALLOWED, "This is an authentication api, cannot be used directly");
93+
}
94+
95+
@Override
96+
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 {
97+
final String[] username = (String[])params.get(ApiConstants.USERNAME);
98+
final String[] domainName = (String[])params.get(ApiConstants.DOMAIN);
99+
100+
Long domainId = null;
101+
String domain = null;
102+
domain = getDomainName(auditTrailSb, domainName, domain);
103+
104+
String serializedResponse = null;
105+
if (username != null) {
106+
try {
107+
final Domain userDomain = _domainService.findDomainByPath(domain);
108+
if (userDomain != null) {
109+
domainId = userDomain.getId();
110+
} else {
111+
throw new ServerApiException(ApiErrorCode.PARAM_ERROR, String.format("Unable to find the domain from the path %s", domain));
112+
}
113+
final UserAccount userAccount = _accountService.getActiveUserAccount(username[0], domainId);
114+
if (userAccount != null && List.of(User.Source.SAML2, User.Source.OAUTH2, User.Source.LDAP).contains(userAccount.getSource())) {
115+
throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Forgot Password is not allowed for this user");
116+
}
117+
boolean success = _apiServer.forgotPassword(userAccount, userDomain);
118+
logger.debug("Forgot password request for user " + username[0] + " in domain " + domain + " is successful: " + success);
119+
} catch (final CloudRuntimeException ex) {
120+
ApiServlet.invalidateHttpSession(session, "fall through to API key,");
121+
String msg = String.format("%s", ex.getMessage() != null ?
122+
ex.getMessage() :
123+
"forgot password request failed for user, check if username/domain are correct");
124+
auditTrailSb.append(" " + ApiErrorCode.ACCOUNT_ERROR + " " + msg);
125+
serializedResponse = _apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), msg, params, responseType);
126+
if (logger.isTraceEnabled()) {
127+
logger.trace(msg);
128+
}
129+
}
130+
SuccessResponse successResponse = new SuccessResponse();
131+
successResponse.setSuccess(true);
132+
successResponse.setResponseName(getCommandName());
133+
return ApiResponseSerializer.toSerializedString(successResponse, responseType);
134+
}
135+
// We should not reach here and if we do we throw an exception
136+
throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, serializedResponse);
137+
}
138+
139+
@Nullable
140+
private String getDomainName(StringBuilder auditTrailSb, String[] domainName, String domain) {
141+
if (domainName != null) {
142+
domain = domainName[0];
143+
auditTrailSb.append(" domain=" + domain);
144+
if (domain != null) {
145+
// ensure domain starts with '/' and ends with '/'
146+
if (!domain.endsWith("/")) {
147+
domain += '/';
148+
}
149+
if (!domain.startsWith("/")) {
150+
domain = "/" + domain;
151+
}
152+
}
153+
}
154+
return domain;
155+
}
156+
157+
@Override
158+
public APIAuthenticationType getAPIType() {
159+
return APIAuthenticationType.PASSWORD_RESET;
160+
}
161+
162+
@Override
163+
public void setAuthenticators(List<PluggableAPIAuthenticator> authenticators) {
164+
}
165+
}

0 commit comments

Comments
 (0)