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>


Reply via email to