Allow enforcing password change for a user after reset by admin (root/domain) (#12294)

* API modifications for passwordchangerequired

* ui login flow for passwordchangerequired

* add passwordchangerequired in listUsers API response, it will be used in UI to render reset password form

* cleanup redundant LOGIN_SOURCE and limiting apis for first time login

* address copilot comments

* allow enforcing password change for all role types and update reset pwd flow for passwordchangerequired

* address review comments

* add unit tests

* cleanup ispasswordchangerequired from user_view

* address review comments

* 1. Allow enforcing password change while creating user
2. Admin can enforce password change on next login with out resetting password

* address review comment, add unit test

* improve code coverage

* fix pre-commit license issue

* 1. allow enter key to submit change password form
2. hide force password reset for disabled/locked user in ui

* 1. throw exception when force reset password is done for locked/disabled user/account
2. ui validation on current and new password being same
3. allow enforce change password for add user until saml is not enabled

* allow oauth login to skip force password change
This commit is contained in:
Manoj Kumar 2026-02-16 16:01:42 +05:30 committed by GitHub
parent b1edfb8d60
commit c79b33c1fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1023 additions and 38 deletions

View File

@ -59,7 +59,8 @@ public interface AccountService {
User getSystemUser();
User createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID);
User createUser(String userName, String password, String firstName, String lastName, String email, String timeZone,
String accountName, Long domainId, String userUUID, boolean isPasswordChangeRequired);
User createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID,
User.Source source);

View File

@ -1261,6 +1261,7 @@ public class ApiConstants {
public static final String PROVIDER_FOR_2FA = "providerfor2fa";
public static final String ISSUER_FOR_2FA = "issuerfor2fa";
public static final String MANDATE_2FA = "mandate2fa";
public static final String PASSWORD_CHANGE_REQUIRED = "passwordchangerequired";
public static final String SECRET_CODE = "secretcode";
public static final String LOGIN = "login";
public static final String LOGOUT = "logout";

View File

@ -26,6 +26,7 @@ import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.response.DomainResponse;
import org.apache.cloudstack.api.response.UserResponse;
import org.apache.cloudstack.context.CallContext;
import org.apache.commons.lang.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import com.cloud.user.Account;
@ -78,6 +79,12 @@ public class CreateUserCmd extends BaseCmd {
@Parameter(name = ApiConstants.USER_ID, type = CommandType.STRING, description = "User UUID, required for adding account from external provisioning system")
private String userUUID;
@Parameter(name = ApiConstants.PASSWORD_CHANGE_REQUIRED,
type = CommandType.BOOLEAN,
description = "Provide true to mandate the User to reset password on next login.",
since = "4.23.0")
private Boolean passwordChangeRequired;
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////
@ -118,6 +125,10 @@ public class CreateUserCmd extends BaseCmd {
return userUUID;
}
public Boolean isPasswordChangeRequired() {
return BooleanUtils.isTrue(passwordChangeRequired);
}
/////////////////////////////////////////////////////
/////////////// API Implementation///////////////////
/////////////////////////////////////////////////////
@ -147,7 +158,7 @@ public class CreateUserCmd extends BaseCmd {
CallContext.current().setEventDetails("UserName: " + getUserName() + ", FirstName :" + getFirstName() + ", LastName: " + getLastName());
User user =
_accountService.createUser(getUserName(), getPassword(), getFirstName(), getLastName(), getEmail(), getTimezone(), getAccountName(), getDomainId(),
getUserUUID());
getUserUUID(), isPasswordChangeRequired());
if (user != null) {
UserResponse response = _responseGenerator.createUserResponse(user);
response.setResponseName(getCommandName());

View File

@ -29,6 +29,7 @@ import org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.api.response.UserResponse;
import org.apache.cloudstack.context.CallContext;
import org.apache.cloudstack.region.RegionService;
import org.apache.commons.lang.BooleanUtils;
import com.cloud.user.Account;
import com.cloud.user.User;
@ -38,6 +39,8 @@ import com.cloud.user.UserAccount;
requestHasSensitiveInfo = true, responseHasSensitiveInfo = true)
public class UpdateUserCmd extends BaseCmd {
@Inject
private RegionService _regionService;
/////////////////////////////////////////////////////
//////////////// API parameters /////////////////////
@ -85,8 +88,11 @@ public class UpdateUserCmd extends BaseCmd {
"This parameter is only used to mandate 2FA, not to disable 2FA", since = "4.18.0.0")
private Boolean mandate2FA;
@Inject
private RegionService _regionService;
@Parameter(name = ApiConstants.PASSWORD_CHANGE_REQUIRED,
type = CommandType.BOOLEAN,
description = "Provide true to mandate the User to reset password on next login.",
since = "4.23.0")
private Boolean passwordChangeRequired;
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
@ -193,4 +199,8 @@ public class UpdateUserCmd extends BaseCmd {
public ApiCommandResourceType getApiResourceType() {
return ApiCommandResourceType.User;
}
public Boolean isPasswordChangeRequired() {
return BooleanUtils.isTrue(passwordChangeRequired);
}
}

View File

@ -90,6 +90,10 @@ public class LoginCmdResponse extends AuthenticationCmdResponse {
@Param(description = "Management Server ID that the user logged to", since = "4.21.0.0")
private String managementServerId;
@SerializedName(value = ApiConstants.PASSWORD_CHANGE_REQUIRED)
@Param(description = "Indicates whether the User is required to change password on next login.", since = "4.23.0")
private Boolean passwordChangeRequired;
public String getUsername() {
return username;
}
@ -223,4 +227,12 @@ public class LoginCmdResponse extends AuthenticationCmdResponse {
public void setManagementServerId(String managementServerId) {
this.managementServerId = managementServerId;
}
public Boolean getPasswordChangeRequired() {
return passwordChangeRequired;
}
public void setPasswordChangeRequired(Boolean passwordChangeRequired) {
this.passwordChangeRequired = passwordChangeRequired;
}
}

View File

@ -69,7 +69,7 @@ public class CreateUserCmdTest {
} catch (ServerApiException e) {
Assert.assertTrue("Received exception as the mock accountService createUser returns null user", true);
}
Mockito.verify(accountService, Mockito.times(1)).createUser(null, "Test", null, null, null, null, null, null, null);
Mockito.verify(accountService, Mockito.times(1)).createUser(null, "Test", null, null, null, null, null, null, null, false);
}
@Test
@ -82,7 +82,7 @@ public class CreateUserCmdTest {
Assert.assertEquals(ApiErrorCode.PARAM_ERROR,e.getErrorCode());
Assert.assertEquals("Empty passwords are not allowed", e.getMessage());
}
Mockito.verify(accountService, Mockito.never()).createUser(null, null, null, null, null, null, null, null, null);
Mockito.verify(accountService, Mockito.never()).createUser(null, null, null, null, null, null, null, null, null, false);
}
@Test
@ -95,6 +95,6 @@ public class CreateUserCmdTest {
Assert.assertEquals(ApiErrorCode.PARAM_ERROR,e.getErrorCode());
Assert.assertEquals("Empty passwords are not allowed", e.getMessage());
}
Mockito.verify(accountService, Mockito.never()).createUser(null, null, null, null, null, null, null, null, null);
Mockito.verify(accountService, Mockito.never()).createUser(null, null, null, null, null, null, null, null, null, true);
}
}

View File

