diff --git a/api/src/main/java/com/cloud/user/AccountService.java b/api/src/main/java/com/cloud/user/AccountService.java index 09fe5ffc0590..c12e6fc1f0ed 100644 --- a/api/src/main/java/com/cloud/user/AccountService.java +++ b/api/src/main/java/com/cloud/user/AccountService.java @@ -58,7 +58,8 @@ UserAccount createUserAccount(String userName, String password, String firstName 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); diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index 3e36d933772b..b3639ca1edf8 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -1257,6 +1257,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"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmd.java index f03bb1c4ddd3..684103cf8d39 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmd.java @@ -26,6 +26,7 @@ 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 String getUserUUID() { return userUUID; } + public Boolean isPasswordChangeRequired() { + return BooleanUtils.isTrue(passwordChangeRequired); + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// @@ -147,7 +158,7 @@ public void execute() { 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()); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java index cc0b2e4954ce..b28f6c78e932 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java @@ -29,6 +29,7 @@ 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 @@ 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 Long getApiResourceId() { public ApiCommandResourceType getApiResourceType() { return ApiCommandResourceType.User; } + + public Boolean isPasswordChangeRequired() { + return BooleanUtils.isTrue(passwordChangeRequired); + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java index c20f700fe08e..6e3ef4678d28 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java @@ -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 String getManagementServerId() { public void setManagementServerId(String managementServerId) { this.managementServerId = managementServerId; } + + public Boolean getPasswordChangeRequired() { + return passwordChangeRequired; + } + + public void setPasswordChangeRequired(Boolean passwordChangeRequired) { + this.passwordChangeRequired = passwordChangeRequired; + } } diff --git a/api/src/test/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmdTest.java index 8a57ac3eb22c..397723dd6069 100644 --- a/api/src/test/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmdTest.java +++ b/api/src/test/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmdTest.java @@ -69,7 +69,7 @@ public void testExecuteWithNotBlankPassword() { } 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 void testExecuteWithNullPassword() { 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 void testExecuteWithEmptyPassword() { 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); } } diff --git a/api/src/test/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmdTest.java new file mode 100644 index 000000000000..f86e51adb5ab --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmdTest.java @@ -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()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/response/LoginCmdResponseTest.java b/api/src/test/java/org/apache/cloudstack/api/response/LoginCmdResponseTest.java new file mode 100644 index 000000000000..7811138fffe1 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/response/LoginCmdResponseTest.java @@ -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()); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java index d0cfcc3d4396..4e7289dae128 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java @@ -48,6 +48,7 @@ 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 UserDetailVO() { } diff --git a/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java b/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java index 452b95cf2c05..db274e3575f1 100644 --- a/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java +++ b/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java @@ -44,8 +44,10 @@ 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; @@ -55,6 +57,7 @@ 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; @@ -66,6 +69,7 @@ public class ApiDiscoveryServiceImpl extends ComponentLifecycleBase implements A List _apiAccessCheckers = null; List _services = null; protected static Map s_apiNameDiscoveryResponseMap = null; + public static final List APIS_ALLOWED_FOR_PASSWORD_CHANGE = Arrays.asList("login", "logout", "updateUser", "listApis"); @Inject AccountService accountService; @@ -280,12 +284,19 @@ public ListResponse listApis(User user, String name) { 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 userAccDetails = userAccount.getDetails(); + if (MapUtils.isNotEmpty(userAccDetails) && "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); + } } } diff --git a/plugins/api/discovery/src/test/java/org/apache/cloudstack/discovery/ApiDiscoveryTest.java b/plugins/api/discovery/src/test/java/org/apache/cloudstack/discovery/ApiDiscoveryTest.java index eea78d8abb93..d33774cad031 100644 --- a/plugins/api/discovery/src/test/java/org/apache/cloudstack/discovery/ApiDiscoveryTest.java +++ b/plugins/api/discovery/src/test/java/org/apache/cloudstack/discovery/ApiDiscoveryTest.java @@ -21,6 +21,8 @@ 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.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.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 void listApisTestGetsApisAllowedToUserOnUserRole() throws PermissionDenie 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 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 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 response = (ListResponse) discoveryServiceSpy.listApis(getTestUser(), null); + Assert.assertEquals(4, response.getResponses().size()); + } } diff --git a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java index 2ff68b4836f4..352c13c8f3ef 100644 --- a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java +++ b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java @@ -130,7 +130,7 @@ public String[] createApiKeyAndSecretKey(final long userId) { } @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; } diff --git a/server/src/main/java/com/cloud/api/ApiServer.java b/server/src/main/java/com/cloud/api/ApiServer.java index 5a3c8c2c7179..9db3f226bdf6 100644 --- a/server/src/main/java/com/cloud/api/ApiServer.java +++ b/server/src/main/java/com/cloud/api/ApiServer.java @@ -116,9 +116,11 @@ 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.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 @@ private ResponseObject createLoginResponse(HttpSession session) { 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 ResponseObject loginUser(final HttpSession session, final String username final String sessionKey = Base64.encodeBase64URLSafeString(sessionKeyBytes); session.setAttribute(ApiConstants.SESSIONKEY, sessionKey); + Map 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"); diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index dd60fbfb9cca..aa777b988e87 100644 --- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java +++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java @@ -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; @@ -1508,12 +1510,24 @@ private List getEnabledApiCheckers() { @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"); @@ -1544,14 +1558,21 @@ public UserVO createUser(String userName, String password, String firstName, Str 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 @@ -1585,10 +1606,36 @@ public UserAccount updateUser(UpdateUserCmd updateUserCmd) { if (mandate2FA != null && mandate2FA) { user.setUser2faEnabled(true); } + updatePasswordChangeRequired(caller, updateUserCmd, user); _userDao.update(user.getId(), user); return _userAccountDao.findById(user.getId()); } + private void updatePasswordChangeRequired(User caller, UpdateUserCmd updateUserCmd, UserVO user) { + User.Source userSource = user.getSource(); + if ((userSource == User.Source.SAML2 || userSource == User.Source.SAML2DISABLED || userSource == User.Source.LDAP) + && updateUserCmd.isPasswordChangeRequired()) { + 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)); @@ -2847,6 +2894,8 @@ public UserAccount authenticateUser(final String username, final String password 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()) { diff --git a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java index 618ad5c86572..844f452de470 100644 --- a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java @@ -48,6 +48,7 @@ import java.util.Set; import java.util.UUID; +import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordChangeRequired; import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetToken; import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetTokenExpiryDate; @@ -247,6 +248,8 @@ void resetPassword(UserAccount userAccount, String password) { 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); } diff --git a/server/src/test/java/com/cloud/api/ApiServerTest.java b/server/src/test/java/com/cloud/api/ApiServerTest.java index dedd6e02ec5c..2caf6bf9fae6 100644 --- a/server/src/test/java/com/cloud/api/ApiServerTest.java +++ b/server/src/test/java/com/cloud/api/ApiServerTest.java @@ -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.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 void testSetupIntegrationPortListenerValidPort() { @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 void testForgotPasswordSuccess() { @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 void testForgotPasswordFailureNoEmail() { @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 void testForgotPasswordFailureDisabledUser() { @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 void testForgotPasswordFailureDisabledAccount() { @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 void testForgotPasswordFailureInactiveDomain() { @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 void testVerifyApiKeyAccessAllowed() { 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 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 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); + } } diff --git a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java index c33cb334e77f..0577b8dd89ae 100644 --- a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java +++ b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java @@ -23,6 +23,7 @@ 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,19 @@ public void updateUserTestTimeZoneAndEmailNotNull() { prepareMockAndExecuteUpdateUserTest(1); } + @Test + public void updateUserTestPwdChange() { + 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 +1606,104 @@ public void testcheckCallerApiPermissionsForUserOperationsNotAllowedApis() { 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); + } } diff --git a/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java index 17092e6311dd..5e274b06be28 100644 --- a/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java @@ -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 passwordResetMailTemplate = Mockito.mock(ConfigKey.class); @@ -147,4 +157,21 @@ public void testValidateExistingTokenSecondRequestUnexpired() { 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); + } } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index c40682cce76b..6d28636119c1 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -530,6 +530,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", @@ -3138,6 +3140,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.", @@ -3385,6 +3389,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.", @@ -3688,6 +3693,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.", diff --git a/ui/src/config/router.js b/ui/src/config/router.js index 582fbaaf2f35..5300385eeac7 100644 --- a/ui/src/config/router.js +++ b/ui/src/config/router.js @@ -313,6 +313,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') } ] }, diff --git a/ui/src/config/section/user.js b/ui/src/config/section/user.js index a18994fd6ce1..69c5277de989 100644 --- a/ui/src/config/section/user.js +++ b/ui/src/config/section/user.js @@ -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) + } + }, { api: 'updateUser', icon: 'key-outlined', diff --git a/ui/src/permission.js b/ui/src/permission.js index 266dc992c8db..671d6626b931 100644 --- a/ui/src/permission.js +++ b/ui/src/permission.js @@ -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) diff --git a/ui/src/store/getters.js b/ui/src/store/getters.js index 911234d9b715..c7ab2f0c536b 100644 --- a/ui/src/store/getters.js +++ b/ui/src/store/getters.js @@ -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 diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js index 2c0edf656d73..a71c3378e26c 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -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) { diff --git a/ui/src/store/mutation-types.js b/ui/src/store/mutation-types.js index 0b1f921ab86e..5fc2cd74d213 100644 --- a/ui/src/store/mutation-types.js +++ b/ui/src/store/mutation-types.js @@ -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', diff --git a/ui/src/views/iam/AddUser.vue b/ui/src/views/iam/AddUser.vue index 374d81c51d13..4b1b60d8d06b 100644 --- a/ui/src/views/iam/AddUser.vue +++ b/ui/src/views/iam/AddUser.vue @@ -147,6 +147,11 @@ + + + {{ $t('label.change.password.onlogin') }} + +
@@ -384,6 +389,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) }, diff --git a/ui/src/views/iam/ChangeUserPassword.vue b/ui/src/views/iam/ChangeUserPassword.vue index d5c52b8f637e..f736557289c7 100644 --- a/ui/src/views/iam/ChangeUserPassword.vue +++ b/ui/src/views/iam/ChangeUserPassword.vue @@ -49,6 +49,11 @@ v-model:value="form.confirmpassword" :placeholder="$t('label.confirmpassword.description')"/> + + + {{ $t('label.change.password.onlogin') }} + +
{{ $t('label.cancel') }} @@ -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'), diff --git a/ui/src/views/iam/ForceChangePassword.vue b/ui/src/views/iam/ForceChangePassword.vue new file mode 100644 index 000000000000..ea156ca7811e --- /dev/null +++ b/ui/src/views/iam/ForceChangePassword.vue @@ -0,0 +1,269 @@ +// 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. + + + + + +