This is an automated email from the ASF dual-hosted git repository.
harikrishna pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git
The following commit(s) were added to refs/heads/main by this push:
new c79b33c1fbd Allow enforcing password change for a user after reset by
admin (root/domain) (#12294)
c79b33c1fbd is described below
commit c79b33c1fbd4840613fa8a09cbffc9b76ea0b1a1
Author: Manoj Kumar <[email protected]>
AuthorDate: Mon Feb 16 16:01:42 2026 +0530
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
---
.../main/java/com/cloud/user/AccountService.java | 3 +-
.../org/apache/cloudstack/api/ApiConstants.java | 1 +
.../api/command/admin/user/CreateUserCmd.java | 13 +-
.../api/command/admin/user/UpdateUserCmd.java | 14 +-
.../cloudstack/api/response/LoginCmdResponse.java | 12 +
.../api/command/admin/user/CreateUserCmdTest.java | 6 +-
.../api/command/admin/user/UpdateUserCmdTest.java | 64 +++++
.../api/response/LoginCmdResponseTest.java | 87 +++++++
.../cloudstack/resourcedetail/UserDetailVO.java | 2 +
.../discovery/ApiDiscoveryServiceImpl.java | 22 +-
.../cloudstack/discovery/ApiDiscoveryTest.java | 38 +++
.../contrail/management/MockAccountManager.java | 2 +-
.../api/command/OauthLoginAPIAuthenticatorCmd.java | 9 +-
server/src/main/java/com/cloud/api/ApiServer.java | 13 +
.../api/auth/DefaultLoginAPIAuthenticatorCmd.java | 9 +-
.../java/com/cloud/user/AccountManagerImpl.java | 60 ++++-
.../user/UserPasswordResetManagerImpl.java | 3 +
.../src/test/java/com/cloud/api/ApiServerTest.java | 124 ++++++++-
.../com/cloud/user/AccountManagerImplTest.java | 139 ++++++++++
.../user/UserPasswordResetManagerImplTest.java | 27 ++
ui/public/locales/en.json | 7 +
ui/src/config/router.js | 5 +
ui/src/config/section/user.js | 18 ++
ui/src/permission.js | 23 ++
ui/src/store/getters.js | 3 +-
ui/src/store/modules/user.js | 32 ++-
ui/src/store/mutation-types.js | 1 +
ui/src/views/iam/AddUser.vue | 25 +-
ui/src/views/iam/ChangeUserPassword.vue | 14 +
ui/src/views/iam/ForceChangePassword.vue | 285 +++++++++++++++++++++
30 files changed, 1023 insertions(+), 38 deletions(-)
diff --git a/api/src/main/java/com/cloud/user/AccountService.java
b/api/src/main/java/com/cloud/user/AccountService.java
index b92654bfe17..eb47b75ac5b 100644
--- a/api/src/main/java/com/cloud/user/AccountService.java
+++ b/api/src/main/java/com/cloud/user/AccountService.java
@@ -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);
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 1ab6fba6081..9a8913da5b0 100644
--- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java
+++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java
@@ -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";
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 f03bb1c4ddd..684103cf8d3 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.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());
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 b61550c7087..628ddb96deb 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.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);
+ }
}
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 c20f700fe08..6e3ef4678d2 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 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;
+ }
}
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 8a57ac3eb22..397723dd606 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 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);
}
}
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 00000000000..f86e51adb5a
--- /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 00000000000..7811138fffe
--- /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 d0cfcc3d439..93b49bc20a1 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,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() {
}
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 d6d235162ef..4493f1e9074 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.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);
+ }
}
}
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 eea78d8abb9..d33774cad03 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.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());
+ }
}
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 4dffb405d1a..d3714f14834 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
@@ -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;
}
diff --git
a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/OauthLoginAPIAuthenticatorCmd.java
b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/OauthLoginAPIAuthenticatorCmd.java
index f9a1d10d352..ced34068bb8 100644
---
a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/OauthLoginAPIAuthenticatorCmd.java
+++
b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/OauthLoginAPIAuthenticatorCmd.java
@@ -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 ?
diff --git a/server/src/main/java/com/cloud/api/ApiServer.java
b/server/src/main/java/com/cloud/api/ApiServer.java
index 95aca28b53f..4a6a1180363 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.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");
diff --git
a/server/src/main/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java
b/server/src/main/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java
index c9b03a85f4c..dc220b1b836 100644
---
a/server/src/main/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java
+++
b/server/src/main/java/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java
@@ -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)
diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java
b/server/src/main/java/com/cloud/user/AccountManagerImpl.java
index 53b88690654..f0be13d858d 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;
@@ -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()) {
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 c62bca8eca4..d6b5dbb18f9 100644
---
a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java
+++
b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java
@@ -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);
}
diff --git a/server/src/test/java/com/cloud/api/ApiServerTest.java
b/server/src/test/java/com/cloud/api/ApiServerTest.java
index dedd6e02ec5..2caf6bf9fae 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.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("[email protected]");
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("[email protected]");
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("[email protected]");
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("[email protected]");
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);
+ }
}
diff --git a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java
b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java
index c33cb334e77..d429d7c7076 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.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", "[email protected]",
+ "UTC", "acct", 1L, null,
+ User.Source.SAML2, true
+ );
+ }
+
+ @Test(expected = InvalidParameterValueException.class)
+ public void testPasswordChangeRequiredWithLdapSourceThrows() {
+ accountManagerImpl.createUser(
+ "user", "pass", "fn", "ln", "[email protected]",
+ "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", "[email protected]",
+ "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", "[email protected]",
+ "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", "[email protected]",
+ "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", "[email protected]",
+ "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", "[email protected]",
+ "UTC", "acct", 1L, null,
+ User.Source.NATIVE, false
+ );
+ Assert.assertNotNull(userResultVO);
+ UserVO userResultPasswordChangeVO = accountManagerImpl.createUser(
+ "user", newPassword, "fn", "ln", "[email protected]",
+ "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 17092e6311d..5e274b06be2 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<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);
+ }
}
diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 9987d352833..8bcc5d0a94b 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -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.",
diff --git a/ui/src/config/router.js b/ui/src/config/router.js
index 3e5d8677b34..43e8efd7b5d 100644
--- a/ui/src/config/router.js
+++ b/ui/src/config/router.js
@@ -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')
}
]
},
diff --git a/ui/src/config/section/user.js b/ui/src/config/section/user.js
index 65c1a17f760..233f6cf49f7 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 && record.state === 'enabled' &&
record.usersource === 'native'
+ }
+ },
{
api: 'updateUser',
icon: 'key-outlined',
diff --git a/ui/src/permission.js b/ui/src/permission.js
index 266dc992c8d..671d6626b93 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 911234d9b71..c7ab2f0c536 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 5c78b8a592f..6a818d58723 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 0b1f921ab86..5fc2cd74d21 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 acde7583887..704c55c4814 100644
--- a/ui/src/views/iam/AddUser.vue
+++ b/ui/src/views/iam/AddUser.vue
@@ -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)
},
diff --git a/ui/src/views/iam/ChangeUserPassword.vue
b/ui/src/views/iam/ChangeUserPassword.vue
index d5c52b8f637..f736557289c 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')"/>
</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'),
diff --git a/ui/src/views/iam/ForceChangePassword.vue
b/ui/src/views/iam/ForceChangePassword.vue
new file mode 100644
index 00000000000..31a4a2c512b
--- /dev/null
+++ b/ui/src/views/iam/ForceChangePassword.vue
@@ -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>