@ -0,0 +1,64 @@
/*
* 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.api.command.admin.user;
import org.apache.cloudstack.api.ApiCommandResourceType;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.test.util.ReflectionTestUtils;
@RunWith(MockitoJUnitRunner.class)
public class UpdateUserCmdTest {
@InjectMocks
private UpdateUserCmd cmd;
@Test
public void testGetApiResourceId() {
Long userId = 99L;
cmd.setId(userId);
Assert.assertEquals(userId, cmd.getApiResourceId());
}
@Test
public void testGetApiResourceType() {
Assert.assertEquals(ApiCommandResourceType.User, cmd.getApiResourceType());
}
@Test
public void testIsPasswordChangeRequired_True() {
ReflectionTestUtils.setField(cmd, "passwordChangeRequired", Boolean.TRUE);
Assert.assertTrue(cmd.isPasswordChangeRequired());
}
@Test
public void testIsPasswordChangeRequired_False() {
ReflectionTestUtils.setField(cmd, "passwordChangeRequired", Boolean.FALSE);
Assert.assertFalse(cmd.isPasswordChangeRequired());
}
@Test
public void testIsPasswordChangeRequired_Null() {
ReflectionTestUtils.setField(cmd, "passwordChangeRequired", null);
Assert.assertFalse(cmd.isPasswordChangeRequired());
}
}

View File

@ -0,0 +1,87 @@
// 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.api.response;
import org.junit.Assert;
import org.junit.Test;
public class LoginCmdResponseTest {
@Test
public void testAllGettersAndSetters() {
LoginCmdResponse response = new LoginCmdResponse();
response.setUsername("user1");
response.setUserId("100");
response.setDomainId("200");
response.setTimeout(3600);
response.setAccount("account1");
response.setFirstName("John");
response.setLastName("Doe");
response.setType("admin");
response.setTimeZone("UTC");
response.setTimeZoneOffset("+00:00");
response.setRegistered("true");
response.setSessionKey("session-key");
response.set2FAenabled("true");
response.set2FAverfied("false");
response.setProviderFor2FA("totp");
response.setIssuerFor2FA("cloudstack");
response.setManagementServerId("ms-1");
Assert.assertEquals("user1", response.getUsername());
Assert.assertEquals("100", response.getUserId());
Assert.assertEquals("200", response.getDomainId());
Assert.assertEquals(Integer.valueOf(3600), response.getTimeout());
Assert.assertEquals("account1", response.getAccount());
Assert.assertEquals("John", response.getFirstName());
Assert.assertEquals("Doe", response.getLastName());
Assert.assertEquals("admin", response.getType());
Assert.assertEquals("UTC", response.getTimeZone());
Assert.assertEquals("+00:00", response.getTimeZoneOffset());
Assert.assertEquals("true", response.getRegistered());
Assert.assertEquals("session-key", response.getSessionKey());
Assert.assertEquals("true", response.is2FAenabled());
Assert.assertEquals("false", response.is2FAverfied());
Assert.assertEquals("totp", response.getProviderFor2FA());
Assert.assertEquals("cloudstack", response.getIssuerFor2FA());
Assert.assertEquals("ms-1", response.getManagementServerId());
}
@Test
public void testPasswordChangeRequired_True() {
LoginCmdResponse response = new LoginCmdResponse();
response.setPasswordChangeRequired(true);
Assert.assertTrue(response.getPasswordChangeRequired());
}
@Test
public void testPasswordChangeRequired_False() {
LoginCmdResponse response = new LoginCmdResponse();
response.setPasswordChangeRequired(false);
Assert.assertFalse(response.getPasswordChangeRequired());
}
@Test
public void testPasswordChangeRequired_Null() {
LoginCmdResponse response = new LoginCmdResponse();
response.setPasswordChangeRequired(null);
Assert.assertNull("Boolean.parseBoolean(null) should return null", response.getPasswordChangeRequired());
}
}

View File

@ -48,6 +48,8 @@ public class UserDetailVO implements ResourceDetail {
public static final String Setup2FADetail = "2FASetupStatus";
public static final String PasswordResetToken = "PasswordResetToken";
public static final String PasswordResetTokenExpiryDate = "PasswordResetTokenExpiryDate";
public static final String PasswordChangeRequired = "PasswordChangeRequired";
public static final String OauthLogin = "OauthLogin";
public UserDetailVO() {
}

View File

@ -44,8 +44,10 @@ import org.apache.cloudstack.api.response.ApiDiscoveryResponse;
import org.apache.cloudstack.api.response.ApiParameterResponse;
import org.apache.cloudstack.api.response.ApiResponseResponse;
import org.apache.cloudstack.api.response.ListResponse;
import org.apache.cloudstack.resourcedetail.UserDetailVO;
import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.collections.MapUtils;
import org.apache.commons.lang3.StringUtils;
import org.reflections.ReflectionUtils;
import org.springframework.stereotype.Component;
@ -56,6 +58,7 @@ import com.cloud.serializer.Param;
import com.cloud.user.Account;
import com.cloud.user.AccountService;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.utils.ReflectUtil;
import com.cloud.utils.component.ComponentLifecycleBase;
import com.cloud.utils.component.PluggableService;
@ -67,6 +70,7 @@ public class ApiDiscoveryServiceImpl extends ComponentLifecycleBase implements A
List<APIChecker> _apiAccessCheckers = null;
List<PluggableService> _services = null;
protected static Map<String, ApiDiscoveryResponse> s_apiNameDiscoveryResponseMap = null;
public static final List<String> APIS_ALLOWED_FOR_PASSWORD_CHANGE = Arrays.asList("login", "logout", "updateUser", "listApis");
@Inject
AccountService accountService;
@ -287,12 +291,20 @@ public class ApiDiscoveryServiceImpl extends ComponentLifecycleBase implements A
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(account, "accountName", "uuid")));
}
if (role.getRoleType() == RoleType.Admin && role.getId() == RoleType.Admin.getId()) {
logger.info(String.format("Account [%s] is Root Admin, all APIs are allowed.",
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(account, "accountName", "uuid")));
// Limit APIs on first login requiring password change
UserAccount userAccount = accountService.getUserAccountById(user.getId());
Map<String, String> userAccDetails = userAccount.getDetails();
if (MapUtils.isNotEmpty(userAccDetails) && !userAccDetails.containsKey(UserDetailVO.OauthLogin) &&
"true".equalsIgnoreCase(userAccDetails.get(UserDetailVO.PasswordChangeRequired))) {
apisAllowed = APIS_ALLOWED_FOR_PASSWORD_CHANGE;
} else {
for (APIChecker apiChecker : _apiAccessCheckers) {
apisAllowed = apiChecker.getApisAllowedToUser(role, user, apisAllowed);
if (role.getRoleType() == RoleType.Admin && role.getId() == RoleType.Admin.getId()) {
logger.info(String.format("Account [%s] is Root Admin, all APIs are allowed.",
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(account, "accountName", "uuid")));
} else {
for (APIChecker apiChecker : _apiAccessCheckers) {
apisAllowed = apiChecker.getApisAllowedToUser(role, user, apisAllowed);
}
}
}

View File

@ -21,6 +21,8 @@ import com.cloud.user.Account;
import com.cloud.user.AccountService;
import com.cloud.user.AccountVO;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.user.UserAccountVO;
import com.cloud.user.UserVO;
import org.apache.cloudstack.acl.APIChecker;
@ -29,6 +31,8 @@ import org.apache.cloudstack.acl.RoleService;
import org.apache.cloudstack.acl.RoleType;
import org.apache.cloudstack.acl.RoleVO;
import org.apache.cloudstack.api.response.ApiDiscoveryResponse;
import org.apache.cloudstack.api.response.ListResponse;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@ -39,11 +43,15 @@ import org.mockito.Spy;
import org.mockito.junit.MockitoJUnitRunner;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordChangeRequired;
import static org.apache.cloudstack.resourcedetail.UserDetailVO.Setup2FADetail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
@RunWith(MockitoJUnitRunner.class)
public class ApiDiscoveryTest {
@ -66,12 +74,17 @@ public class ApiDiscoveryTest {
@InjectMocks
ApiDiscoveryServiceImpl discoveryServiceSpy;
@Mock
UserAccount mockUserAccount;
@Before
public void setup() {
discoveryServiceSpy.s_apiNameDiscoveryResponseMap = apiNameDiscoveryResponseMapMock;
discoveryServiceSpy._apiAccessCheckers = apiAccessCheckersMock;
Mockito.when(discoveryServiceSpy._apiAccessCheckers.iterator()).thenReturn(Arrays.asList(apiCheckerMock).iterator());
Mockito.when(mockUserAccount.getDetails()).thenReturn(null);
Mockito.when(accountServiceMock.getUserAccountById(anyLong())).thenReturn(mockUserAccount);
}
private User getTestUser() {
@ -131,4 +144,29 @@ public class ApiDiscoveryTest {
Mockito.verify(apiCheckerMock, Mockito.times(1)).getApisAllowedToUser(any(Role.class), any(User.class), anyList());
}
@Test
public void listApisForUserWithoutEnforcedPwdChange() throws PermissionDeniedException {
RoleVO userRoleVO = new RoleVO(4L, "name", RoleType.User, "description");
Map<String, String> userDetails = new HashMap<>();
userDetails.put(Setup2FADetail, UserAccountVO.Setup2FAstatus.ENABLED.name());
Mockito.when(mockUserAccount.getDetails()).thenReturn(userDetails);
Mockito.when(accountServiceMock.getAccount(Mockito.anyLong())).thenReturn(getNormalAccount());
Mockito.when(roleServiceMock.findRole(Mockito.anyLong())).thenReturn(userRoleVO);
discoveryServiceSpy.listApis(getTestUser(), null);
Mockito.verify(apiCheckerMock, Mockito.times(1)).getApisAllowedToUser(any(Role.class), any(User.class), anyList());
}
@Test
public void listApisForUserEnforcedPwdChange() throws PermissionDeniedException {
RoleVO userRoleVO = new RoleVO(4L, "name", RoleType.User, "description");
Map<String, String> userDetails = new HashMap<>();
userDetails.put(PasswordChangeRequired, "true");
Mockito.when(mockUserAccount.getDetails()).thenReturn(userDetails);
Mockito.when(accountServiceMock.getAccount(Mockito.anyLong())).thenReturn(getNormalAccount());
Mockito.when(roleServiceMock.findRole(Mockito.anyLong())).thenReturn(userRoleVO);
Mockito.when(apiNameDiscoveryResponseMapMock.get(Mockito.anyString())).thenReturn(Mockito.mock(ApiDiscoveryResponse.class));
ListResponse<ApiDiscoveryResponse> response = (ListResponse<ApiDiscoveryResponse>) discoveryServiceSpy.listApis(getTestUser(), null);
Assert.assertEquals(4, response.getResponses().size());
}
}

View File

@ -131,7 +131,7 @@ public class MockAccountManager extends ManagerBase implements AccountManager {
}
@Override
public User createUser(String arg0, String arg1, String arg2, String arg3, String arg4, String arg5, String arg6, Long arg7, String arg8) {
public User createUser(String arg0, String arg1, String arg2, String arg3, String arg4, String arg5, String arg6, Long arg7, String arg8, boolean arg9) {
// TODO Auto-generated method stub
return null;
}

View File

@ -34,6 +34,8 @@ 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.LoginCmdResponse;
import org.apache.cloudstack.resourcedetail.UserDetailVO;
import org.apache.cloudstack.resourcedetail.dao.UserDetailsDao;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.Nullable;
@ -74,6 +76,9 @@ public class OauthLoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthent
@Inject
ApiServerService _apiServer;
@Inject
UserDetailsDao userDetailsDao;
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////
@ -157,8 +162,10 @@ public class OauthLoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthent
if (userAccount != null && User.Source.SAML2 == userAccount.getSource()) {
throw new CloudAuthenticationException("User is not allowed CloudStack login");
}
return ApiResponseSerializer.toSerializedString(_apiServer.loginUser(session, userAccount.getUsername(), null, domainId, domain, remoteAddress, params),
serializedResponse = ApiResponseSerializer.toSerializedString(_apiServer.loginUser(session, userAccount.getUsername(), null, domainId, domain, remoteAddress, params),
responseType);
userDetailsDao.addDetail(userAccount.getId(), UserDetailVO.OauthLogin, "true", false);
return serializedResponse;
} catch (final CloudAuthenticationException ex) {
ApiServlet.invalidateHttpSession(session, "fall through to API key,");
String msg = String.format("%s", ex.getMessage() != null ?

View File

@ -116,9 +116,11 @@ import org.apache.cloudstack.framework.messagebus.MessageBus;
import org.apache.cloudstack.framework.messagebus.MessageDispatcher;
import org.apache.cloudstack.framework.messagebus.MessageHandler;
import org.apache.cloudstack.managed.context.ManagedContextRunnable;
import org.apache.cloudstack.resourcedetail.UserDetailVO;
import org.apache.cloudstack.user.UserPasswordResetManager;
import org.apache.cloudstack.utils.identity.ManagementServerNode;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.collections.MapUtils;
import org.apache.commons.lang3.EnumUtils;
import org.apache.http.ConnectionClosedException;
import org.apache.http.HttpException;
@ -194,6 +196,7 @@ import com.cloud.utils.net.NetUtils;
import com.google.gson.reflect.TypeToken;
import static com.cloud.user.AccountManagerImpl.apiKeyAccess;
import static org.apache.cloudstack.api.ApiConstants.PASSWORD_CHANGE_REQUIRED;
import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled;
@Component
@ -1227,6 +1230,9 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
if (ApiConstants.MANAGEMENT_SERVER_ID.equalsIgnoreCase(attrName)) {
response.setManagementServerId(attrObj.toString());
}
if (PASSWORD_CHANGE_REQUIRED.equalsIgnoreCase(attrName) && attrObj instanceof Boolean) {
response.setPasswordChangeRequired((Boolean) attrObj);
}
}
}
response.setResponseName("loginresponse");
@ -1327,6 +1333,13 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer
final String sessionKey = Base64.encodeBase64URLSafeString(sessionKeyBytes);
session.setAttribute(ApiConstants.SESSIONKEY, sessionKey);
Map<String, String> userAccDetails = userAcct.getDetails();
if (MapUtils.isNotEmpty(userAccDetails)) {
String needPwdChangeStr = userAccDetails.get(UserDetailVO.PasswordChangeRequired);
if ("true".equalsIgnoreCase(needPwdChangeStr)) {
session.setAttribute(PASSWORD_CHANGE_REQUIRED, true);
}
}
return createLoginResponse(session);
}
throw new CloudAuthenticationException("Failed to authenticate user " + username + " in domain " + domainId + "; please provide valid credentials");

View File

@ -34,6 +34,8 @@ 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.LoginCmdResponse;
import org.apache.cloudstack.resourcedetail.UserDetailVO;
import org.apache.cloudstack.resourcedetail.dao.UserDetailsDao;
import org.jetbrains.annotations.Nullable;
import javax.inject.Inject;
@ -66,6 +68,9 @@ public class DefaultLoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthe
@Inject
ApiServerService _apiServer;
@Inject
UserDetailsDao userDetailsDao;
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////
@ -151,8 +156,10 @@ public class DefaultLoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthe
if (userAccount != null && User.Source.SAML2 == userAccount.getSource()) {
throw new CloudAuthenticationException("User is not allowed CloudStack login");
}
return ApiResponseSerializer.toSerializedString(_apiServer.loginUser(session, username[0], pwd, domainId, domain, remoteAddress, params),
serializedResponse = ApiResponseSerializer.toSerializedString(_apiServer.loginUser(session, username[0], pwd, domainId, domain, remoteAddress, params),
responseType);
userDetailsDao.removeDetail(userAccount.getId(), UserDetailVO.OauthLogin);
return serializedResponse;
} catch (final CloudAuthenticationException ex) {
ApiServlet.invalidateHttpSession(session, "fall through to API key,");
// TODO: fall through to API key, or just fail here w/ auth error? (HTTP 401)

View File

@ -16,6 +16,8 @@
// under the License.
package com.cloud.user;
import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordChangeRequired;
import java.net.InetAddress;
import java.net.URLEncoder;
import java.security.NoSuchAlgorithmException;
@ -1509,12 +1511,24 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M
@Override
@ActionEvent(eventType = EventTypes.EVENT_USER_CREATE, eventDescription = "creating User")
public UserVO createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID,
User.Source source) {
User.Source source) {
return createUser(userName, password, firstName, lastName, email, timeZone, accountName, domainId, userUUID, source, false);
}
@ActionEvent(eventType = EventTypes.EVENT_USER_CREATE, eventDescription = "creating User")
public UserVO createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID,
User.Source source, boolean isPasswordChangeRequired) {
// default domain to ROOT if not specified
if (domainId == null) {
domainId = Domain.ROOT_DOMAIN;
}
if (isPasswordChangeRequired && (source == User.Source.SAML2 || source == User.Source.SAML2DISABLED || source == User.Source.LDAP)) {
logger.warn("Enforcing password change is not permitted for source [{}].", source);
throw new InvalidParameterValueException("CloudStack does not support enforcing password change for SAML or LDAP users.");
}
Domain domain = _domainMgr.getDomain(domainId);
if (domain == null) {
throw new CloudRuntimeException("The domain " + domainId + " does not exist; unable to create user");
@ -1545,14 +1559,21 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M
verifyCallerPrivilegeForUserOrAccountOperations(account);
UserVO user;
user = createUser(account.getId(), userName, password, firstName, lastName, email, timeZone, userUUID, source);
if (isPasswordChangeRequired) {
long callerAccountId = CallContext.current().getCallingAccountId();
if ((isRootAdmin(callerAccountId) || isDomainAdmin(callerAccountId))) {
_userDetailsDao.addDetail(user.getId(), PasswordChangeRequired, "true", false);
}
}
return user;
}
@Override
@ActionEvent(eventType = EventTypes.EVENT_USER_CREATE, eventDescription = "creating User")
public UserVO createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID) {
public UserVO createUser(String userName, String password, String firstName, String lastName, String email,
String timeZone, String accountName, Long domainId, String userUUID, boolean isPasswordChangeRequired) {
return createUser(userName, password, firstName, lastName, email, timeZone, accountName, domainId, userUUID, User.Source.UNKNOWN);
return createUser(userName, password, firstName, lastName, email, timeZone, accountName, domainId, userUUID, User.Source.UNKNOWN, isPasswordChangeRequired);
}
@Override
@ -1586,10 +1607,41 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M
if (mandate2FA != null && mandate2FA) {
user.setUser2faEnabled(true);
}
validateAndUpdatePasswordChangeRequired(caller, updateUserCmd, user, account);
_userDao.update(user.getId(), user);
return _userAccountDao.findById(user.getId());
}
private void validateAndUpdatePasswordChangeRequired(User caller, UpdateUserCmd updateUserCmd, UserVO user, Account account) {
if (updateUserCmd.isPasswordChangeRequired()) {
if (user.getState() != State.ENABLED || account.getState() != State.ENABLED) {
throw new CloudRuntimeException("CloudStack does not support enforcing password change for locked/disabled User or Account.");
}
User.Source userSource = user.getSource();
if (userSource == User.Source.SAML2 || userSource == User.Source.SAML2DISABLED || userSource == User.Source.LDAP) {
logger.warn("Enforcing password change is not permitted for source [{}].", user.getSource());
throw new InvalidParameterValueException("CloudStack does not support enforcing password change for SAML or LDAP users.");
}
}
boolean isCallerSameAsUser = user.getId() == caller.getId();
boolean isPasswordResetRequired = updateUserCmd.isPasswordChangeRequired() && !isCallerSameAsUser;
// Admins only can enforce passwordChangeRequired for user
if (isRootAdmin(caller.getAccountId()) || isDomainAdmin(caller.getAccountId())) {
if (isPasswordResetRequired) {
_userDetailsDao.addDetail(user.getId(), PasswordChangeRequired, "true", false);
}
}
if (StringUtils.isNotBlank(updateUserCmd.getPassword())) {
// Remove passwordChangeRequired if user updating own pwd or admin has not enforced it
if (isCallerSameAsUser || !isPasswordResetRequired) {
_userDetailsDao.removeDetail(user.getId(), PasswordChangeRequired);
}
}
}
@Override
public void verifyCallerPrivilegeForUserOrAccountOperations(Account userAccount) {
logger.debug(String.format("Verifying whether the caller has the correct privileges based on the user's role type and API permissions: %s", userAccount));
@ -2848,6 +2900,8 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M
logger.debug(String.format("User: %s in domain %d has successfully logged in, auth time duration - %d ms", username, domainId, validUserLastAuthTimeDurationInMs));
}
user.setDetails(_userDetailsDao.listDetailsKeyPairs(user.getId()));
return user;
} else {
if (logger.isDebugEnabled()) {

View File

@ -50,6 +50,7 @@ import java.util.Set;
import java.util.UUID;
import static org.apache.cloudstack.config.ApiServiceConfiguration.ManagementServerAddresses;
import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordChangeRequired;
import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetToken;
import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetTokenExpiryDate;
@ -265,6 +266,8 @@ public class UserPasswordResetManagerImpl extends ManagerBase implements UserPas
userDetailsDao.removeDetail(userAccount.getId(), PasswordResetToken);
userDetailsDao.removeDetail(userAccount.getId(), PasswordResetTokenExpiryDate);
// remove password change required if user reset password
userDetailsDao.removeDetail(userAccount.getId(), PasswordChangeRequired);
userDao.persist(user);
}

View File

@ -17,11 +17,21 @@
package com.cloud.api;
import com.cloud.domain.Domain;
import com.cloud.domain.DomainVO;
import com.cloud.exception.CloudAuthenticationException;
import com.cloud.user.Account;
import com.cloud.user.AccountManager;
import com.cloud.user.DomainManager;
import com.cloud.user.User;
import com.cloud.user.UserAccount;
import com.cloud.user.UserVO;
import com.cloud.utils.exception.CloudRuntimeException;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.ResponseObject;
import org.apache.cloudstack.api.response.LoginCmdResponse;
import org.apache.cloudstack.framework.config.ConfigKey;
import org.apache.cloudstack.resourcedetail.UserDetailVO;
import org.apache.cloudstack.user.UserPasswordResetManager;
import org.junit.AfterClass;
import org.junit.Assert;
@ -35,10 +45,22 @@ import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.apache.cloudstack.api.ApiConstants.PASSWORD_CHANGE_REQUIRED;
import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import javax.servlet.http.HttpSession;
@RunWith(MockitoJUnitRunner.class)
public class ApiServerTest {
@ -49,6 +71,15 @@ public class ApiServerTest {
@Mock
UserPasswordResetManager userPasswordResetManager;
@Mock
DomainManager domainManager;
@Mock
AccountManager accountManager;
@Mock
HttpSession session;
@BeforeClass
public static void beforeClass() throws Exception {
overrideDefaultConfigValue(UserPasswordResetEnabled, "_value", true);
@ -96,8 +127,8 @@ public class ApiServerTest {
@Test
public void testForgotPasswordSuccess() {
UserAccount userAccount = Mockito.mock(UserAccount.class);
Domain domain = Mockito.mock(Domain.class);
UserAccount userAccount = mock(UserAccount.class);
Domain domain = mock(Domain.class);
Mockito.when(userAccount.getEmail()).thenReturn("test@test.com");
Mockito.when(userAccount.getState()).thenReturn("ENABLED");
@ -110,8 +141,8 @@ public class ApiServerTest {
@Test(expected = CloudRuntimeException.class)
public void testForgotPasswordFailureNoEmail() {
UserAccount userAccount = Mockito.mock(UserAccount.class);
Domain domain = Mockito.mock(Domain.class);
UserAccount userAccount = mock(UserAccount.class);
Domain domain = mock(Domain.class);
Mockito.when(userAccount.getEmail()).thenReturn("");
apiServer.forgotPassword(userAccount, domain);
@ -119,8 +150,8 @@ public class ApiServerTest {
@Test(expected = CloudRuntimeException.class)
public void testForgotPasswordFailureDisabledUser() {
UserAccount userAccount = Mockito.mock(UserAccount.class);
Domain domain = Mockito.mock(Domain.class);
UserAccount userAccount = mock(UserAccount.class);
Domain domain = mock(Domain.class);
Mockito.when(userAccount.getEmail()).thenReturn("test@test.com");
Mockito.when(userAccount.getState()).thenReturn("DISABLED");
@ -129,8 +160,8 @@ public class ApiServerTest {
@Test(expected = CloudRuntimeException.class)
public void testForgotPasswordFailureDisabledAccount() {
UserAccount userAccount = Mockito.mock(UserAccount.class);
Domain domain = Mockito.mock(Domain.class);
UserAccount userAccount = mock(UserAccount.class);
Domain domain = mock(Domain.class);
Mockito.when(userAccount.getEmail()).thenReturn("test@test.com");
Mockito.when(userAccount.getState()).thenReturn("ENABLED");
@ -140,8 +171,8 @@ public class ApiServerTest {
@Test(expected = CloudRuntimeException.class)
public void testForgotPasswordFailureInactiveDomain() {
UserAccount userAccount = Mockito.mock(UserAccount.class);
Domain domain = Mockito.mock(Domain.class);
UserAccount userAccount = mock(UserAccount.class);
Domain domain = mock(Domain.class);
Mockito.when(userAccount.getEmail()).thenReturn("test@test.com");
Mockito.when(userAccount.getState()).thenReturn("ENABLED");
@ -153,8 +184,8 @@ public class ApiServerTest {
@Test
public void testVerifyApiKeyAccessAllowed() {
Long domainId = 1L;
User user = Mockito.mock(User.class);
Account account = Mockito.mock(Account.class);
User user = mock(User.class);
Account account = mock(Account.class);
Mockito.when(user.getApiKeyAccess()).thenReturn(true);
Assert.assertEquals(true, apiServer.verifyApiKeyAccessAllowed(user, account));
@ -176,4 +207,73 @@ public class ApiServerTest {
Mockito.when(account.getApiKeyAccess()).thenReturn(null);
Assert.assertEquals(true, apiServer.verifyApiKeyAccessAllowed(user, account));
}
@Test
public void testLoginUserSuccess() throws Exception {
String username = "user";
String password = "password";
Long domainId = 1L;
String domainPath = "/";
InetAddress loginIp = InetAddress.getByName("127.0.0.1");
Map<String, Object[]> requestParams = new HashMap<>();
DomainVO domain = mock(DomainVO.class);
Mockito.when(domain.getId()).thenReturn(domainId);
Mockito.when(domain.getUuid()).thenReturn("domain-uuid");
Mockito.when(domainManager.findDomainByIdOrPath(domainId, domainPath)).thenReturn(domain);
Mockito.when(domainManager.getDomain(domainId)).thenReturn(domain);
UserAccount userAccount = mock(UserAccount.class);
Mockito.when(userAccount.getId()).thenReturn(100L);
Mockito.when(userAccount.getAccountId()).thenReturn(200L);
Mockito.when(userAccount.getUsername()).thenReturn(username);
Mockito.when(userAccount.getFirstname()).thenReturn("First");
Mockito.when(userAccount.getLastname()).thenReturn("Last");
Mockito.when(userAccount.getTimezone()).thenReturn("UTC");
Mockito.when(userAccount.getRegistrationToken()).thenReturn("token");
Mockito.when(userAccount.isRegistered()).thenReturn(true);
Mockito.when(userAccount.getDomainId()).thenReturn(domainId);
Map<String, String> userAccDetails = new HashMap<>();
userAccDetails.put(UserDetailVO.PasswordChangeRequired, "true");
Mockito.when(userAccount.getDetails()).thenReturn(userAccDetails);
Mockito.when(accountManager.authenticateUser(username, password, domainId, loginIp, requestParams)).thenReturn(userAccount);
Mockito.when(accountManager.clearUserTwoFactorAuthenticationInSetupStateOnLogin(userAccount)).thenReturn(userAccount);
Account account = mock(Account.class);
Mockito.when(account.getAccountName()).thenReturn("account");
Mockito.when(account.getDomainId()).thenReturn(domainId);
Mockito.when(account.getType()).thenReturn(Account.Type.NORMAL);
Mockito.when(account.getType()).thenReturn(Account.Type.NORMAL);
Mockito.when(accountManager.getAccount(200L)).thenReturn(account);
UserVO userVO = mock(UserVO.class);
Mockito.when(userVO.getUuid()).thenReturn("user-uuid");
Mockito.when(accountManager.getActiveUser(100L)).thenReturn(userVO);
Mockito.when(session.getAttributeNames()).thenReturn(Collections.enumeration(List.of(PASSWORD_CHANGE_REQUIRED)));
Mockito.when(session.getAttribute(PASSWORD_CHANGE_REQUIRED)).thenReturn(Boolean.TRUE);
ResponseObject response = apiServer.loginUser(session, username, password, domainId, domainPath, loginIp, requestParams);
Assert.assertNotNull(response);
Assert.assertTrue(response instanceof LoginCmdResponse);
Mockito.verify(session).setAttribute(eq("userid"), eq(100L));
Mockito.verify(session).setAttribute(eq(ApiConstants.SESSIONKEY), anyString());
}
@Test(expected = CloudAuthenticationException.class)
public void testLoginUserDomainNotFound() throws Exception {
Mockito.when(domainManager.findDomainByIdOrPath(anyLong(), anyString())).thenReturn(null);
apiServer.loginUser(session, "user", "pass", 1L, "/", null, null);
}
@Test(expected = CloudAuthenticationException.class)
public void testLoginUserAuthFailed() throws Exception {
DomainVO domain = mock(DomainVO.class);
Mockito.when(domain.getId()).thenReturn(1L);
Mockito.when(domainManager.findDomainByIdOrPath(anyLong(), anyString())).thenReturn(domain);
Mockito.when(accountManager.authenticateUser(anyString(), anyString(), anyLong(), any(), any())).thenReturn(null);
apiServer.loginUser(session, "user", "pass", 1L, "/", null, null);
}
}

View File

@ -23,6 +23,7 @@ import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -432,6 +433,44 @@ public class AccountManagerImplTest extends AccountManagetImplTestBase {
prepareMockAndExecuteUpdateUserTest(1);
}
@Test(expected = CloudRuntimeException.class)
public void updateUserTestPwdChangeDisabledUser() {
Mockito.when(userVoMock.getState()).thenReturn(State.DISABLED);
updateUserPwdChange();
}
@Test(expected = CloudRuntimeException.class)
public void updateUserTestPwdChangeLockedUser() {
Mockito.when(userVoMock.getState()).thenReturn(State.LOCKED);
updateUserPwdChange();
}
@Test(expected = CloudRuntimeException.class)
public void updateUserTestPwdChangeDisabledAccount() {
Mockito.when(userVoMock.getState()).thenReturn(State.ENABLED);
Mockito.when(accountMock.getState()).thenReturn(State.LOCKED);
updateUserPwdChange();
}
@Test
public void testUpdateUserTestPwdChange() {
Mockito.when(userVoMock.getState()).thenReturn(State.ENABLED);
Mockito.when(accountMock.getState()).thenReturn(State.ENABLED);
updateUserPwdChange();
}
private void updateUserPwdChange() {
Mockito.doReturn(true).when(UpdateUserCmdMock).isPasswordChangeRequired();
Mockito.when(userVoMock.getAccountId()).thenReturn(10L);
Mockito.doReturn(accountMock).when(accountManagerImpl).getAccount(10L);
Mockito.when(accountMock.getAccountId()).thenReturn(10L);
Mockito.doReturn(false).when(accountManagerImpl).isRootAdmin(10L);
Mockito.lenient().when(accountManagerImpl.getRoleType(Mockito.eq(accountMock))).thenReturn(RoleType.User);
Mockito.when(callingUser.getAccountId()).thenReturn(1L);
Mockito.doReturn(true).when(accountManagerImpl).isRootAdmin(1L);
prepareMockAndExecuteUpdateUserTest(0);
}
private void prepareMockAndExecuteUpdateUserTest(int numberOfExpectedCallsForSetEmailAndSetTimeZone) {
Mockito.doReturn("password").when(UpdateUserCmdMock).getPassword();
Mockito.doReturn("newpassword").when(UpdateUserCmdMock).getCurrentPassword();
@ -1592,4 +1631,104 @@ public class AccountManagerImplTest extends AccountManagetImplTestBase {
accountManagerImpl.checkCallerApiPermissionsForUserOrAccountOperations(accountMock);
}
@Test(expected = InvalidParameterValueException.class)
public void testPasswordChangeRequiredWithSamlThrowsException() {
accountManagerImpl.createUser(
"user", "pass", "fn", "ln", "e@mail.com",
"UTC", "acct", 1L, null,
User.Source.SAML2, true
);
}
@Test(expected = InvalidParameterValueException.class)
public void testPasswordChangeRequiredWithLdapSourceThrows() {
accountManagerImpl.createUser(
"user", "pass", "fn", "ln", "e@mail.com",
"UTC", "acct", 1L, null,
User.Source.LDAP, true);
}
@Test(expected = CloudRuntimeException.class)
public void testDomainNotFound() {
Mockito.when(_domainMgr.getDomain(1L)).thenReturn(null);
accountManagerImpl.createUser(
"user", "pass", "fn", "ln", "e@mail.com",
"UTC", "acct", 1L, null,
User.Source.UNKNOWN, false);
}
@Test(expected = CloudRuntimeException.class)
public void testCreateUserInactiveDomain() {
Mockito.when(domainVoMock.getState()).thenReturn(Domain.State.Inactive);
Mockito.when(_domainMgr.getDomain(Mockito.anyLong())).thenReturn(domainVoMock);
accountManagerImpl.createUser(
"user", "pass", "fn", "ln", "e@mail.com",
"UTC", "acct", 1L, null,
User.Source.NATIVE, false);
}
@Test(expected = InvalidParameterValueException.class)
public void testCreateUserCheckAccess() {
Mockito.when(domainVoMock.getState()).thenReturn(Domain.State.Active);
Mockito.when(_domainMgr.getDomain(Mockito.anyLong())).thenReturn(domainVoMock);
Mockito.doNothing().when(accountManagerImpl).checkAccess(Mockito.any(Account.class), Mockito.any(Domain.class));
accountManagerImpl.createUser(
"user", "pass", "fn", "ln", "e@mail.com",
"UTC", "acct", 1L, null,
User.Source.NATIVE, false);
}
@Test(expected = InvalidParameterValueException.class)
public void testCreateUserMissingOrProjectAccount() {
Mockito.when(domainVoMock.getState()).thenReturn(Domain.State.Active);
Mockito.when(_accountDao.findEnabledAccount(Mockito.anyString(), Mockito.anyLong())).thenReturn(accountMock);
Mockito.when(accountMock.getType()).thenReturn(Account.Type.PROJECT);
Mockito.when(_domainMgr.getDomain(Mockito.anyLong())).thenReturn(domainVoMock);
Mockito.doNothing().when(accountManagerImpl).checkAccess(Mockito.any(Account.class), Mockito.any(Domain.class));
accountManagerImpl.createUser(
"user", "pass", "fn", "ln", "e@mail.com",
"UTC", "acct", 1L, null,
User.Source.NATIVE, false);
}
@Test
public void testCreateUserSuccess() {
Account rootAdminAccount = Mockito.mock(Account.class);
Mockito.when(rootAdminAccount.getId()).thenReturn(1L);
Mockito.when(accountManagerImpl.isRootAdmin(1L)).thenReturn(true);
User callingUser = Mockito.mock(User.class);
CallContext.register(callingUser, rootAdminAccount);
String newPassword = "newPassword";
configureUserMockAuthenticators(newPassword);
Mockito.doNothing().when(accountManagerImpl).checkAccess(any(Account.class), any(Domain.class));
Mockito.doReturn(accountMock).when(accountManagerImpl).getAccount(Mockito.anyLong());
Mockito.doNothing().when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(), Mockito.anyString(), Mockito.anyLong());
Mockito.when(_domainMgr.getDomain(Mockito.anyLong())).thenReturn(domainVoMock);
Mockito.when(domainVoMock.getState()).thenReturn(Domain.State.Active);
Mockito.when(_accountDao.findEnabledAccount(Mockito.anyString(), Mockito.anyLong())).thenReturn(accountMock);
Mockito.when(accountMock.getId()).thenReturn(10L);
Mockito.when(accountMock.getType()).thenReturn(Account.Type.NORMAL);
Mockito.when(userAccountDaoMock.validateUsernameInDomain(Mockito.anyString(), Mockito.anyLong())).thenReturn(true);
Mockito.when(userDaoMock.findUsersByName(Mockito.anyString())).thenReturn(Collections.emptyList());
UserVO createdUser = new UserVO();
String userMockUUID = "userMockUUID";
createdUser.setUuid(userMockUUID);
Mockito.when(userDaoMock.persist(Mockito.any(UserVO.class))).thenReturn(createdUser);
UserVO userResultVO = accountManagerImpl.createUser(
"user", newPassword, "fn", "ln", "e@mail.com",
"UTC", "acct", 1L, null,
User.Source.NATIVE, false
);
Assert.assertNotNull(userResultVO);
UserVO userResultPasswordChangeVO = accountManagerImpl.createUser(
"user", newPassword, "fn", "ln", "e@mail.com",
"UTC", "acct", 1L, null,
User.Source.NATIVE, true
);
Assert.assertNotNull(userResultVO);
}
}

View File

@ -16,7 +16,11 @@
// 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 org.apache.cloudstack.api.ServerApiException;
import org.apache.cloudstack.framework.config.ConfigKey;
import org.apache.cloudstack.resourcedetail.UserDetailVO;
@ -45,6 +49,12 @@ public class UserPasswordResetManagerImplTest {
@Mock
private UserDetailsDao userDetailsDao;
@Mock
AccountManager accountManager;
@Mock
UserDao userDao;
@Test
public void testGetMessageBody() {
ConfigKey<String> passwordResetMailTemplate = Mockito.mock(ConfigKey.class);
@ -147,4 +157,21 @@ public class UserPasswordResetManagerImplTest {
Assert.assertFalse(passwordReset.validateExistingToken(userAccount));
}
@Test
public void testResetPassword() {
UserAccount userAccount = Mockito.mock(UserAccount.class);
UserVO userVO = Mockito.mock(UserVO.class);
long userId = 1L;
String newPassword = "newPassword";
Mockito.when(userAccount.getId()).thenReturn(userId);
Mockito.when(userDao.getUser(userId)).thenReturn(userVO);
passwordReset.resetPassword(userAccount, newPassword);
Mockito.verify(userDao).getUser(userId);
Mockito.verify(accountManager).validateUserPasswordAndUpdateIfNeeded(newPassword, userVO, "", true);
Mockito.verify(userDetailsDao).removeDetail(userId, PasswordResetToken);
Mockito.verify(userDetailsDao).removeDetail(userId, PasswordResetTokenExpiryDate);
Mockito.verify(userDetailsDao).removeDetail(userId, UserDetailVO.PasswordChangeRequired);
Mockito.verify(userDao).persist(userVO);
}
}

View File

@ -539,6 +539,8 @@
"label.change.ipaddress": "Change IP address for NIC",
"label.change.disk.offering": "Change disk offering",
"label.change.offering.for.volume": "Change disk offering for the volume",
"label.change.password.onlogin": "User must change password at next login",
"label.change.password.reset": "Force password reset",
"label.change.service.offering": "Change service offering",
"label.character": "Character",
"label.checksum": "Checksum",
@ -3169,6 +3171,8 @@
"message.change.offering.for.volume.failed": "Change offering for the volume failed",
"message.change.offering.for.volume.processing": "Changing offering for the volume...",
"message.change.password": "Please change your password.",
"message.change.password.required": "You are required to change your password.",
"message.change.password.reset": "Force password reset on next login.",
"message.change.scope.failed": "Scope change failed",
"message.change.scope.processing": "Scope change in progress",
"message.change.service.offering.sharedfs.failed": "Failed to change service offering for the Shared FileSystem.",
@ -3419,6 +3423,7 @@
"message.error.apply.tungsten.tag": "Applying Tag failed",
"message.error.binaries.iso.url": "Please enter binaries ISO URL.",
"message.error.bucket": "Please enter bucket",
"message.error.change.password": "Failed to change password.",
"message.error.cidr": "CIDR is required",
"message.error.cidr.or.cidrsize": "CIDR or cidr size is required",
"message.error.cloudian.console": "Single-Sign-On failed for Cloudian management console. Please ask your administrator to fix integration issues.",
@ -3482,6 +3487,7 @@
"message.error.netmask": "Please enter Netmask.",
"message.error.network.offering": "Please select Network offering.",
"message.error.new.password": "Please enter new password.",
"message.error.newpassword.sameascurrent": "New password cannot be the same as the current password.",
"message.error.nexus1000v.ipaddress": "Please enter Nexus 1000v IP address.",
"message.error.nexus1000v.password": "Please enter Nexus 1000v password.",
"message.error.nexus1000v.username": "Please enter Nexus 1000v username.",
@ -3726,6 +3732,7 @@
"message.please.confirm.remove.user.data": "Please confirm that you want to remove this User Data",
"message.please.enter.valid.value": "Please enter a valid value.",
"message.please.enter.value": "Please enter values.",
"message.please.login.new.password": "Please log in again with your new password",
"message.please.wait.while.autoscale.vmgroup.is.being.created": "Please wait while your AutoScaling Group is being created; this may take a while...",
"message.please.wait.while.zone.is.being.created": "Please wait while your Zone is being created; this may take a while...",
"message.pod.dedicated": "Pod dedicated.",

View File

@ -318,6 +318,11 @@ export const constantRouterMap = [
path: 'resetPassword',
name: 'resetPassword',
component: () => import(/* webpackChunkName: "auth" */ '@/views/auth/ResetPassword')
},
{
path: 'forceChangePassword',
name: 'forceChangePassword',
component: () => import(/* webpackChunkName: "auth" */ '@/views/iam/ForceChangePassword')
}
]
},

View File

@ -82,6 +82,24 @@ export default {
popup: true,
component: shallowRef(defineAsyncComponent(() => import('@/views/iam/EditUser.vue')))
},
{
api: 'updateUser',
icon: 'redo-outlined',
label: 'label.change.password.reset',
message: 'message.change.password.reset',
dataView: true,
args: ['passwordchangerequired'],
mapping: {
passwordchangerequired: {
value: (record) => { return true }
}
},
popup: true,
show: (record, store) => {
return ['Admin', 'DomainAdmin'].includes(store.userInfo.roletype) && !record.isdefault &&
store.userInfo.id !== record.id && record.state === 'enabled' && record.usersource === 'native'
}
},
{
api: 'updateUser',
icon: 'key-outlined',

View File

@ -94,6 +94,16 @@ router.beforeEach((to, from, next) => {
}
store.commit('SET_LOGIN_FLAG', true)
}
// store already loaded
if (store.getters.passwordChangeRequired) {
if (to.path === '/user/forceChangePassword') {
next()
} else {
next({ path: '/user/forceChangePassword' })
NProgress.done()
}
return
}
if (Object.keys(store.getters.apis).length === 0) {
const cachedApis = vueProps.$localStorage.get(APIS, {})
if (Object.keys(cachedApis).length > 0) {
@ -102,6 +112,19 @@ router.beforeEach((to, from, next) => {
store
.dispatch('GetInfo')
.then(apis => {
// Essential for Page Refresh scenarios
if (store.getters.passwordChangeRequired) {
// Only allow the Change Password page
if (to.path === '/user/forceChangePassword') {
next()
} else {
// Redirect everything else (including dashboard, wildcards) to Change Password
next({ path: '/user/forceChangePassword' })
NProgress.done()
}
return
}
store.dispatch('GenerateRoutes', { apis }).then(() => {
store.getters.addRouters.map(route => {
router.addRoute(route)

View File

@ -55,7 +55,8 @@ const getters = {
loginFlag: state => state.user.loginFlag,
allProjects: (state) => state.app.allProjects,
customHypervisorName: state => state.user.customHypervisorName,
readyForShutdownPollingJob: state => state.user.readyForShutdownPollingJob
readyForShutdownPollingJob: state => state.user.readyForShutdownPollingJob,
passwordChangeRequired: state => state.user.passwordChangeRequired
}
export default getters

View File

@ -44,7 +44,8 @@ import {
MS_ID,
OAUTH_DOMAIN,
OAUTH_PROVIDER,
LATEST_CS_VERSION
LATEST_CS_VERSION,
PASSWORD_CHANGE_REQUIRED
} from '@/store/mutation-types'
import {
@ -80,7 +81,8 @@ const user = {
twoFaProvider: '',
twoFaIssuer: '',
customHypervisorName: 'Custom',
readyForShutdownPollingJob: ''
readyForShutdownPollingJob: '',
passwordChangeRequired: false
},
mutations: {
@ -196,6 +198,14 @@ const user = {
vueProps.$localStorage.set(LATEST_CS_VERSION, version)
state.latestVersion = version
}
},
SET_PASSWORD_CHANGE_REQUIRED: (state, required) => {
state.passwordChangeRequired = required
if (required) {
vueProps.$localStorage.set(PASSWORD_CHANGE_REQUIRED, true)
} else {
vueProps.$localStorage.remove(PASSWORD_CHANGE_REQUIRED)
}
}
},
@ -244,6 +254,13 @@ const user = {
if (result && result.managementserverid) {
commit('SET_MS_ID', result.managementserverid)
}
if (result.passwordchangerequired) {
commit('SET_PASSWORD_CHANGE_REQUIRED', true)
commit('SET_APIS', {})
vueProps.$localStorage.remove(APIS)
} else {
commit('SET_PASSWORD_CHANGE_REQUIRED', false)
}
const latestVersion = vueProps.$localStorage.get(LATEST_CS_VERSION, { version: '', fetchedTs: 0 })
commit('SET_LATEST_VERSION', latestVersion)
notification.destroy()
@ -323,6 +340,15 @@ const user = {
commit('SET_DOMAIN_STORE', domainStore)
commit('SET_DARK_MODE', darkMode)
commit('SET_LATEST_VERSION', latestVersion)
// This block is to enforce password change for first time login after admin resets password
const isPwdChangeRequired = vueProps.$localStorage.get(PASSWORD_CHANGE_REQUIRED)
commit('SET_PASSWORD_CHANGE_REQUIRED', isPwdChangeRequired)
if (isPwdChangeRequired) {
resolve()
return
}
if (hasAuth) {
console.log('Login detected, using cached APIs')
commit('SET_ZONES', cachedZones)
@ -485,6 +511,8 @@ const user = {
vueProps.$localStorage.remove(ACCESS_TOKEN)
vueProps.$localStorage.remove(HEADER_NOTICES)
commit('SET_PASSWORD_CHANGE_REQUIRED', false)
logout(state.token).then(() => {
message.destroy()
if (cloudianUrl) {

View File

@ -43,6 +43,7 @@ export const RELOAD_ALL_PROJECTS = 'RELOAD_ALL_PROJECTS'
export const MS_ID = 'MS_ID'
export const OAUTH_DOMAIN = 'OAUTH_DOMAIN'
export const OAUTH_PROVIDER = 'OAUTH_PROVIDER'
export const PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED'
export const CONTENT_WIDTH_TYPE = {
Fluid: 'Fluid',

View File

@ -133,11 +133,16 @@
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="isAdminOrDomainAdmin() && !samlEnable" name="passwordChangeRequired" ref="passwordChangeRequired">
<a-checkbox v-model:checked="form.passwordChangeRequired">
{{ $t('label.change.password.onlogin') }}
</a-checkbox>
</a-form-item>
<div v-if="samlAllowed">
<a-form-item name="samlenable" ref="samlenable" :label="$t('label.samlenable')">
<a-switch v-model:checked="form.samlenable" />
<a-form-item name="samlEnable" ref="samlEnable" :label="$t('label.samlenable')">
<a-switch v-model:checked="samlEnable" />
</a-form-item>
<a-form-item name="samlentity" ref="samlentity" v-if="form.samlenable">
<a-form-item name="samlentity" ref="samlentity" v-if="samlEnable">
<template #label>
<tooltip-label :title="$t('label.samlentity')" :tooltip="apiParams.entityid.description"/>
</template>
@ -198,6 +203,13 @@ export default {
this.initForm()
this.fetchData()
},
watch: {
samlEnable (newVal) {
if (newVal) {
this.form.passwordChangeRequired = false
}
}
},
computed: {
samlAllowed () {
return 'authorizeSamlSso' in this.$store.getters.apis
@ -291,9 +303,9 @@ export default {
})
const user = userCreationResponse?.createuserresponse?.user
if (values.samlenable && user) {
if (this.samlEnable && user) {
await postAPI('authorizeSamlSso', {
enable: values.samlenable,
enable: this.samlEnable,
entityid: values.samlentity,
userid: user.id
})
@ -347,6 +359,9 @@ export default {
if (this.isValidValueForKey(rawParams, 'timezone') && rawParams.timezone.length > 0) {
params.timezone = rawParams.timezone
}
if (this.isAdminOrDomainAdmin() && rawParams.passwordChangeRequired === true) {
params.passwordchangerequired = rawParams.passwordChangeRequired
}
return postAPI('createUser', params)
},

View File

@ -49,6 +49,11 @@
v-model:value="form.confirmpassword"
:placeholder="$t('label.confirmpassword.description')"/>
</a-form-item>
<a-form-item v-if="isAdminOrDomainAdmin() && isCallerNotSameAsUser()" name="passwordChangeRequired" ref="passwordChangeRequired">
<a-checkbox v-model:checked="form.passwordChangeRequired">
{{ $t('label.change.password.onlogin') }}
</a-checkbox>
</a-form-item>
<div :span="24" class="action-button">
<a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
@ -102,6 +107,11 @@ export default {
isAdminOrDomainAdmin () {
return ['Admin', 'DomainAdmin'].includes(this.$store.getters.userInfo.roletype)
},
isCallerNotSameAsUser () {
const userId = this.$store.getters.userInfo.id
const resourceId = this.resource?.id ?? null
return userId !== resourceId
},
isValidValueForKey (obj, key) {
return key in obj && obj[key] != null
},
@ -134,6 +144,10 @@ export default {
if (this.isValidValueForKey(values, 'currentpassword') && values.currentpassword.length > 0) {
params.currentpassword = values.currentpassword
}
if (this.isAdminOrDomainAdmin() && values.passwordChangeRequired === true) {
params.passwordchangerequired = values.passwordChangeRequired
}
postAPI('updateUser', params).then(json => {
this.$notification.success({
message: this.$t('label.action.change.password'),

View File

@ -0,0 +1,285 @@
// 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>
<div class="user-layout-wrapper">
<div class="container">
<div class="user-layout-content">
<a-card :bordered="false" class="force-password-card">
<template #title>
<div style="text-align: center; font-size: 18px; font-weight: bold;">
{{ $t('label.action.change.password') }}
</div>
<div v-if="!isSubmitted" style="text-align: center; font-size: 14px; color: #666; margin-top: 5px;">
{{ $t('message.change.password.required') }}
</div>
</template>
<a-spin :spinning="loading">
<div v-if="isSubmitted" class="success-state">
<check-outlined class="success-icon" />
<div class="success-text">
{{ $t('message.success.change.password') }}
</div>
<div class="success-subtext">
{{ $t('message.please.login.new.password') }}
</div>
<a-button
type="primary"
size="large"
block
@click="redirectToLogin()"
style="margin-top: 20px;"
>
{{ $t('label.login') }}
</a-button>
</div>
<a-form
v-else
:ref="formRef"
:model="form"
:rules="rules"
layout="vertical"
@finish="handleSubmit"
v-ctrl-enter="handleSubmit"
>
<a-form-item name="currentpassword" :label="$t('label.currentpassword')">
<a-input-password
v-model:value="form.currentpassword"
:placeholder="$t('label.currentpassword')"
size="large"
v-focus="true"
/>
</a-form-item>
<a-form-item name="password" :label="$t('label.new.password')">
<a-input-password
v-model:value="form.password"
:placeholder="$t('label.new.password')"
size="large"
/>
</a-form-item>
<a-form-item name="confirmpassword" :label="$t('label.confirmpassword')">
<a-input-password
v-model:value="form.confirmpassword"
:placeholder="$t('label.confirmpassword')"
size="large"
/>
</a-form-item>
<a-form-item>
<a-button
html-type="submit"
type="primary"
size="large"
block
:disabled="loading"
:loading="loading"
@click="handleSubmit"
>
{{ $t('label.ok') }}
</a-button>
</a-form-item>
<div class="actions">
<a @click="logoutAndRedirectToLogin()">{{ $t('label.logout') }}</a>
</div>
</a-form>
</a-spin>
</a-card>
</div>
</div>
</div>
</template>
<script>
import { ref, reactive, toRaw } from 'vue'
import { postAPI } from '@/api'
import Cookies from 'js-cookie'
import { PASSWORD_CHANGE_REQUIRED } from '@/store/mutation-types'
export default {
name: 'ForceChangePassword',
data () {
return {
loading: false,
isSubmitted: false
}
},
created () {
this.formRef = ref()
this.form = reactive({})
this.isPasswordChangeRequired()
},
computed: {
rules () {
return {
currentpassword: [{ required: true, message: this.$t('message.error.current.password') }],
password: [
{ required: true, message: this.$t('message.error.new.password') },
{ validator: this.validateNewPassword, trigger: 'change' }
],
confirmpassword: [
{ required: true, message: this.$t('message.error.confirm.password') },
{ validator: this.validateTwoPassword, trigger: 'change' }
]
}
}
},
methods: {
async validateNewPassword (rule, value) {
const currentPassword = this.form.currentpassword
if (!value || value.length === 0) {
return Promise.resolve()
}
// Ensure new password is different from current password
if (currentPassword && value === currentPassword) {
return Promise.reject(this.$t('message.error.newpassword.sameascurrent'))
}
return Promise.resolve()
},
async validateTwoPassword (rule, value) {
if (!value || value.length === 0) {
return Promise.resolve()
} else if (rule.field === 'confirmpassword') {
const form = this.form
const messageConfirm = this.$t('message.validate.equalto')
const passwordVal = form.password
if (passwordVal && passwordVal !== value) {
return Promise.reject(messageConfirm)
} else {
return Promise.resolve()
}
} else {
return Promise.resolve()
}
},
handleSubmit (e) {
e.preventDefault()
if (this.loading) return
this.formRef.value.validate().then(() => {
this.loading = true
const values = toRaw(this.form)
const userId = Cookies.get('userid')
const params = {
id: userId,
password: values.password,
currentpassword: values.currentpassword
}
postAPI('updateUser', params).then(async () => {
this.$localStorage.remove(PASSWORD_CHANGE_REQUIRED)
await this.handleLogout()
this.isSubmitted = true
}).catch(error => {
console.error(error)
this.$message.error(this.$t('message.error.change.password'))
}).finally(() => {
this.loading = false
})
}).catch(error => {
console.log('Validation failed:', error)
})
},
async handleLogout () {
try {
await this.$store.dispatch('Logout')
} catch (e) {
console.error('Logout failed:', e)
} finally {
Cookies.remove('userid')
Cookies.remove('token')
}
},
redirectToLogin () {
this.$router.replace('/user/login')
},
logoutAndRedirectToLogin () {
this.handleLogout().then(() => {
this.redirectToLogin()
})
},
async isPasswordChangeRequired () {
const passwordChangeRequired = this.$localStorage.get(PASSWORD_CHANGE_REQUIRED)
this.isSubmitted = !passwordChangeRequired
}
}
}
</script>
<style scoped lang="less">
.user-layout-wrapper {
display: flex;
justify-content: center;
align-items: center;
.container {
width: 100%;
padding: 16px;
.user-layout-content {
display: flex;
justify-content: center;
.force-password-card {
width: 100%;
max-width: 420px;
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
}
}
}
.actions {
text-align: center;
margin-top: 16px;
a {
color: #1890ff;
transition: color 0.3s;
&:hover {
color: #40a9ff;
}
}
}
.success-state {
text-align: center;
padding: 20px 0;
.success-icon {
font-size: 48px;
color: #52c41a;
margin-bottom: 16px;
}
.success-text {
font-size: 20px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.success-subtext {
font-size: 14px;
color: #666;
}
}
</style>