From a00dd7110cb806b83d2afc19d4a77695699a948d Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Mon, 15 Dec 2025 18:04:53 +0530 Subject: [PATCH 01/13] API modifications for passwordchangerequired --- .../apache/cloudstack/api/ApiConstants.java | 1 + .../api/command/admin/user/UpdateUserCmd.java | 18 +++++++++++-- .../api/response/LoginCmdResponse.java | 12 +++++++++ .../resourcedetail/UserDetailVO.java | 1 + .../main/java/com/cloud/api/ApiServer.java | 13 ++++++++++ .../com/cloud/user/AccountManagerImpl.java | 25 +++++++++++++++++++ ui/public/locales/en.json | 1 + ui/src/config/router.js | 9 +++++++ ui/src/store/modules/user.js | 3 +++ ui/src/views/iam/ChangeUserPassword.vue | 12 ++++++++- 10 files changed, 92 insertions(+), 3 deletions(-) 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 8fca652518f2..5506cb82e294 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -1256,6 +1256,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/UpdateUserCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java index 3d7f51ae2204..586c1e09ac8c 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmd.java @@ -29,6 +29,7 @@ import org.apache.cloudstack.api.response.UserResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.region.RegionService; +import org.apache.commons.lang.BooleanUtils; import com.cloud.user.Account; import com.cloud.user.User; @@ -38,6 +39,8 @@ requestHasSensitiveInfo = true, responseHasSensitiveInfo = true) public class UpdateUserCmd extends BaseCmd { + @Inject + private RegionService _regionService; ///////////////////////////////////////////////////// //////////////// API parameters ///////////////////// @@ -85,8 +88,11 @@ public class UpdateUserCmd extends BaseCmd { "This parameter is only used to mandate 2FA, not to disable 2FA", since = "4.18.0.0") private Boolean mandate2FA; - @Inject - private RegionService _regionService; + @Parameter(name = ApiConstants.PASSWORD_CHANGE_REQUIRED, + type = CommandType.BOOLEAN, + description = "Provide true to mandate the User to reset password on next login.", + since = "4.23.0") + private Boolean passwordChangeRequired; ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// @@ -193,4 +199,12 @@ public Long getApiResourceId() { public ApiCommandResourceType getApiResourceType() { return ApiCommandResourceType.User; } + + public Boolean isPasswordChangeRequired() { + return BooleanUtils.isTrue(passwordChangeRequired); + } + + public void setPasswordChangeRequired(Boolean passwordChangeRequired) { + this.passwordChangeRequired = 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 43f92db84cb5..5189c96de77a 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 = "Is User required to change password on next login.", since = "4.23.0") + private String passwordChangeRequired; + public String getUsername() { return username; } @@ -223,4 +227,12 @@ public String getManagementServerId() { public void setManagementServerId(String managementServerId) { this.managementServerId = managementServerId; } + + public String getPasswordChangeRequired() { + return passwordChangeRequired; + } + + public void setPasswordChangeRequired(String passwordChangeRequired) { + this.passwordChangeRequired = passwordChangeRequired; + } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java index d0cfcc3d4396..4e7289dae128 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java @@ -48,6 +48,7 @@ public class UserDetailVO implements ResourceDetail { public static final String Setup2FADetail = "2FASetupStatus"; public static final String PasswordResetToken = "PasswordResetToken"; public static final String PasswordResetTokenExpiryDate = "PasswordResetTokenExpiryDate"; + public static final String PasswordChangeRequired = "PasswordChangeRequired"; public UserDetailVO() { } diff --git a/server/src/main/java/com/cloud/api/ApiServer.java b/server/src/main/java/com/cloud/api/ApiServer.java index 5a3c8c2c7179..bc2db3137152 100644 --- a/server/src/main/java/com/cloud/api/ApiServer.java +++ b/server/src/main/java/com/cloud/api/ApiServer.java @@ -116,9 +116,11 @@ import org.apache.cloudstack.framework.messagebus.MessageDispatcher; import org.apache.cloudstack.framework.messagebus.MessageHandler; import org.apache.cloudstack.managed.context.ManagedContextRunnable; +import org.apache.cloudstack.resourcedetail.UserDetailVO; import org.apache.cloudstack.user.UserPasswordResetManager; import org.apache.cloudstack.utils.identity.ManagementServerNode; import org.apache.commons.codec.binary.Base64; +import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.EnumUtils; import org.apache.http.ConnectionClosedException; import org.apache.http.HttpException; @@ -194,6 +196,7 @@ import com.google.gson.reflect.TypeToken; import static com.cloud.user.AccountManagerImpl.apiKeyAccess; +import static org.apache.cloudstack.api.ApiConstants.PASSWORD_CHANGE_REQUIRED; import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled; @Component @@ -1227,6 +1230,9 @@ private ResponseObject createLoginResponse(HttpSession session) { if (ApiConstants.MANAGEMENT_SERVER_ID.equalsIgnoreCase(attrName)) { response.setManagementServerId(attrObj.toString()); } + if (PASSWORD_CHANGE_REQUIRED.endsWith(attrName)) { + response.setPasswordChangeRequired(attrObj.toString()); + } } } response.setResponseName("loginresponse"); @@ -1327,6 +1333,13 @@ public ResponseObject loginUser(final HttpSession session, final String username final String sessionKey = Base64.encodeBase64URLSafeString(sessionKeyBytes); session.setAttribute(ApiConstants.SESSIONKEY, sessionKey); + if (!MapUtils.isEmpty(userAcct.getDetails())) { + String needPwdChangeStr = userAcct.getDetails().getOrDefault(UserDetailVO.PasswordChangeRequired, null); + if (needPwdChangeStr != null) { + boolean needPwdChange = "true".equalsIgnoreCase(needPwdChangeStr); + session.setAttribute(PASSWORD_CHANGE_REQUIRED, needPwdChange); + } + } return createLoginResponse(session); } throw new CloudAuthenticationException("Failed to authenticate user " + username + " in domain " + domainId + "; please provide valid credentials"); diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index bbfc8fd36826..081d1063d8a6 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; @@ -1580,9 +1582,30 @@ public UserAccount updateUser(UpdateUserCmd updateUserCmd) { user.setUser2faEnabled(true); } _userDao.update(user.getId(), user); + updatePasswordChangeRequired(caller, updateUserCmd, user); return _userAccountDao.findById(user.getId()); } + private void updatePasswordChangeRequired(User caller, UpdateUserCmd updateUserCmd, UserVO user) { + if (StringUtils.isNotBlank(updateUserCmd.getPassword()) && isNormalUser(user.getAccountId())) { + boolean isPasswordResetRequired = updateUserCmd.isPasswordChangeRequired(); + // Admins only can enforce passwordChangeRequired for user + if ((isRootAdmin(caller.getId()) || isDomainAdmin(caller.getAccountId()))) { + if (isPasswordResetRequired) { + _userDetailsDao.addDetail(user.getId(), PasswordChangeRequired, "true", false); + } + } + + // Remove passwordChangeRequired if user updating own pwd or admin has not enforced it + if ((caller.getId() == user.getId()) || !isPasswordResetRequired) { + UserDetailVO userDetailVO = _userDetailsDao.findDetail(user.getId(), PasswordChangeRequired); + if (userDetailVO != null) { + _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)); @@ -2841,6 +2864,8 @@ public UserAccount authenticateUser(final String username, final String password logger.debug(String.format("User: %s in domain %d has successfully logged in, auth time duration - %d ms", username, domainId, validUserLastAuthTimeDurationInMs)); } + user.setDetails(_userDetailsDao.listDetailsKeyPairs(user.getId())); + return user; } else { if (logger.isDebugEnabled()) { diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 028406bbc682..149b1f274c3e 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1096,6 +1096,7 @@ "label.forced": "Force", "label.force.convert.to.pool": "Force converting to storage pool directly (not using temporary storage for conversion)", "label.force.ms.to.import.vm.files": "Enable to force OVF Download via Management Server. Disable to use KVM Host ovftool (if installed)", +"label.force.password.reset": "Force password change on next login", "label.force.update.os.type": "Force update OS type", "label.force.stop": "Force stop", "label.force.reboot": "Force reboot", diff --git a/ui/src/config/router.js b/ui/src/config/router.js index 582fbaaf2f35..d2322703082d 100644 --- a/ui/src/config/router.js +++ b/ui/src/config/router.js @@ -313,6 +313,15 @@ export const constantRouterMap = [ path: 'resetPassword', name: 'resetPassword', component: () => import(/* webpackChunkName: "auth" */ '@/views/auth/ResetPassword') + }, + { + path: 'forceChangePassword', + name: 'ForceChangePassword', + component: () => import('@/views/iam/ChangeUserPassword'), + meta: { + title: 'label.change.password', + hidden: true + } } ] }, diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js index 2c0edf656d73..101fb3f43b4a 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -207,6 +207,9 @@ const user = { return new Promise((resolve, reject) => { login(userInfo).then(response => { const result = response.loginresponse || {} + if (result.passwordchangerequired) { + console.log('Password change required for user ', userInfo.username) + } Cookies.set('account', result.account, { expires: 1 }) Cookies.set('domainid', result.domainid, { expires: 1 }) Cookies.set('role', result.type, { expires: 1 }) diff --git a/ui/src/views/iam/ChangeUserPassword.vue b/ui/src/views/iam/ChangeUserPassword.vue index d5c52b8f637e..3105e5ef28f8 100644 --- a/ui/src/views/iam/ChangeUserPassword.vue +++ b/ui/src/views/iam/ChangeUserPassword.vue @@ -49,7 +49,10 @@ v-model:value="form.confirmpassword" :placeholder="$t('label.confirmpassword.description')"/> - + + + {{ $t('label.force.password.reset') }} +
{{ $t('label.cancel') }} {{ $t('label.ok') }} @@ -99,6 +102,9 @@ export default { ] }) }, + isNormalUserResource () { + return ['User'].includes(this.resource.roletype) + }, isAdminOrDomainAdmin () { return ['Admin', 'DomainAdmin'].includes(this.$store.getters.userInfo.roletype) }, @@ -134,6 +140,10 @@ export default { if (this.isValidValueForKey(values, 'currentpassword') && values.currentpassword.length > 0) { params.currentpassword = values.currentpassword } + + if (this.isAdminOrDomainAdmin && values.passwordChangeRequired) { + params.passwordchangerequired = values.passwordChangeRequired + } postAPI('updateUser', params).then(json => { this.$notification.success({ message: this.$t('label.action.change.password'), From ea927593d8b697d347ed1481809cab7287d2f6c1 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Wed, 17 Dec 2025 16:29:17 +0530 Subject: [PATCH 02/13] ui login flow for passwordchangerequired --- ui/public/locales/en.json | 1 + ui/src/config/router.js | 8 +- ui/src/permission.js | 23 +++ ui/src/store/getters.js | 3 +- ui/src/store/modules/user.js | 70 +++++++- ui/src/store/mutation-types.js | 2 + ui/src/views/iam/ForceChangePassword.vue | 220 +++++++++++++++++++++++ 7 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 ui/src/views/iam/ForceChangePassword.vue diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 149b1f274c3e..89ac99b6db0b 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -3369,6 +3369,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.", diff --git a/ui/src/config/router.js b/ui/src/config/router.js index d2322703082d..5300385eeac7 100644 --- a/ui/src/config/router.js +++ b/ui/src/config/router.js @@ -316,12 +316,8 @@ export const constantRouterMap = [ }, { path: 'forceChangePassword', - name: 'ForceChangePassword', - component: () => import('@/views/iam/ChangeUserPassword'), - meta: { - title: 'label.change.password', - hidden: true - } + name: 'forceChangePassword', + component: () => import(/* webpackChunkName: "auth" */ '@/views/iam/ForceChangePassword') } ] }, diff --git a/ui/src/permission.js b/ui/src/permission.js index 266dc992c8db..208a4201fb3a 100644 --- a/ui/src/permission.js +++ b/ui/src/permission.js @@ -94,6 +94,17 @@ router.beforeEach((to, from, next) => { } store.commit('SET_LOGIN_FLAG', true) } + 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 + } if (Object.keys(store.getters.apis).length === 0) { const cachedApis = vueProps.$localStorage.get(APIS, {}) if (Object.keys(cachedApis).length > 0) { @@ -102,6 +113,18 @@ router.beforeEach((to, from, next) => { store .dispatch('GetInfo') .then(apis => { + if (store.getters.passwordChangeRequired) { + // Only allow the Change Password page + if (to.path === '/user/forceChangePassword') { + next() + } else { + // Redirect everything else (including dashboard, wildcards) to Change Password + next({ path: '/user/forceChangePassword' }) + NProgress.done() + } + return + } + store.dispatch('GenerateRoutes', { apis }).then(() => { store.getters.addRouters.map(route => { router.addRoute(route) diff --git a/ui/src/store/getters.js b/ui/src/store/getters.js index 911234d9b715..c7ab2f0c536b 100644 --- a/ui/src/store/getters.js +++ b/ui/src/store/getters.js @@ -55,7 +55,8 @@ const getters = { loginFlag: state => state.user.loginFlag, allProjects: (state) => state.app.allProjects, customHypervisorName: state => state.user.customHypervisorName, - readyForShutdownPollingJob: state => state.user.readyForShutdownPollingJob + readyForShutdownPollingJob: state => state.user.readyForShutdownPollingJob, + passwordChangeRequired: state => state.user.passwordChangeRequired } export default getters diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js index 101fb3f43b4a..b8d3345ee47c 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -44,7 +44,9 @@ import { MS_ID, OAUTH_DOMAIN, OAUTH_PROVIDER, - LATEST_CS_VERSION + LATEST_CS_VERSION, + PASSWORD_CHANGE_REQUIRED, + LOGIN_SOURCE } from '@/store/mutation-types' import { @@ -80,7 +82,9 @@ const user = { twoFaProvider: '', twoFaIssuer: '', customHypervisorName: 'Custom', - readyForShutdownPollingJob: '' + readyForShutdownPollingJob: '', + passwordChangeRequired: false, + loginSource: '' }, mutations: { @@ -196,6 +200,17 @@ 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') + } + }, + SET_LOGIN_SOURCE: (state, source) => { + vueProps.$localStorage.set('LOGIN_SOURCE', source) } }, @@ -207,9 +222,6 @@ const user = { return new Promise((resolve, reject) => { login(userInfo).then(response => { const result = response.loginresponse || {} - if (result.passwordchangerequired) { - console.log('Password change required for user ', userInfo.username) - } Cookies.set('account', result.account, { expires: 1 }) Cookies.set('domainid', result.domainid, { expires: 1 }) Cookies.set('role', result.type, { expires: 1 }) @@ -247,6 +259,14 @@ const user = { if (result && result.managementserverid) { commit('SET_MS_ID', result.managementserverid) } + commit('SET_LOGIN_SOURCE', 'password') + if (result.passwordchangerequired && result.passwordchangerequired === 'true') { + 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() @@ -301,7 +321,8 @@ const user = { const latestVersion = vueProps.$localStorage.get(LATEST_CS_VERSION, { version: '', fetchedTs: 0 }) commit('SET_LATEST_VERSION', latestVersion) notification.destroy() - + commit('SET_LOGIN_SOURCE', 'oauth') + commit('SET_PASSWORD_CHANGE_REQUIRED', false) resolve() }).catch(error => { reject(error) @@ -311,6 +332,13 @@ const user = { GetInfo ({ commit }, switchDomain) { return new Promise((resolve, reject) => { + // A. Restore Lock State + const loginSource = vueProps.$localStorage.get(LOGIN_SOURCE) + const isPwdChangeRequired = vueProps.$localStorage.get(PASSWORD_CHANGE_REQUIRED) === 'true' + // Only lock if source was password + const isLocked = (loginSource === 'password' && isPwdChangeRequired) + commit('SET_PASSWORD_CHANGE_REQUIRED', isLocked) + const cachedApis = switchDomain ? {} : vueProps.$localStorage.get(APIS, {}) const cachedZones = vueProps.$localStorage.get(ZONES, []) const cachedTimezoneOffset = vueProps.$localStorage.get(TIMEZONE_OFFSET, 0.0) @@ -326,6 +354,31 @@ const user = { commit('SET_DOMAIN_STORE', domainStore) commit('SET_DARK_MODE', darkMode) commit('SET_LATEST_VERSION', latestVersion) + + if (isLocked) { + console.log('Password change required. Fetching user info only.') + + // We MUST fetch listUsers so the UI Header (Avatar/Name) works + getAPI('listUsers', { id: Cookies.get('userid') }).then(response => { + const result = response.listusersresponse.user[0] + + // Populate State + commit('SET_INFO', result) + commit('SET_NAME', result.firstname + ' ' + result.lastname) + if (result.icon?.base64image) commit('SET_AVATAR', result.icon.base64image) + + // DO NOT fetch Apis + // DO NOT fetch Zones + // DO NOT call GenerateRoutes + + resolve({}) // Resolve empty to signal permission.js to proceed + }).catch(error => { + reject(error) + }) + + return // Stop execution + } + if (hasAuth) { console.log('Login detected, using cached APIs') commit('SET_ZONES', cachedZones) @@ -488,6 +541,11 @@ const user = { vueProps.$localStorage.remove(ACCESS_TOKEN) vueProps.$localStorage.remove(HEADER_NOTICES) + vueProps.$localStorage.remove(PASSWORD_CHANGE_REQUIRED) + vueProps.$localStorage.remove(LOGIN_SOURCE) + commit('SET_PASSWORD_CHANGE_REQUIRED', false) + commit('SET_LOGIN_SOURCE', '') + logout(state.token).then(() => { message.destroy() if (cloudianUrl) { diff --git a/ui/src/store/mutation-types.js b/ui/src/store/mutation-types.js index 0b1f921ab86e..38f390e0b94c 100644 --- a/ui/src/store/mutation-types.js +++ b/ui/src/store/mutation-types.js @@ -43,6 +43,8 @@ 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 LOGIN_SOURCE = 'LOGIN_SOURCE' export const CONTENT_WIDTH_TYPE = { Fluid: 'Fluid', diff --git a/ui/src/views/iam/ForceChangePassword.vue b/ui/src/views/iam/ForceChangePassword.vue new file mode 100644 index 000000000000..2979a225e9c2 --- /dev/null +++ b/ui/src/views/iam/ForceChangePassword.vue @@ -0,0 +1,220 @@ +// 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. + + + + + + From 555b69be23685c81e93c37f143625bc794dc1b67 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Wed, 17 Dec 2025 22:10:22 +0530 Subject: [PATCH 03/13] add passwordchangerequired in listUsers API response, it will be used in UI to render reset password form --- .../cloudstack/api/response/UserResponse.java | 12 ++ .../META-INF/db/views/cloud.user_view.sql | 8 +- .../api/query/dao/UserAccountJoinDaoImpl.java | 1 + .../cloud/api/query/vo/UserAccountJoinVO.java | 7 + ui/public/locales/en.json | 2 + ui/src/store/modules/user.js | 29 ++-- ui/src/views/iam/ForceChangePassword.vue | 128 +++++++++++++----- 7 files changed, 132 insertions(+), 55 deletions(-) diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java index 5e4e6e1f3c8b..0cd397fbec02 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java @@ -132,6 +132,10 @@ public class UserResponse extends BaseResponse implements SetResourceIconRespons @Param(description = "whether api key access is Enabled, Disabled or set to Inherit (it inherits the value from the parent)", since = "4.20.1.0") ApiConstants.ApiKeyAccess apiKeyAccess; + @SerializedName(value = ApiConstants.PASSWORD_CHANGE_REQUIRED) + @Param(description = "Is User required to change password on next login.", since = "4.23.0") + private Boolean passwordChangeRequired; + @Override public String getObjectId() { return this.getId(); @@ -317,4 +321,12 @@ public void set2FAmandated(Boolean is2FAmandated) { public void setApiKeyAccess(Boolean apiKeyAccess) { this.apiKeyAccess = ApiConstants.ApiKeyAccess.fromBoolean(apiKeyAccess); } + + public Boolean isPasswordChangeRequired() { + return passwordChangeRequired; + } + + public void setPasswordChangeRequired(Boolean passwordChangeRequired) { + this.passwordChangeRequired = passwordChangeRequired; + } } diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.user_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.user_view.sql index 340cfa9055fb..1e781f8ef685 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.user_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.user_view.sql @@ -53,7 +53,8 @@ select async_job.uuid job_uuid, async_job.job_status job_status, async_job.account_id job_account_id, - user.is_user_2fa_enabled is_user_2fa_enabled + user.is_user_2fa_enabled is_user_2fa_enabled, + `user_details`.`value` AS `password_change_required` from `cloud`.`user` inner join @@ -63,4 +64,7 @@ from left join `cloud`.`async_job` ON async_job.instance_id = user.id and async_job.instance_type = 'User' - and async_job.job_status = 0; + and async_job.job_status = 0 + left join + `cloud`.`user_details` AS `user_details` ON `user_details`.`user_id` = `user`.`id` + and `user_details`.`name` = 'PasswordChangeRequired'; diff --git a/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java index f2c234b4c7cb..8d2bd26d8f1c 100644 --- a/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java @@ -73,6 +73,7 @@ public UserResponse newUserResponse(ResponseView view, UserAccountJoinVO usr) { userResponse.setSecretKey(usr.getSecretKey()); userResponse.setIsDefault(usr.isDefault()); userResponse.set2FAenabled(usr.isUser2faEnabled()); + userResponse.setPasswordChangeRequired(usr.isPasswordChangeRequired()); long domainId = usr.getDomainId(); boolean is2FAmandated = Boolean.TRUE.equals(AccountManagerImpl.enableUserTwoFactorAuthentication.valueIn(domainId)) && Boolean.TRUE.equals(AccountManagerImpl.mandateUserTwoFactorAuthentication.valueIn(domainId)); userResponse.set2FAmandated(is2FAmandated); diff --git a/server/src/main/java/com/cloud/api/query/vo/UserAccountJoinVO.java b/server/src/main/java/com/cloud/api/query/vo/UserAccountJoinVO.java index ad005eebb76e..c1db804aa8e5 100644 --- a/server/src/main/java/com/cloud/api/query/vo/UserAccountJoinVO.java +++ b/server/src/main/java/com/cloud/api/query/vo/UserAccountJoinVO.java @@ -136,6 +136,9 @@ public class UserAccountJoinVO extends BaseViewVO implements InternalIdentity, I @Column(name = "api_key_access") Boolean apiKeyAccess; + @Column(name = "password_change_required") + Boolean passwordChangeRequired; + public UserAccountJoinVO() { } @@ -288,4 +291,8 @@ public boolean isUser2faEnabled() { public Boolean getApiKeyAccess() { return apiKeyAccess; } + + public Boolean isPasswordChangeRequired() { + return passwordChangeRequired; + } } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 89ac99b6db0b..85a7246577fb 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -3124,6 +3124,7 @@ "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.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.", @@ -3673,6 +3674,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/store/modules/user.js b/ui/src/store/modules/user.js index b8d3345ee47c..2371e7b053fa 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -332,13 +332,6 @@ const user = { GetInfo ({ commit }, switchDomain) { return new Promise((resolve, reject) => { - // A. Restore Lock State - const loginSource = vueProps.$localStorage.get(LOGIN_SOURCE) - const isPwdChangeRequired = vueProps.$localStorage.get(PASSWORD_CHANGE_REQUIRED) === 'true' - // Only lock if source was password - const isLocked = (loginSource === 'password' && isPwdChangeRequired) - commit('SET_PASSWORD_CHANGE_REQUIRED', isLocked) - const cachedApis = switchDomain ? {} : vueProps.$localStorage.get(APIS, {}) const cachedZones = vueProps.$localStorage.get(ZONES, []) const cachedTimezoneOffset = vueProps.$localStorage.get(TIMEZONE_OFFSET, 0.0) @@ -355,28 +348,22 @@ const user = { commit('SET_DARK_MODE', darkMode) commit('SET_LATEST_VERSION', latestVersion) - if (isLocked) { - console.log('Password change required. Fetching user info only.') - - // We MUST fetch listUsers so the UI Header (Avatar/Name) works + // This block is to enforce password change for first time login after admin resets password + const loginSource = vueProps.$localStorage.get(LOGIN_SOURCE) + const isPwdChangeRequired = vueProps.$localStorage.get(PASSWORD_CHANGE_REQUIRED) === 'true' + const isPwdChangeRequiredForLogin = (loginSource === 'password' && isPwdChangeRequired) + commit('SET_PASSWORD_CHANGE_REQUIRED', isPwdChangeRequiredForLogin) + if (isPwdChangeRequiredForLogin) { getAPI('listUsers', { id: Cookies.get('userid') }).then(response => { const result = response.listusersresponse.user[0] - - // Populate State commit('SET_INFO', result) commit('SET_NAME', result.firstname + ' ' + result.lastname) if (result.icon?.base64image) commit('SET_AVATAR', result.icon.base64image) - - // DO NOT fetch Apis - // DO NOT fetch Zones - // DO NOT call GenerateRoutes - - resolve({}) // Resolve empty to signal permission.js to proceed + resolve({}) }).catch(error => { reject(error) }) - - return // Stop execution + return } if (hasAuth) { diff --git a/ui/src/views/iam/ForceChangePassword.vue b/ui/src/views/iam/ForceChangePassword.vue index 2979a225e9c2..d872d75c0b93 100644 --- a/ui/src/views/iam/ForceChangePassword.vue +++ b/ui/src/views/iam/ForceChangePassword.vue @@ -24,17 +24,38 @@
{{ $t('label.action.change.password') }}
-
- {{ $t('message.change.password') }} +
+ {{ $t('message.change.password.required') }}
+ +
+
+
+ {{ $t('message.success.change.password') }} +
+
+ {{ $t('message.please.login.new.password') }} +
+ + {{ $t('label.login') }} + +
{{ $t('label.logout') }}
+
@@ -87,33 +109,34 @@ 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 + loading: false, + isSubmitted: false } }, - beforeCreate () { - this.apiParams = this.$getApiParams('updateUser') - }, created () { - this.initForm() + this.formRef = ref() + this.form = reactive({}) + this.isPasswordChangeRequired() }, - methods: { - initForm () { - this.formRef = ref() - this.form = reactive({}) - this.rules = reactive({ - currentpassword: [{ required: true, message: this.$t('message.error.current.password') }], - password: [{ required: true, message: this.$t('message.error.new.password') }], + computed: { + rules () { + return { + currentpassword: [{ required: true, message: this.$t('message.error.current.password') || 'Please enter current password' }], + password: [{ required: true, message: this.$t('message.error.new.password') || 'Please enter new password' }], confirmpassword: [ - { required: true, message: this.$t('message.error.confirm.password') }, - { validator: this.validateTwoPassword } + { required: true, message: this.$t('message.error.confirm.password') || 'Please confirm new password' }, + { validator: this.validateTwoPassword, trigger: 'change' } ] - }) - }, + } + } + }, + methods: { async validateTwoPassword (rule, value) { if (!value || value.length === 0) { return Promise.resolve() @@ -130,9 +153,6 @@ export default { return Promise.resolve() } }, - isValidValueForKey (obj, key) { - return key in obj && obj[key] != null - }, handleSubmit (e) { e.preventDefault() if (this.loading) return @@ -147,9 +167,8 @@ export default { currentpassword: values.currentpassword } postAPI('updateUser', params).then(() => { - this.$message.success(this.$t('message.success.change.password'), 5) - console.log('Password changed successfully.') - this.handleLogout() + this.$message.success(this.$t('message.success.change.password')) + this.isSubmitted = true }).catch(error => { console.error(error) this.$notification.error({ @@ -164,17 +183,39 @@ export default { console.log('Validation failed:', error) }) }, - handleLogout () { - this.$store.dispatch('Logout').then(() => { + async handleLogout () { + try { + await this.$store.dispatch('Logout') + } catch (e) { + console.error('Logout failed:', e) + } finally { Cookies.remove('userid') Cookies.remove('token') + this.$localStorage.remove(PASSWORD_CHANGE_REQUIRED) this.$router.replace({ path: '/user/login' }) - }).catch(err => { - this.$message.error({ - title: 'Failed to Logout', - description: err.message - }) - }) + } + }, + async isPasswordChangeRequired () { + try { + this.loading = true + const user = await this.getUserInfo() + if (user && !user.passwordchangerequired) { + this.isSubmitted = true + this.$router.replace({ path: '/user/login' }) + } + } catch (e) { + console.error('Failed to resolve user info:', e) + } finally { + this.loading = false + } + }, + async getUserInfo () { + const userInfo = this.$store.getters.userInfo + if (userInfo && userInfo.id) { + return userInfo + } + await this.$store.dispatch('GetInfo') + return this.$store.getters.userInfo } } } @@ -217,4 +258,27 @@ export default { } } } + +.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; + } +} From 10225ed25fba800425f4dd9acfbc59b27af25b2a Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Thu, 18 Dec 2025 16:00:29 +0530 Subject: [PATCH 04/13] cleanup redundant LOGIN_SOURCE and limiting apis for first time login --- .../discovery/ApiDiscoveryServiceImpl.java | 24 ++++++++++++---- .../discovery/ApiDiscoveryTest.java | 7 +++++ ui/public/locales/en.json | 2 +- ui/src/permission.js | 4 +-- ui/src/store/modules/user.js | 28 ++++++------------- ui/src/store/mutation-types.js | 1 - ui/src/views/iam/ChangeUserPassword.vue | 6 ++-- 7 files changed, 41 insertions(+), 31 deletions(-) diff --git a/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java b/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java index 452b95cf2c05..07e6759b7cad 100644 --- a/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java +++ b/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java @@ -44,8 +44,10 @@ import org.apache.cloudstack.api.response.ApiParameterResponse; import org.apache.cloudstack.api.response.ApiResponseResponse; import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.resourcedetail.UserDetailVO; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.StringUtils; import org.reflections.ReflectionUtils; import org.springframework.stereotype.Component; @@ -55,6 +57,7 @@ import com.cloud.user.Account; import com.cloud.user.AccountService; import com.cloud.user.User; +import com.cloud.user.UserAccount; import com.cloud.utils.ReflectUtil; import com.cloud.utils.component.ComponentLifecycleBase; import com.cloud.utils.component.PluggableService; @@ -280,12 +283,23 @@ public ListResponse listApis(User user, String name) { ReflectionToStringBuilderUtils.reflectOnlySelectedFields(account, "accountName", "uuid"))); } - if (role.getRoleType() == RoleType.Admin && role.getId() == RoleType.Admin.getId()) { - logger.info(String.format("Account [%s] is Root Admin, all APIs are allowed.", - ReflectionToStringBuilderUtils.reflectOnlySelectedFields(account, "accountName", "uuid"))); + // Limit APIs on first login requiring password change + UserAccount userAccount = accountService.getUserAccountById(user.getId()); + if (MapUtils.isNotEmpty(userAccount.getDetails()) && + userAccount.getDetails().containsKey(UserDetailVO.PasswordChangeRequired)) { + + String needPasswordChange = userAccount.getDetails().get(UserDetailVO.PasswordChangeRequired); + if ("true".equalsIgnoreCase(needPasswordChange)) { + apisAllowed = Arrays.asList("login", "logout", "updateUser", "listUsers", "listApis"); + } } else { - for (APIChecker apiChecker : _apiAccessCheckers) { - apisAllowed = apiChecker.getApisAllowedToUser(role, user, apisAllowed); + if (role.getRoleType() == RoleType.Admin && role.getId() == RoleType.Admin.getId()) { + logger.info(String.format("Account [%s] is Root Admin, all APIs are allowed.", + ReflectionToStringBuilderUtils.reflectOnlySelectedFields(account, "accountName", "uuid"))); + } else { + for (APIChecker apiChecker : _apiAccessCheckers) { + apisAllowed = apiChecker.getApisAllowedToUser(role, user, apisAllowed); + } } } diff --git a/plugins/api/discovery/src/test/java/org/apache/cloudstack/discovery/ApiDiscoveryTest.java b/plugins/api/discovery/src/test/java/org/apache/cloudstack/discovery/ApiDiscoveryTest.java index eea78d8abb93..8d6eaba81ec1 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,7 @@ import com.cloud.user.AccountService; import com.cloud.user.AccountVO; import com.cloud.user.User; +import com.cloud.user.UserAccount; import com.cloud.user.UserVO; import org.apache.cloudstack.acl.APIChecker; @@ -44,6 +45,7 @@ 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 +68,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() { diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 85a7246577fb..7a284bd7a8cc 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -527,6 +527,7 @@ "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.service.offering": "Change service offering", "label.character": "Character", "label.checksum": "Checksum", @@ -1096,7 +1097,6 @@ "label.forced": "Force", "label.force.convert.to.pool": "Force converting to storage pool directly (not using temporary storage for conversion)", "label.force.ms.to.import.vm.files": "Enable to force OVF Download via Management Server. Disable to use KVM Host ovftool (if installed)", -"label.force.password.reset": "Force password change on next login", "label.force.update.os.type": "Force update OS type", "label.force.stop": "Force stop", "label.force.reboot": "Force reboot", diff --git a/ui/src/permission.js b/ui/src/permission.js index 208a4201fb3a..671d6626b931 100644 --- a/ui/src/permission.js +++ b/ui/src/permission.js @@ -94,12 +94,11 @@ router.beforeEach((to, from, next) => { } store.commit('SET_LOGIN_FLAG', true) } + // store already loaded 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() } @@ -113,6 +112,7 @@ 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') { diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js index 2371e7b053fa..5ece6a38e5ff 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -45,8 +45,7 @@ import { OAUTH_DOMAIN, OAUTH_PROVIDER, LATEST_CS_VERSION, - PASSWORD_CHANGE_REQUIRED, - LOGIN_SOURCE + PASSWORD_CHANGE_REQUIRED } from '@/store/mutation-types' import { @@ -83,8 +82,7 @@ const user = { twoFaIssuer: '', customHypervisorName: 'Custom', readyForShutdownPollingJob: '', - passwordChangeRequired: false, - loginSource: '' + passwordChangeRequired: false }, mutations: { @@ -204,13 +202,10 @@ const user = { SET_PASSWORD_CHANGE_REQUIRED: (state, required) => { state.passwordChangeRequired = required if (required) { - vueProps.$localStorage.set('PASSWORD_CHANGE_REQUIRED', 'true') + vueProps.$localStorage.set(PASSWORD_CHANGE_REQUIRED, true) } else { - vueProps.$localStorage.remove('PASSWORD_CHANGE_REQUIRED') + vueProps.$localStorage.remove(PASSWORD_CHANGE_REQUIRED) } - }, - SET_LOGIN_SOURCE: (state, source) => { - vueProps.$localStorage.set('LOGIN_SOURCE', source) } }, @@ -259,8 +254,7 @@ const user = { if (result && result.managementserverid) { commit('SET_MS_ID', result.managementserverid) } - commit('SET_LOGIN_SOURCE', 'password') - if (result.passwordchangerequired && result.passwordchangerequired === 'true') { + if (result.passwordchangerequired) { commit('SET_PASSWORD_CHANGE_REQUIRED', true) commit('SET_APIS', {}) vueProps.$localStorage.remove(APIS) @@ -321,8 +315,6 @@ const user = { const latestVersion = vueProps.$localStorage.get(LATEST_CS_VERSION, { version: '', fetchedTs: 0 }) commit('SET_LATEST_VERSION', latestVersion) notification.destroy() - commit('SET_LOGIN_SOURCE', 'oauth') - commit('SET_PASSWORD_CHANGE_REQUIRED', false) resolve() }).catch(error => { reject(error) @@ -349,11 +341,9 @@ const user = { commit('SET_LATEST_VERSION', latestVersion) // This block is to enforce password change for first time login after admin resets password - const loginSource = vueProps.$localStorage.get(LOGIN_SOURCE) - const isPwdChangeRequired = vueProps.$localStorage.get(PASSWORD_CHANGE_REQUIRED) === 'true' - const isPwdChangeRequiredForLogin = (loginSource === 'password' && isPwdChangeRequired) - commit('SET_PASSWORD_CHANGE_REQUIRED', isPwdChangeRequiredForLogin) - if (isPwdChangeRequiredForLogin) { + const isPwdChangeRequired = vueProps.$localStorage.get(PASSWORD_CHANGE_REQUIRED) + commit('SET_PASSWORD_CHANGE_REQUIRED', isPwdChangeRequired) + if (isPwdChangeRequired) { getAPI('listUsers', { id: Cookies.get('userid') }).then(response => { const result = response.listusersresponse.user[0] commit('SET_INFO', result) @@ -529,9 +519,7 @@ const user = { vueProps.$localStorage.remove(HEADER_NOTICES) vueProps.$localStorage.remove(PASSWORD_CHANGE_REQUIRED) - vueProps.$localStorage.remove(LOGIN_SOURCE) commit('SET_PASSWORD_CHANGE_REQUIRED', false) - commit('SET_LOGIN_SOURCE', '') logout(state.token).then(() => { message.destroy() diff --git a/ui/src/store/mutation-types.js b/ui/src/store/mutation-types.js index 38f390e0b94c..5fc2cd74d213 100644 --- a/ui/src/store/mutation-types.js +++ b/ui/src/store/mutation-types.js @@ -44,7 +44,6 @@ 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 LOGIN_SOURCE = 'LOGIN_SOURCE' export const CONTENT_WIDTH_TYPE = { Fluid: 'Fluid', diff --git a/ui/src/views/iam/ChangeUserPassword.vue b/ui/src/views/iam/ChangeUserPassword.vue index 3105e5ef28f8..90c67ecf6d7e 100644 --- a/ui/src/views/iam/ChangeUserPassword.vue +++ b/ui/src/views/iam/ChangeUserPassword.vue @@ -50,9 +50,11 @@ :placeholder="$t('label.confirmpassword.description')"/> - - {{ $t('label.force.password.reset') }} + + {{ $t('label.change.password.onlogin') }} + +
{{ $t('label.cancel') }} {{ $t('label.ok') }} From c7e48b703faf13e636c226588be33a3e63924ff9 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Thu, 18 Dec 2025 20:15:33 +0530 Subject: [PATCH 05/13] address copilot comments --- .../cloudstack/api/response/LoginCmdResponse.java | 2 +- .../apache/cloudstack/api/response/UserResponse.java | 4 ++-- .../discovery/ApiDiscoveryServiceImpl.java | 9 ++++----- server/src/main/java/com/cloud/api/ApiServer.java | 12 ++++++------ .../main/java/com/cloud/user/AccountManagerImpl.java | 2 +- ui/src/views/iam/ChangeUserPassword.vue | 2 +- ui/src/views/iam/ForceChangePassword.vue | 8 ++++---- 7 files changed, 19 insertions(+), 20 deletions(-) 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 5189c96de77a..348a1e5b368f 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 @@ -91,7 +91,7 @@ public class LoginCmdResponse extends AuthenticationCmdResponse { private String managementServerId; @SerializedName(value = ApiConstants.PASSWORD_CHANGE_REQUIRED) - @Param(description = "Is User required to change password on next login.", since = "4.23.0") + @Param(description = "Indicates whether the User is required to change password on next login.", since = "4.23.0") private String passwordChangeRequired; public String getUsername() { diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java index 0cd397fbec02..f1b1bebaaef8 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java @@ -133,7 +133,7 @@ public class UserResponse extends BaseResponse implements SetResourceIconRespons ApiConstants.ApiKeyAccess apiKeyAccess; @SerializedName(value = ApiConstants.PASSWORD_CHANGE_REQUIRED) - @Param(description = "Is User required to change password on next login.", since = "4.23.0") + @Param(description = "Indicates whether the User is required to change password on next login.", since = "4.23.0") private Boolean passwordChangeRequired; @Override @@ -323,7 +323,7 @@ public void setApiKeyAccess(Boolean apiKeyAccess) { } public Boolean isPasswordChangeRequired() { - return passwordChangeRequired; + return Boolean.TRUE.equals(passwordChangeRequired); } public void setPasswordChangeRequired(Boolean passwordChangeRequired) { 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 07e6759b7cad..41ac2cb6afe6 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 @@ -285,11 +285,10 @@ public ListResponse listApis(User user, String name) { // Limit APIs on first login requiring password change UserAccount userAccount = accountService.getUserAccountById(user.getId()); - if (MapUtils.isNotEmpty(userAccount.getDetails()) && - userAccount.getDetails().containsKey(UserDetailVO.PasswordChangeRequired)) { - - String needPasswordChange = userAccount.getDetails().get(UserDetailVO.PasswordChangeRequired); - if ("true".equalsIgnoreCase(needPasswordChange)) { + Map userAccDetails = userAccount.getDetails(); + if (MapUtils.isNotEmpty(userAccDetails)) { + String needPwdChangeStr = userAccDetails.get(UserDetailVO.PasswordChangeRequired); + if ("true".equalsIgnoreCase(needPwdChangeStr)) { apisAllowed = Arrays.asList("login", "logout", "updateUser", "listUsers", "listApis"); } } else { diff --git a/server/src/main/java/com/cloud/api/ApiServer.java b/server/src/main/java/com/cloud/api/ApiServer.java index bc2db3137152..f1fe6d964027 100644 --- a/server/src/main/java/com/cloud/api/ApiServer.java +++ b/server/src/main/java/com/cloud/api/ApiServer.java @@ -1230,7 +1230,7 @@ private ResponseObject createLoginResponse(HttpSession session) { if (ApiConstants.MANAGEMENT_SERVER_ID.equalsIgnoreCase(attrName)) { response.setManagementServerId(attrObj.toString()); } - if (PASSWORD_CHANGE_REQUIRED.endsWith(attrName)) { + if (PASSWORD_CHANGE_REQUIRED.equalsIgnoreCase(attrName)) { response.setPasswordChangeRequired(attrObj.toString()); } } @@ -1333,11 +1333,11 @@ public ResponseObject loginUser(final HttpSession session, final String username final String sessionKey = Base64.encodeBase64URLSafeString(sessionKeyBytes); session.setAttribute(ApiConstants.SESSIONKEY, sessionKey); - if (!MapUtils.isEmpty(userAcct.getDetails())) { - String needPwdChangeStr = userAcct.getDetails().getOrDefault(UserDetailVO.PasswordChangeRequired, null); - if (needPwdChangeStr != null) { - boolean needPwdChange = "true".equalsIgnoreCase(needPwdChangeStr); - session.setAttribute(PASSWORD_CHANGE_REQUIRED, needPwdChange); + Map userAccDetails = userAcct.getDetails(); + if (MapUtils.isNotEmpty(userAccDetails)) { + String needPwdChangeStr = userAccDetails.get(UserDetailVO.PasswordChangeRequired); + if ("true".equalsIgnoreCase(needPwdChangeStr)) { + session.setAttribute(PASSWORD_CHANGE_REQUIRED, true); } } return createLoginResponse(session); diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index 081d1063d8a6..0217b1483246 100644 --- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java +++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java @@ -1590,7 +1590,7 @@ private void updatePasswordChangeRequired(User caller, UpdateUserCmd updateUserC if (StringUtils.isNotBlank(updateUserCmd.getPassword()) && isNormalUser(user.getAccountId())) { boolean isPasswordResetRequired = updateUserCmd.isPasswordChangeRequired(); // Admins only can enforce passwordChangeRequired for user - if ((isRootAdmin(caller.getId()) || isDomainAdmin(caller.getAccountId()))) { + if ((isRootAdmin(caller.getAccountId()) || isDomainAdmin(caller.getAccountId()))) { if (isPasswordResetRequired) { _userDetailsDao.addDetail(user.getId(), PasswordChangeRequired, "true", false); } diff --git a/ui/src/views/iam/ChangeUserPassword.vue b/ui/src/views/iam/ChangeUserPassword.vue index 90c67ecf6d7e..aa298e4bcf5b 100644 --- a/ui/src/views/iam/ChangeUserPassword.vue +++ b/ui/src/views/iam/ChangeUserPassword.vue @@ -143,7 +143,7 @@ export default { params.currentpassword = values.currentpassword } - if (this.isAdminOrDomainAdmin && values.passwordChangeRequired) { + if (this.isAdminOrDomainAdmin() && values.passwordChangeRequired) { params.passwordchangerequired = values.passwordChangeRequired } postAPI('updateUser', params).then(json => { diff --git a/ui/src/views/iam/ForceChangePassword.vue b/ui/src/views/iam/ForceChangePassword.vue index d872d75c0b93..b05ce4d60070 100644 --- a/ui/src/views/iam/ForceChangePassword.vue +++ b/ui/src/views/iam/ForceChangePassword.vue @@ -127,10 +127,10 @@ export default { computed: { rules () { return { - currentpassword: [{ required: true, message: this.$t('message.error.current.password') || 'Please enter current password' }], - password: [{ required: true, message: this.$t('message.error.new.password') || 'Please enter new password' }], + currentpassword: [{ required: true, message: this.$t('message.error.current.password') }], + password: [{ required: true, message: this.$t('message.error.new.password') }], confirmpassword: [ - { required: true, message: this.$t('message.error.confirm.password') || 'Please confirm new password' }, + { required: true, message: this.$t('message.error.confirm.password') }, { validator: this.validateTwoPassword, trigger: 'change' } ] } @@ -250,7 +250,7 @@ export default { margin-top: 16px; a { - color: #1890ff; /* Ant Design Link Color */ + color: #1890ff; transition: color 0.3s; &:hover { From 95cba294de57ff9a32d8b971f75810c38d9a5c2b Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Fri, 19 Dec 2025 13:48:52 +0530 Subject: [PATCH 06/13] allow enforcing password change for all role types and update reset pwd flow for passwordchangerequired --- .../main/java/com/cloud/user/AccountManagerImpl.java | 12 +++++------- .../user/UserPasswordResetManagerImpl.java | 3 +++ ui/src/views/iam/ChangeUserPassword.vue | 9 +++++---- ui/src/views/iam/ForceChangePassword.vue | 6 +----- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index 0217b1483246..b3bb9690717c 100644 --- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java +++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java @@ -1587,8 +1587,9 @@ public UserAccount updateUser(UpdateUserCmd updateUserCmd) { } private void updatePasswordChangeRequired(User caller, UpdateUserCmd updateUserCmd, UserVO user) { - if (StringUtils.isNotBlank(updateUserCmd.getPassword()) && isNormalUser(user.getAccountId())) { - boolean isPasswordResetRequired = updateUserCmd.isPasswordChangeRequired(); + if (StringUtils.isNotBlank(updateUserCmd.getPassword())) { + 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) { @@ -1597,11 +1598,8 @@ private void updatePasswordChangeRequired(User caller, UpdateUserCmd updateUserC } // Remove passwordChangeRequired if user updating own pwd or admin has not enforced it - if ((caller.getId() == user.getId()) || !isPasswordResetRequired) { - UserDetailVO userDetailVO = _userDetailsDao.findDetail(user.getId(), PasswordChangeRequired); - if (userDetailVO != null) { - _userDetailsDao.removeDetail(user.getId(), PasswordChangeRequired); - } + if (isCallerSameAsUser || !isPasswordResetRequired) { + _userDetailsDao.removeDetail(user.getId(), PasswordChangeRequired); } } } diff --git a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java index 618ad5c86572..844f452de470 100644 --- a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java @@ -48,6 +48,7 @@ import java.util.Set; import java.util.UUID; +import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordChangeRequired; import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetToken; import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetTokenExpiryDate; @@ -247,6 +248,8 @@ void resetPassword(UserAccount userAccount, String password) { userDetailsDao.removeDetail(userAccount.getId(), PasswordResetToken); userDetailsDao.removeDetail(userAccount.getId(), PasswordResetTokenExpiryDate); + // remove password change required if user reset password + userDetailsDao.removeDetail(userAccount.getId(), PasswordChangeRequired); userDao.persist(user); } diff --git a/ui/src/views/iam/ChangeUserPassword.vue b/ui/src/views/iam/ChangeUserPassword.vue index aa298e4bcf5b..7ac95f571533 100644 --- a/ui/src/views/iam/ChangeUserPassword.vue +++ b/ui/src/views/iam/ChangeUserPassword.vue @@ -49,7 +49,7 @@ v-model:value="form.confirmpassword" :placeholder="$t('label.confirmpassword.description')"/> - + {{ $t('label.change.password.onlogin') }} @@ -104,12 +104,13 @@ export default { ] }) }, - isNormalUserResource () { - return ['User'].includes(this.resource.roletype) - }, isAdminOrDomainAdmin () { return ['Admin', 'DomainAdmin'].includes(this.$store.getters.userInfo.roletype) }, + isCallerNotSameAsUser () { + const userId = this.$store.getters.userInfo.id + return userId !== this.resource.id + }, isValidValueForKey (obj, key) { return key in obj && obj[key] != null }, diff --git a/ui/src/views/iam/ForceChangePassword.vue b/ui/src/views/iam/ForceChangePassword.vue index b05ce4d60070..8b0862c32ba0 100644 --- a/ui/src/views/iam/ForceChangePassword.vue +++ b/ui/src/views/iam/ForceChangePassword.vue @@ -167,14 +167,10 @@ export default { currentpassword: values.currentpassword } postAPI('updateUser', params).then(() => { - this.$message.success(this.$t('message.success.change.password')) + this.$message.success(this.$t('message.please.login.new.password')) this.isSubmitted = true }).catch(error => { console.error(error) - this.$notification.error({ - message: 'Error', - description: error.response?.data?.updateuserresponse?.errortext || 'Failed to update password' - }) this.$message.error(this.$t('message.error.change.password')) }).finally(() => { this.loading = false From 71b7b81d42984e59ecf4bf1589aff624f6d5ab69 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Fri, 19 Dec 2025 18:10:48 +0530 Subject: [PATCH 07/13] address review comments --- .../cloudstack/discovery/ApiDiscoveryServiceImpl.java | 7 ++----- .../src/main/java/com/cloud/user/AccountManagerImpl.java | 2 +- ui/src/store/modules/user.js | 1 + ui/src/views/iam/ChangeUserPassword.vue | 5 +++-- ui/src/views/iam/ForceChangePassword.vue | 3 +-- 5 files changed, 8 insertions(+), 10 deletions(-) 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 41ac2cb6afe6..0894cc0ebd47 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 @@ -286,11 +286,8 @@ public ListResponse listApis(User user, String name) { // Limit APIs on first login requiring password change UserAccount userAccount = accountService.getUserAccountById(user.getId()); Map userAccDetails = userAccount.getDetails(); - if (MapUtils.isNotEmpty(userAccDetails)) { - String needPwdChangeStr = userAccDetails.get(UserDetailVO.PasswordChangeRequired); - if ("true".equalsIgnoreCase(needPwdChangeStr)) { - apisAllowed = Arrays.asList("login", "logout", "updateUser", "listUsers", "listApis"); - } + if (MapUtils.isNotEmpty(userAccDetails) && "true".equalsIgnoreCase(userAccDetails.get(UserDetailVO.PasswordChangeRequired))) { + apisAllowed = Arrays.asList("login", "logout", "updateUser", "listUsers", "listApis"); } else { if (role.getRoleType() == RoleType.Admin && role.getId() == RoleType.Admin.getId()) { logger.info(String.format("Account [%s] is Root Admin, all APIs are allowed.", diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index b3bb9690717c..f4b27fcf87b3 100644 --- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java +++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java @@ -1591,7 +1591,7 @@ private void updatePasswordChangeRequired(User caller, UpdateUserCmd updateUserC 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 (isRootAdmin(caller.getAccountId()) || isDomainAdmin(caller.getAccountId())) { if (isPasswordResetRequired) { _userDetailsDao.addDetail(user.getId(), PasswordChangeRequired, "true", false); } diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js index 5ece6a38e5ff..9006ff0846f4 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -315,6 +315,7 @@ const user = { const latestVersion = vueProps.$localStorage.get(LATEST_CS_VERSION, { version: '', fetchedTs: 0 }) commit('SET_LATEST_VERSION', latestVersion) notification.destroy() + resolve() }).catch(error => { reject(error) diff --git a/ui/src/views/iam/ChangeUserPassword.vue b/ui/src/views/iam/ChangeUserPassword.vue index 7ac95f571533..f736557289c7 100644 --- a/ui/src/views/iam/ChangeUserPassword.vue +++ b/ui/src/views/iam/ChangeUserPassword.vue @@ -109,7 +109,8 @@ export default { }, isCallerNotSameAsUser () { const userId = this.$store.getters.userInfo.id - return userId !== this.resource.id + const resourceId = this.resource?.id ?? null + return userId !== resourceId }, isValidValueForKey (obj, key) { return key in obj && obj[key] != null @@ -144,7 +145,7 @@ export default { params.currentpassword = values.currentpassword } - if (this.isAdminOrDomainAdmin() && values.passwordChangeRequired) { + if (this.isAdminOrDomainAdmin() && values.passwordChangeRequired === true) { params.passwordchangerequired = values.passwordChangeRequired } postAPI('updateUser', params).then(json => { diff --git a/ui/src/views/iam/ForceChangePassword.vue b/ui/src/views/iam/ForceChangePassword.vue index 8b0862c32ba0..ac7a86815564 100644 --- a/ui/src/views/iam/ForceChangePassword.vue +++ b/ui/src/views/iam/ForceChangePassword.vue @@ -30,7 +30,7 @@
-
+
{{ $t('message.success.change.password') }}
@@ -197,7 +197,6 @@ export default { const user = await this.getUserInfo() if (user && !user.passwordchangerequired) { this.isSubmitted = true - this.$router.replace({ path: '/user/login' }) } } catch (e) { console.error('Failed to resolve user info:', e) From 316c454037ab5a0063eecdc2bc83bbba1288fd43 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Mon, 22 Dec 2025 16:03:49 +0530 Subject: [PATCH 08/13] add unit tests --- .../discovery/ApiDiscoveryServiceImpl.java | 3 +- .../discovery/ApiDiscoveryTest.java | 31 +++++++++++++++++++ .../cloud/user/AccountManagerImplTest.java | 13 ++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) 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 0894cc0ebd47..d57a7a16a043 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 @@ -69,6 +69,7 @@ public class ApiDiscoveryServiceImpl extends ComponentLifecycleBase implements A List _apiAccessCheckers = null; List _services = null; protected static Map s_apiNameDiscoveryResponseMap = null; + public static final List APIS_ALLOWED_FOR_PASSWORD_CHANGE = Arrays.asList("login", "logout", "updateUser", "listUsers", "listApis"); @Inject AccountService accountService; @@ -287,7 +288,7 @@ public ListResponse listApis(User user, String name) { UserAccount userAccount = accountService.getUserAccountById(user.getId()); Map userAccDetails = userAccount.getDetails(); if (MapUtils.isNotEmpty(userAccDetails) && "true".equalsIgnoreCase(userAccDetails.get(UserDetailVO.PasswordChangeRequired))) { - apisAllowed = Arrays.asList("login", "logout", "updateUser", "listUsers", "listApis"); + apisAllowed = APIS_ALLOWED_FOR_PASSWORD_CHANGE; } else { if (role.getRoleType() == RoleType.Admin && role.getId() == RoleType.Admin.getId()) { logger.info(String.format("Account [%s] is Root Admin, all APIs are allowed.", 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 8d6eaba81ec1..6ca167e2710a 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 @@ -22,6 +22,7 @@ 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; @@ -30,6 +31,8 @@ import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.acl.RoleVO; import org.apache.cloudstack.api.response.ApiDiscoveryResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -40,9 +43,12 @@ 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; @@ -138,4 +144,29 @@ public void listApisTestGetsApisAllowedToUserOnUserRole() throws PermissionDenie Mockito.verify(apiCheckerMock, Mockito.times(1)).getApisAllowedToUser(any(Role.class), any(User.class), anyList()); } + + @Test + public void listApisForUserWithoutEnforcedPwdChange() throws PermissionDeniedException { + RoleVO userRoleVO = new RoleVO(4L, "name", RoleType.User, "description"); + Map userDetails = new HashMap<>(); + userDetails.put(Setup2FADetail, UserAccountVO.Setup2FAstatus.ENABLED.name()); + Mockito.when(mockUserAccount.getDetails()).thenReturn(userDetails); + Mockito.when(accountServiceMock.getAccount(Mockito.anyLong())).thenReturn(getNormalAccount()); + Mockito.when(roleServiceMock.findRole(Mockito.anyLong())).thenReturn(userRoleVO); + discoveryServiceSpy.listApis(getTestUser(), null); + Mockito.verify(apiCheckerMock, Mockito.times(1)).getApisAllowedToUser(any(Role.class), any(User.class), anyList()); + } + + @Test + public void listApisForUserEnforcedPwdChange() throws PermissionDeniedException { + RoleVO userRoleVO = new RoleVO(4L, "name", RoleType.User, "description"); + Map userDetails = new HashMap<>(); + userDetails.put(PasswordChangeRequired, "true"); + Mockito.when(mockUserAccount.getDetails()).thenReturn(userDetails); + Mockito.when(accountServiceMock.getAccount(Mockito.anyLong())).thenReturn(getNormalAccount()); + Mockito.when(roleServiceMock.findRole(Mockito.anyLong())).thenReturn(userRoleVO); + Mockito.when(apiNameDiscoveryResponseMapMock.get(Mockito.anyString())).thenReturn(Mockito.mock(ApiDiscoveryResponse.class)); + ListResponse response = (ListResponse) discoveryServiceSpy.listApis(getTestUser(), null); + Assert.assertEquals(5, response.getResponses().size()); + } } diff --git a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java index 846d8cdc989b..bafe380bb26e 100644 --- a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java +++ b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java @@ -432,6 +432,19 @@ public void updateUserTestTimeZoneAndEmailNotNull() { prepareMockAndExecuteUpdateUserTest(1); } + @Test + public void updateUserTestPwdChange() { + Mockito.doReturn(true).when(UpdateUserCmdMock).isPasswordChangeRequired(); + Mockito.when(userVoMock.getAccountId()).thenReturn(10L); + Mockito.doReturn(accountMock).when(accountManagerImpl).getAccount(10L); + Mockito.when(accountMock.getAccountId()).thenReturn(10L); + Mockito.doReturn(false).when(accountManagerImpl).isRootAdmin(10L); + Mockito.lenient().when(accountManagerImpl.getRoleType(Mockito.eq(accountMock))).thenReturn(RoleType.User); + Mockito.when(callingUser.getAccountId()).thenReturn(1L); + Mockito.doReturn(true).when(accountManagerImpl).isRootAdmin(1L); + prepareMockAndExecuteUpdateUserTest(0); + } + private void prepareMockAndExecuteUpdateUserTest(int numberOfExpectedCallsForSetEmailAndSetTimeZone) { Mockito.doReturn("password").when(UpdateUserCmdMock).getPassword(); Mockito.doReturn("newpassword").when(UpdateUserCmdMock).getCurrentPassword(); From 3364f20a6371d30c5879a0273657d5417d144bc0 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Tue, 23 Dec 2025 16:21:04 +0530 Subject: [PATCH 09/13] cleanup ispasswordchangerequired from user_view --- .../api/command/admin/user/UpdateUserCmd.java | 4 -- .../cloudstack/api/response/UserResponse.java | 12 ------ .../META-INF/db/views/cloud.user_view.sql | 8 +--- .../discovery/ApiDiscoveryServiceImpl.java | 2 +- .../discovery/ApiDiscoveryTest.java | 2 +- .../api/query/dao/UserAccountJoinDaoImpl.java | 1 - .../cloud/api/query/vo/UserAccountJoinVO.java | 7 ---- ui/src/store/modules/user.js | 10 +---- ui/src/views/iam/ForceChangePassword.vue | 38 +++++++------------ 9 files changed, 19 insertions(+), 65 deletions(-) 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 586c1e09ac8c..364f57443b01 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 @@ -203,8 +203,4 @@ public ApiCommandResourceType getApiResourceType() { public Boolean isPasswordChangeRequired() { return BooleanUtils.isTrue(passwordChangeRequired); } - - public void setPasswordChangeRequired(Boolean passwordChangeRequired) { - this.passwordChangeRequired = passwordChangeRequired; - } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java index f1b1bebaaef8..5e4e6e1f3c8b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserResponse.java @@ -132,10 +132,6 @@ public class UserResponse extends BaseResponse implements SetResourceIconRespons @Param(description = "whether api key access is Enabled, Disabled or set to Inherit (it inherits the value from the parent)", since = "4.20.1.0") ApiConstants.ApiKeyAccess apiKeyAccess; - @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; - @Override public String getObjectId() { return this.getId(); @@ -321,12 +317,4 @@ public void set2FAmandated(Boolean is2FAmandated) { public void setApiKeyAccess(Boolean apiKeyAccess) { this.apiKeyAccess = ApiConstants.ApiKeyAccess.fromBoolean(apiKeyAccess); } - - public Boolean isPasswordChangeRequired() { - return Boolean.TRUE.equals(passwordChangeRequired); - } - - public void setPasswordChangeRequired(Boolean passwordChangeRequired) { - this.passwordChangeRequired = passwordChangeRequired; - } } diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.user_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.user_view.sql index 1e781f8ef685..340cfa9055fb 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.user_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.user_view.sql @@ -53,8 +53,7 @@ select async_job.uuid job_uuid, async_job.job_status job_status, async_job.account_id job_account_id, - user.is_user_2fa_enabled is_user_2fa_enabled, - `user_details`.`value` AS `password_change_required` + user.is_user_2fa_enabled is_user_2fa_enabled from `cloud`.`user` inner join @@ -64,7 +63,4 @@ from left join `cloud`.`async_job` ON async_job.instance_id = user.id and async_job.instance_type = 'User' - and async_job.job_status = 0 - left join - `cloud`.`user_details` AS `user_details` ON `user_details`.`user_id` = `user`.`id` - and `user_details`.`name` = 'PasswordChangeRequired'; + and async_job.job_status = 0; 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 d57a7a16a043..db274e3575f1 100644 --- a/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java +++ b/plugins/api/discovery/src/main/java/org/apache/cloudstack/discovery/ApiDiscoveryServiceImpl.java @@ -69,7 +69,7 @@ public class ApiDiscoveryServiceImpl extends ComponentLifecycleBase implements A List _apiAccessCheckers = null; List _services = null; protected static Map s_apiNameDiscoveryResponseMap = null; - public static final List APIS_ALLOWED_FOR_PASSWORD_CHANGE = Arrays.asList("login", "logout", "updateUser", "listUsers", "listApis"); + public static final List APIS_ALLOWED_FOR_PASSWORD_CHANGE = Arrays.asList("login", "logout", "updateUser", "listApis"); @Inject AccountService accountService; 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 6ca167e2710a..d33774cad031 100644 --- a/plugins/api/discovery/src/test/java/org/apache/cloudstack/discovery/ApiDiscoveryTest.java +++ b/plugins/api/discovery/src/test/java/org/apache/cloudstack/discovery/ApiDiscoveryTest.java @@ -167,6 +167,6 @@ public void listApisForUserEnforcedPwdChange() throws PermissionDeniedException Mockito.when(roleServiceMock.findRole(Mockito.anyLong())).thenReturn(userRoleVO); Mockito.when(apiNameDiscoveryResponseMapMock.get(Mockito.anyString())).thenReturn(Mockito.mock(ApiDiscoveryResponse.class)); ListResponse response = (ListResponse) discoveryServiceSpy.listApis(getTestUser(), null); - Assert.assertEquals(5, response.getResponses().size()); + Assert.assertEquals(4, response.getResponses().size()); } } diff --git a/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java index 8d2bd26d8f1c..f2c234b4c7cb 100644 --- a/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/UserAccountJoinDaoImpl.java @@ -73,7 +73,6 @@ public UserResponse newUserResponse(ResponseView view, UserAccountJoinVO usr) { userResponse.setSecretKey(usr.getSecretKey()); userResponse.setIsDefault(usr.isDefault()); userResponse.set2FAenabled(usr.isUser2faEnabled()); - userResponse.setPasswordChangeRequired(usr.isPasswordChangeRequired()); long domainId = usr.getDomainId(); boolean is2FAmandated = Boolean.TRUE.equals(AccountManagerImpl.enableUserTwoFactorAuthentication.valueIn(domainId)) && Boolean.TRUE.equals(AccountManagerImpl.mandateUserTwoFactorAuthentication.valueIn(domainId)); userResponse.set2FAmandated(is2FAmandated); diff --git a/server/src/main/java/com/cloud/api/query/vo/UserAccountJoinVO.java b/server/src/main/java/com/cloud/api/query/vo/UserAccountJoinVO.java index c1db804aa8e5..ad005eebb76e 100644 --- a/server/src/main/java/com/cloud/api/query/vo/UserAccountJoinVO.java +++ b/server/src/main/java/com/cloud/api/query/vo/UserAccountJoinVO.java @@ -136,9 +136,6 @@ public class UserAccountJoinVO extends BaseViewVO implements InternalIdentity, I @Column(name = "api_key_access") Boolean apiKeyAccess; - @Column(name = "password_change_required") - Boolean passwordChangeRequired; - public UserAccountJoinVO() { } @@ -291,8 +288,4 @@ public boolean isUser2faEnabled() { public Boolean getApiKeyAccess() { return apiKeyAccess; } - - public Boolean isPasswordChangeRequired() { - return passwordChangeRequired; - } } diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js index 9006ff0846f4..041e17307924 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -345,15 +345,7 @@ const user = { const isPwdChangeRequired = vueProps.$localStorage.get(PASSWORD_CHANGE_REQUIRED) commit('SET_PASSWORD_CHANGE_REQUIRED', isPwdChangeRequired) if (isPwdChangeRequired) { - getAPI('listUsers', { id: Cookies.get('userid') }).then(response => { - const result = response.listusersresponse.user[0] - commit('SET_INFO', result) - commit('SET_NAME', result.firstname + ' ' + result.lastname) - if (result.icon?.base64image) commit('SET_AVATAR', result.icon.base64image) - resolve({}) - }).catch(error => { - reject(error) - }) + resolve() return } diff --git a/ui/src/views/iam/ForceChangePassword.vue b/ui/src/views/iam/ForceChangePassword.vue index ac7a86815564..b2c5f7110f42 100644 --- a/ui/src/views/iam/ForceChangePassword.vue +++ b/ui/src/views/iam/ForceChangePassword.vue @@ -41,7 +41,7 @@ type="primary" size="large" block - @click="handleLogout" + @click="redirectToLogin()" style="margin-top: 20px;" > {{ $t('label.login') }} @@ -95,7 +95,7 @@ @@ -167,7 +167,8 @@ export default { currentpassword: values.currentpassword } postAPI('updateUser', params).then(() => { - this.$message.success(this.$t('message.please.login.new.password')) + this.$localStorage.remove(PASSWORD_CHANGE_REQUIRED) + this.handleLogout() this.isSubmitted = true }).catch(error => { console.error(error) @@ -187,30 +188,19 @@ export default { } finally { Cookies.remove('userid') Cookies.remove('token') - this.$localStorage.remove(PASSWORD_CHANGE_REQUIRED) - this.$router.replace({ path: '/user/login' }) } }, - async isPasswordChangeRequired () { - try { - this.loading = true - const user = await this.getUserInfo() - if (user && !user.passwordchangerequired) { - this.isSubmitted = true - } - } catch (e) { - console.error('Failed to resolve user info:', e) - } finally { - this.loading = false - } + redirectToLogin () { + this.$router.replace('/user/login') }, - async getUserInfo () { - const userInfo = this.$store.getters.userInfo - if (userInfo && userInfo.id) { - return userInfo - } - await this.$store.dispatch('GetInfo') - return this.$store.getters.userInfo + logoutAndRedirectToLogin () { + this.handleLogout().then(() => { + this.redirectToLogin() + }) + }, + async isPasswordChangeRequired () { + const passwordChangeRequired = this.$localStorage.get(PASSWORD_CHANGE_REQUIRED) + this.isSubmitted = !passwordChangeRequired } } } From 1fb241bbbd03f14ff2ed29b932a6d94f39caf211 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Wed, 24 Dec 2025 13:38:43 +0530 Subject: [PATCH 10/13] address review comments --- .../apache/cloudstack/api/response/LoginCmdResponse.java | 6 +++--- ui/src/store/modules/user.js | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) 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 348a1e5b368f..762764017693 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 @@ -92,7 +92,7 @@ public class LoginCmdResponse extends AuthenticationCmdResponse { @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 String passwordChangeRequired; + private Boolean passwordChangeRequired; public String getUsername() { return username; @@ -228,11 +228,11 @@ public void setManagementServerId(String managementServerId) { this.managementServerId = managementServerId; } - public String getPasswordChangeRequired() { + public Boolean getPasswordChangeRequired() { return passwordChangeRequired; } public void setPasswordChangeRequired(String passwordChangeRequired) { - this.passwordChangeRequired = passwordChangeRequired; + this.passwordChangeRequired = Boolean.parseBoolean(passwordChangeRequired); } } diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js index 041e17307924..a71c3378e26c 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -511,7 +511,6 @@ const user = { vueProps.$localStorage.remove(ACCESS_TOKEN) vueProps.$localStorage.remove(HEADER_NOTICES) - vueProps.$localStorage.remove(PASSWORD_CHANGE_REQUIRED) commit('SET_PASSWORD_CHANGE_REQUIRED', false) logout(state.token).then(() => { From 877dccba3be94f8b73f0a20b58e969de765c1016 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Wed, 7 Jan 2026 00:05:17 +0530 Subject: [PATCH 11/13] 1. Allow enforcing password change while creating user 2. Admin can enforce password change on next login with out resetting password --- .../java/com/cloud/user/AccountService.java | 3 +- .../api/command/admin/user/CreateUserCmd.java | 13 ++++- .../command/admin/user/CreateUserCmdTest.java | 6 +-- .../management/MockAccountManager.java | 2 +- .../com/cloud/user/AccountManagerImpl.java | 50 ++++++++++++++----- ui/public/locales/en.json | 1 + ui/src/config/section/user.js | 17 +++++++ ui/src/views/iam/AddUser.vue | 9 ++++ 8 files changed, 83 insertions(+), 18 deletions(-) diff --git a/api/src/main/java/com/cloud/user/AccountService.java b/api/src/main/java/com/cloud/user/AccountService.java index 09fe5ffc0590..c12e6fc1f0ed 100644 --- a/api/src/main/java/com/cloud/user/AccountService.java +++ b/api/src/main/java/com/cloud/user/AccountService.java @@ -58,7 +58,8 @@ UserAccount createUserAccount(String userName, String password, String firstName User getSystemUser(); - User createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID); + User createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, + String accountName, Long domainId, String userUUID, boolean isPasswordChangeRequired); User createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID, User.Source source); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmd.java index f03bb1c4ddd3..684103cf8d39 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmd.java @@ -26,6 +26,7 @@ import org.apache.cloudstack.api.response.DomainResponse; import org.apache.cloudstack.api.response.UserResponse; import org.apache.cloudstack.context.CallContext; +import org.apache.commons.lang.BooleanUtils; import org.apache.commons.lang3.StringUtils; import com.cloud.user.Account; @@ -78,6 +79,12 @@ public class CreateUserCmd extends BaseCmd { @Parameter(name = ApiConstants.USER_ID, type = CommandType.STRING, description = "User UUID, required for adding account from external provisioning system") private String userUUID; + @Parameter(name = ApiConstants.PASSWORD_CHANGE_REQUIRED, + type = CommandType.BOOLEAN, + description = "Provide true to mandate the User to reset password on next login.", + since = "4.23.0") + private Boolean passwordChangeRequired; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -118,6 +125,10 @@ public String getUserUUID() { return userUUID; } + public Boolean isPasswordChangeRequired() { + return BooleanUtils.isTrue(passwordChangeRequired); + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// @@ -147,7 +158,7 @@ public void execute() { CallContext.current().setEventDetails("UserName: " + getUserName() + ", FirstName :" + getFirstName() + ", LastName: " + getLastName()); User user = _accountService.createUser(getUserName(), getPassword(), getFirstName(), getLastName(), getEmail(), getTimezone(), getAccountName(), getDomainId(), - getUserUUID()); + getUserUUID(), isPasswordChangeRequired()); if (user != null) { UserResponse response = _responseGenerator.createUserResponse(user); response.setResponseName(getCommandName()); diff --git a/api/src/test/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmdTest.java index 8a57ac3eb22c..397723dd6069 100644 --- a/api/src/test/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmdTest.java +++ b/api/src/test/java/org/apache/cloudstack/api/command/admin/user/CreateUserCmdTest.java @@ -69,7 +69,7 @@ public void testExecuteWithNotBlankPassword() { } catch (ServerApiException e) { Assert.assertTrue("Received exception as the mock accountService createUser returns null user", true); } - Mockito.verify(accountService, Mockito.times(1)).createUser(null, "Test", null, null, null, null, null, null, null); + Mockito.verify(accountService, Mockito.times(1)).createUser(null, "Test", null, null, null, null, null, null, null, false); } @Test @@ -82,7 +82,7 @@ public void testExecuteWithNullPassword() { Assert.assertEquals(ApiErrorCode.PARAM_ERROR,e.getErrorCode()); Assert.assertEquals("Empty passwords are not allowed", e.getMessage()); } - Mockito.verify(accountService, Mockito.never()).createUser(null, null, null, null, null, null, null, null, null); + Mockito.verify(accountService, Mockito.never()).createUser(null, null, null, null, null, null, null, null, null, false); } @Test @@ -95,6 +95,6 @@ public void testExecuteWithEmptyPassword() { Assert.assertEquals(ApiErrorCode.PARAM_ERROR,e.getErrorCode()); Assert.assertEquals("Empty passwords are not allowed", e.getMessage()); } - Mockito.verify(accountService, Mockito.never()).createUser(null, null, null, null, null, null, null, null, null); + Mockito.verify(accountService, Mockito.never()).createUser(null, null, null, null, null, null, null, null, null, true); } } diff --git a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java index 2ff68b4836f4..352c13c8f3ef 100644 --- a/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java +++ b/plugins/network-elements/juniper-contrail/src/test/java/org/apache/cloudstack/network/contrail/management/MockAccountManager.java @@ -130,7 +130,7 @@ public String[] createApiKeyAndSecretKey(final long userId) { } @Override - public User createUser(String arg0, String arg1, String arg2, String arg3, String arg4, String arg5, String arg6, Long arg7, String arg8) { + public User createUser(String arg0, String arg1, String arg2, String arg3, String arg4, String arg5, String arg6, Long arg7, String arg8, boolean arg9) { // TODO Auto-generated method stub return null; } diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index ce18537e7f78..aa777b988e87 100644 --- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java +++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java @@ -1510,12 +1510,24 @@ private List getEnabledApiCheckers() { @Override @ActionEvent(eventType = EventTypes.EVENT_USER_CREATE, eventDescription = "creating User") public UserVO createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID, - User.Source source) { + User.Source source) { + return createUser(userName, password, firstName, lastName, email, timeZone, accountName, domainId, userUUID, source, false); + } + + + @ActionEvent(eventType = EventTypes.EVENT_USER_CREATE, eventDescription = "creating User") + public UserVO createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID, + User.Source source, boolean isPasswordChangeRequired) { // default domain to ROOT if not specified if (domainId == null) { domainId = Domain.ROOT_DOMAIN; } + if (isPasswordChangeRequired && (source == User.Source.SAML2 || source == User.Source.SAML2DISABLED || source == User.Source.LDAP)) { + logger.warn("Enforcing password change is not permitted for source [{}].", source); + throw new InvalidParameterValueException("CloudStack does not support enforcing password change for SAML or LDAP users."); + } + Domain domain = _domainMgr.getDomain(domainId); if (domain == null) { throw new CloudRuntimeException("The domain " + domainId + " does not exist; unable to create user"); @@ -1546,14 +1558,21 @@ public UserVO createUser(String userName, String password, String firstName, Str verifyCallerPrivilegeForUserOrAccountOperations(account); UserVO user; user = createUser(account.getId(), userName, password, firstName, lastName, email, timeZone, userUUID, source); + if (isPasswordChangeRequired) { + long callerAccountId = CallContext.current().getCallingAccountId(); + if ((isRootAdmin(callerAccountId) || isDomainAdmin(callerAccountId))) { + _userDetailsDao.addDetail(user.getId(), PasswordChangeRequired, "true", false); + } + } return user; } @Override @ActionEvent(eventType = EventTypes.EVENT_USER_CREATE, eventDescription = "creating User") - public UserVO createUser(String userName, String password, String firstName, String lastName, String email, String timeZone, String accountName, Long domainId, String userUUID) { + public UserVO createUser(String userName, String password, String firstName, String lastName, String email, + String timeZone, String accountName, Long domainId, String userUUID, boolean isPasswordChangeRequired) { - return createUser(userName, password, firstName, lastName, email, timeZone, accountName, domainId, userUUID, User.Source.UNKNOWN); + return createUser(userName, password, firstName, lastName, email, timeZone, accountName, domainId, userUUID, User.Source.UNKNOWN, isPasswordChangeRequired); } @Override @@ -1587,22 +1606,29 @@ public UserAccount updateUser(UpdateUserCmd updateUserCmd) { if (mandate2FA != null && mandate2FA) { user.setUser2faEnabled(true); } - _userDao.update(user.getId(), user); updatePasswordChangeRequired(caller, updateUserCmd, user); + _userDao.update(user.getId(), user); return _userAccountDao.findById(user.getId()); } private void updatePasswordChangeRequired(User caller, UpdateUserCmd updateUserCmd, UserVO user) { - if (StringUtils.isNotBlank(updateUserCmd.getPassword())) { - 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); - } + User.Source userSource = user.getSource(); + if ((userSource == User.Source.SAML2 || userSource == User.Source.SAML2DISABLED || userSource == User.Source.LDAP) + && updateUserCmd.isPasswordChangeRequired()) { + logger.warn("Enforcing password change is not permitted for source [{}].", user.getSource()); + throw new InvalidParameterValueException("CloudStack does not support enforcing password change for SAML or LDAP users."); + } + + boolean isCallerSameAsUser = user.getId() == caller.getId(); + boolean isPasswordResetRequired = updateUserCmd.isPasswordChangeRequired() && !isCallerSameAsUser; + // Admins only can enforce passwordChangeRequired for user + if (isRootAdmin(caller.getAccountId()) || isDomainAdmin(caller.getAccountId())) { + if (isPasswordResetRequired) { + _userDetailsDao.addDetail(user.getId(), PasswordChangeRequired, "true", false); } + } + if (StringUtils.isNotBlank(updateUserCmd.getPassword())) { // Remove passwordChangeRequired if user updating own pwd or admin has not enforced it if (isCallerSameAsUser || !isPasswordResetRequired) { _userDetailsDao.removeDetail(user.getId(), PasswordChangeRequired); diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 62192655aa7d..43fbbded2992 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -530,6 +530,7 @@ "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.enforce": "Enforce password change", "label.change.password.onlogin": "User must change password at next login", "label.change.service.offering": "Change service offering", "label.character": "Character", diff --git a/ui/src/config/section/user.js b/ui/src/config/section/user.js index a18994fd6ce1..9224bdd12cfb 100644 --- a/ui/src/config/section/user.js +++ b/ui/src/config/section/user.js @@ -82,6 +82,23 @@ export default { popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/iam/EditUser.vue'))) }, + { + api: 'updateUser', + icon: 'redo-outlined', + label: 'label.change.password.enforce', + dataView: true, + args: ['passwordchangerequired'], + mapping: { + passwordchangerequired: { + value: (record) => { return true } + } + }, + popup: true, + show: (record, store) => { + return ['Admin', 'DomainAdmin'].includes(store.userInfo.roletype) && + !record.isdefault && (store.userInfo.id !== record.id) + } + }, { api: 'updateUser', icon: 'key-outlined', diff --git a/ui/src/views/iam/AddUser.vue b/ui/src/views/iam/AddUser.vue index 374d81c51d13..12238f8e988e 100644 --- a/ui/src/views/iam/AddUser.vue +++ b/ui/src/views/iam/AddUser.vue @@ -147,6 +147,11 @@ + + + {{ $t('label.change.password.onlogin') }} + +
@@ -384,6 +389,10 @@ export default { if (this.isValidValueForKey(rawParams, 'timezone') && rawParams.timezone.length > 0) { params.timezone = rawParams.timezone } + console.log('rawParams.passwordChangeRequired', rawParams.passwordChangeRequired) + if (this.isAdminOrDomainAdmin() && rawParams.passwordChangeRequired === true) { + params.passwordchangerequired = rawParams.passwordChangeRequired + } return postAPI('createUser', params) }, From ab758bdae1caa9fb2c73cd6bf588646b90e8e182 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Wed, 7 Jan 2026 18:06:08 +0530 Subject: [PATCH 12/13] address review comment, add unit test --- .../api/response/LoginCmdResponse.java | 4 +- .../command/admin/user/UpdateUserCmdTest.java | 64 +++++++++ .../api/response/LoginCmdResponseTest.java | 87 ++++++++++++ .../main/java/com/cloud/api/ApiServer.java | 4 +- .../java/com/cloud/api/ApiServerTest.java | 124 ++++++++++++++++-- .../UserPasswordResetManagerImplTest.java | 27 ++++ ui/public/locales/en.json | 3 +- ui/src/config/section/user.js | 3 +- ui/src/views/iam/AddUser.vue | 1 - ui/src/views/iam/ForceChangePassword.vue | 4 +- 10 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 api/src/test/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmdTest.java create mode 100644 api/src/test/java/org/apache/cloudstack/api/response/LoginCmdResponseTest.java 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 2ea3af189f97..6e3ef4678d28 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/LoginCmdResponse.java @@ -232,7 +232,7 @@ public Boolean getPasswordChangeRequired() { return passwordChangeRequired; } - public void setPasswordChangeRequired(String passwordChangeRequired) { - this.passwordChangeRequired = Boolean.parseBoolean(passwordChangeRequired); + public void setPasswordChangeRequired(Boolean passwordChangeRequired) { + this.passwordChangeRequired = passwordChangeRequired; } } diff --git a/api/src/test/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmdTest.java new file mode 100644 index 000000000000..f86e51adb5ab --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/admin/user/UpdateUserCmdTest.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.api.command.admin.user; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +@RunWith(MockitoJUnitRunner.class) +public class UpdateUserCmdTest { + @InjectMocks + private UpdateUserCmd cmd; + + @Test + public void testGetApiResourceId() { + Long userId = 99L; + cmd.setId(userId); + Assert.assertEquals(userId, cmd.getApiResourceId()); + } + + @Test + public void testGetApiResourceType() { + Assert.assertEquals(ApiCommandResourceType.User, cmd.getApiResourceType()); + } + + @Test + public void testIsPasswordChangeRequired_True() { + ReflectionTestUtils.setField(cmd, "passwordChangeRequired", Boolean.TRUE); + Assert.assertTrue(cmd.isPasswordChangeRequired()); + } + + @Test + public void testIsPasswordChangeRequired_False() { + ReflectionTestUtils.setField(cmd, "passwordChangeRequired", Boolean.FALSE); + Assert.assertFalse(cmd.isPasswordChangeRequired()); + } + + @Test + public void testIsPasswordChangeRequired_Null() { + ReflectionTestUtils.setField(cmd, "passwordChangeRequired", null); + Assert.assertFalse(cmd.isPasswordChangeRequired()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/response/LoginCmdResponseTest.java b/api/src/test/java/org/apache/cloudstack/api/response/LoginCmdResponseTest.java new file mode 100644 index 000000000000..7811138fffe1 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/response/LoginCmdResponseTest.java @@ -0,0 +1,87 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.response; + + +import org.junit.Assert; +import org.junit.Test; + +public class LoginCmdResponseTest { + + @Test + public void testAllGettersAndSetters() { + LoginCmdResponse response = new LoginCmdResponse(); + + response.setUsername("user1"); + response.setUserId("100"); + response.setDomainId("200"); + response.setTimeout(3600); + response.setAccount("account1"); + response.setFirstName("John"); + response.setLastName("Doe"); + response.setType("admin"); + response.setTimeZone("UTC"); + response.setTimeZoneOffset("+00:00"); + response.setRegistered("true"); + response.setSessionKey("session-key"); + response.set2FAenabled("true"); + response.set2FAverfied("false"); + response.setProviderFor2FA("totp"); + response.setIssuerFor2FA("cloudstack"); + response.setManagementServerId("ms-1"); + + Assert.assertEquals("user1", response.getUsername()); + Assert.assertEquals("100", response.getUserId()); + Assert.assertEquals("200", response.getDomainId()); + Assert.assertEquals(Integer.valueOf(3600), response.getTimeout()); + Assert.assertEquals("account1", response.getAccount()); + Assert.assertEquals("John", response.getFirstName()); + Assert.assertEquals("Doe", response.getLastName()); + Assert.assertEquals("admin", response.getType()); + Assert.assertEquals("UTC", response.getTimeZone()); + Assert.assertEquals("+00:00", response.getTimeZoneOffset()); + Assert.assertEquals("true", response.getRegistered()); + Assert.assertEquals("session-key", response.getSessionKey()); + Assert.assertEquals("true", response.is2FAenabled()); + Assert.assertEquals("false", response.is2FAverfied()); + Assert.assertEquals("totp", response.getProviderFor2FA()); + Assert.assertEquals("cloudstack", response.getIssuerFor2FA()); + Assert.assertEquals("ms-1", response.getManagementServerId()); + } + + @Test + public void testPasswordChangeRequired_True() { + LoginCmdResponse response = new LoginCmdResponse(); + response.setPasswordChangeRequired(true); + Assert.assertTrue(response.getPasswordChangeRequired()); + } + + @Test + public void testPasswordChangeRequired_False() { + LoginCmdResponse response = new LoginCmdResponse(); + response.setPasswordChangeRequired(false); + Assert.assertFalse(response.getPasswordChangeRequired()); + } + + @Test + public void testPasswordChangeRequired_Null() { + LoginCmdResponse response = new LoginCmdResponse(); + response.setPasswordChangeRequired(null); + Assert.assertNull("Boolean.parseBoolean(null) should return null", response.getPasswordChangeRequired()); + } +} diff --git a/server/src/main/java/com/cloud/api/ApiServer.java b/server/src/main/java/com/cloud/api/ApiServer.java index f1fe6d964027..9db3f226bdf6 100644 --- a/server/src/main/java/com/cloud/api/ApiServer.java +++ b/server/src/main/java/com/cloud/api/ApiServer.java @@ -1230,8 +1230,8 @@ private ResponseObject createLoginResponse(HttpSession session) { if (ApiConstants.MANAGEMENT_SERVER_ID.equalsIgnoreCase(attrName)) { response.setManagementServerId(attrObj.toString()); } - if (PASSWORD_CHANGE_REQUIRED.equalsIgnoreCase(attrName)) { - response.setPasswordChangeRequired(attrObj.toString()); + if (PASSWORD_CHANGE_REQUIRED.equalsIgnoreCase(attrName) && attrObj instanceof Boolean) { + response.setPasswordChangeRequired((Boolean) attrObj); } } } diff --git a/server/src/test/java/com/cloud/api/ApiServerTest.java b/server/src/test/java/com/cloud/api/ApiServerTest.java index dedd6e02ec5c..2caf6bf9fae6 100644 --- a/server/src/test/java/com/cloud/api/ApiServerTest.java +++ b/server/src/test/java/com/cloud/api/ApiServerTest.java @@ -17,11 +17,21 @@ package com.cloud.api; import com.cloud.domain.Domain; +import com.cloud.domain.DomainVO; +import com.cloud.exception.CloudAuthenticationException; import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.user.DomainManager; import com.cloud.user.User; import com.cloud.user.UserAccount; +import com.cloud.user.UserVO; import com.cloud.utils.exception.CloudRuntimeException; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.response.LoginCmdResponse; import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.resourcedetail.UserDetailVO; import org.apache.cloudstack.user.UserPasswordResetManager; import org.junit.AfterClass; import org.junit.Assert; @@ -35,10 +45,22 @@ import org.mockito.junit.MockitoJUnitRunner; import java.lang.reflect.Field; +import java.net.InetAddress; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import static org.apache.cloudstack.api.ApiConstants.PASSWORD_CHANGE_REQUIRED; import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; + +import javax.servlet.http.HttpSession; @RunWith(MockitoJUnitRunner.class) public class ApiServerTest { @@ -49,6 +71,15 @@ public class ApiServerTest { @Mock UserPasswordResetManager userPasswordResetManager; + @Mock + DomainManager domainManager; + + @Mock + AccountManager accountManager; + + @Mock + HttpSession session; + @BeforeClass public static void beforeClass() throws Exception { overrideDefaultConfigValue(UserPasswordResetEnabled, "_value", true); @@ -96,8 +127,8 @@ public void testSetupIntegrationPortListenerValidPort() { @Test public void testForgotPasswordSuccess() { - UserAccount userAccount = Mockito.mock(UserAccount.class); - Domain domain = Mockito.mock(Domain.class); + UserAccount userAccount = mock(UserAccount.class); + Domain domain = mock(Domain.class); Mockito.when(userAccount.getEmail()).thenReturn("test@test.com"); Mockito.when(userAccount.getState()).thenReturn("ENABLED"); @@ -110,8 +141,8 @@ public void testForgotPasswordSuccess() { @Test(expected = CloudRuntimeException.class) public void testForgotPasswordFailureNoEmail() { - UserAccount userAccount = Mockito.mock(UserAccount.class); - Domain domain = Mockito.mock(Domain.class); + UserAccount userAccount = mock(UserAccount.class); + Domain domain = mock(Domain.class); Mockito.when(userAccount.getEmail()).thenReturn(""); apiServer.forgotPassword(userAccount, domain); @@ -119,8 +150,8 @@ public void testForgotPasswordFailureNoEmail() { @Test(expected = CloudRuntimeException.class) public void testForgotPasswordFailureDisabledUser() { - UserAccount userAccount = Mockito.mock(UserAccount.class); - Domain domain = Mockito.mock(Domain.class); + UserAccount userAccount = mock(UserAccount.class); + Domain domain = mock(Domain.class); Mockito.when(userAccount.getEmail()).thenReturn("test@test.com"); Mockito.when(userAccount.getState()).thenReturn("DISABLED"); @@ -129,8 +160,8 @@ public void testForgotPasswordFailureDisabledUser() { @Test(expected = CloudRuntimeException.class) public void testForgotPasswordFailureDisabledAccount() { - UserAccount userAccount = Mockito.mock(UserAccount.class); - Domain domain = Mockito.mock(Domain.class); + UserAccount userAccount = mock(UserAccount.class); + Domain domain = mock(Domain.class); Mockito.when(userAccount.getEmail()).thenReturn("test@test.com"); Mockito.when(userAccount.getState()).thenReturn("ENABLED"); @@ -140,8 +171,8 @@ public void testForgotPasswordFailureDisabledAccount() { @Test(expected = CloudRuntimeException.class) public void testForgotPasswordFailureInactiveDomain() { - UserAccount userAccount = Mockito.mock(UserAccount.class); - Domain domain = Mockito.mock(Domain.class); + UserAccount userAccount = mock(UserAccount.class); + Domain domain = mock(Domain.class); Mockito.when(userAccount.getEmail()).thenReturn("test@test.com"); Mockito.when(userAccount.getState()).thenReturn("ENABLED"); @@ -153,8 +184,8 @@ public void testForgotPasswordFailureInactiveDomain() { @Test public void testVerifyApiKeyAccessAllowed() { Long domainId = 1L; - User user = Mockito.mock(User.class); - Account account = Mockito.mock(Account.class); + User user = mock(User.class); + Account account = mock(Account.class); Mockito.when(user.getApiKeyAccess()).thenReturn(true); Assert.assertEquals(true, apiServer.verifyApiKeyAccessAllowed(user, account)); @@ -176,4 +207,73 @@ public void testVerifyApiKeyAccessAllowed() { Mockito.when(account.getApiKeyAccess()).thenReturn(null); Assert.assertEquals(true, apiServer.verifyApiKeyAccessAllowed(user, account)); } + + @Test + public void testLoginUserSuccess() throws Exception { + String username = "user"; + String password = "password"; + Long domainId = 1L; + String domainPath = "/"; + InetAddress loginIp = InetAddress.getByName("127.0.0.1"); + Map requestParams = new HashMap<>(); + + DomainVO domain = mock(DomainVO.class); + Mockito.when(domain.getId()).thenReturn(domainId); + Mockito.when(domain.getUuid()).thenReturn("domain-uuid"); + + Mockito.when(domainManager.findDomainByIdOrPath(domainId, domainPath)).thenReturn(domain); + Mockito.when(domainManager.getDomain(domainId)).thenReturn(domain); + + UserAccount userAccount = mock(UserAccount.class); + Mockito.when(userAccount.getId()).thenReturn(100L); + Mockito.when(userAccount.getAccountId()).thenReturn(200L); + Mockito.when(userAccount.getUsername()).thenReturn(username); + Mockito.when(userAccount.getFirstname()).thenReturn("First"); + Mockito.when(userAccount.getLastname()).thenReturn("Last"); + Mockito.when(userAccount.getTimezone()).thenReturn("UTC"); + Mockito.when(userAccount.getRegistrationToken()).thenReturn("token"); + Mockito.when(userAccount.isRegistered()).thenReturn(true); + Mockito.when(userAccount.getDomainId()).thenReturn(domainId); + Map userAccDetails = new HashMap<>(); + userAccDetails.put(UserDetailVO.PasswordChangeRequired, "true"); + Mockito.when(userAccount.getDetails()).thenReturn(userAccDetails); + + Mockito.when(accountManager.authenticateUser(username, password, domainId, loginIp, requestParams)).thenReturn(userAccount); + Mockito.when(accountManager.clearUserTwoFactorAuthenticationInSetupStateOnLogin(userAccount)).thenReturn(userAccount); + + Account account = mock(Account.class); + Mockito.when(account.getAccountName()).thenReturn("account"); + Mockito.when(account.getDomainId()).thenReturn(domainId); + Mockito.when(account.getType()).thenReturn(Account.Type.NORMAL); + Mockito.when(account.getType()).thenReturn(Account.Type.NORMAL); + Mockito.when(accountManager.getAccount(200L)).thenReturn(account); + + UserVO userVO = mock(UserVO.class); + Mockito.when(userVO.getUuid()).thenReturn("user-uuid"); + Mockito.when(accountManager.getActiveUser(100L)).thenReturn(userVO); + + Mockito.when(session.getAttributeNames()).thenReturn(Collections.enumeration(List.of(PASSWORD_CHANGE_REQUIRED))); + Mockito.when(session.getAttribute(PASSWORD_CHANGE_REQUIRED)).thenReturn(Boolean.TRUE); + + ResponseObject response = apiServer.loginUser(session, username, password, domainId, domainPath, loginIp, requestParams); + Assert.assertNotNull(response); + Assert.assertTrue(response instanceof LoginCmdResponse); + Mockito.verify(session).setAttribute(eq("userid"), eq(100L)); + Mockito.verify(session).setAttribute(eq(ApiConstants.SESSIONKEY), anyString()); + } + + @Test(expected = CloudAuthenticationException.class) + public void testLoginUserDomainNotFound() throws Exception { + Mockito.when(domainManager.findDomainByIdOrPath(anyLong(), anyString())).thenReturn(null); + apiServer.loginUser(session, "user", "pass", 1L, "/", null, null); + } + + @Test(expected = CloudAuthenticationException.class) + public void testLoginUserAuthFailed() throws Exception { + DomainVO domain = mock(DomainVO.class); + Mockito.when(domain.getId()).thenReturn(1L); + Mockito.when(domainManager.findDomainByIdOrPath(anyLong(), anyString())).thenReturn(domain); + Mockito.when(accountManager.authenticateUser(anyString(), anyString(), anyLong(), any(), any())).thenReturn(null); + apiServer.loginUser(session, "user", "pass", 1L, "/", null, null); + } } diff --git a/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java index 17092e6311dd..5e274b06be28 100644 --- a/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java @@ -16,7 +16,11 @@ // under the License. package org.apache.cloudstack.user; +import com.cloud.user.AccountManager; import com.cloud.user.UserAccount; +import com.cloud.user.UserVO; +import com.cloud.user.dao.UserDao; + import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.resourcedetail.UserDetailVO; @@ -45,6 +49,12 @@ public class UserPasswordResetManagerImplTest { @Mock private UserDetailsDao userDetailsDao; + @Mock + AccountManager accountManager; + + @Mock + UserDao userDao; + @Test public void testGetMessageBody() { ConfigKey passwordResetMailTemplate = Mockito.mock(ConfigKey.class); @@ -147,4 +157,21 @@ public void testValidateExistingTokenSecondRequestUnexpired() { Assert.assertFalse(passwordReset.validateExistingToken(userAccount)); } + + @Test + public void testResetPassword() { + UserAccount userAccount = Mockito.mock(UserAccount.class); + UserVO userVO = Mockito.mock(UserVO.class); + long userId = 1L; + String newPassword = "newPassword"; + Mockito.when(userAccount.getId()).thenReturn(userId); + Mockito.when(userDao.getUser(userId)).thenReturn(userVO); + passwordReset.resetPassword(userAccount, newPassword); + Mockito.verify(userDao).getUser(userId); + Mockito.verify(accountManager).validateUserPasswordAndUpdateIfNeeded(newPassword, userVO, "", true); + Mockito.verify(userDetailsDao).removeDetail(userId, PasswordResetToken); + Mockito.verify(userDetailsDao).removeDetail(userId, PasswordResetTokenExpiryDate); + Mockito.verify(userDetailsDao).removeDetail(userId, UserDetailVO.PasswordChangeRequired); + Mockito.verify(userDao).persist(userVO); + } } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 43fbbded2992..6d28636119c1 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -530,8 +530,8 @@ "label.change.ipaddress": "Change IP address for NIC", "label.change.disk.offering": "Change disk offering", "label.change.offering.for.volume": "Change disk offering for the volume", -"label.change.password.enforce": "Enforce password change", "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", @@ -3141,6 +3141,7 @@ "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.", diff --git a/ui/src/config/section/user.js b/ui/src/config/section/user.js index 9224bdd12cfb..69c5277de989 100644 --- a/ui/src/config/section/user.js +++ b/ui/src/config/section/user.js @@ -85,7 +85,8 @@ export default { { api: 'updateUser', icon: 'redo-outlined', - label: 'label.change.password.enforce', + label: 'label.change.password.reset', + message: 'message.change.password.reset', dataView: true, args: ['passwordchangerequired'], mapping: { diff --git a/ui/src/views/iam/AddUser.vue b/ui/src/views/iam/AddUser.vue index 12238f8e988e..4b1b60d8d06b 100644 --- a/ui/src/views/iam/AddUser.vue +++ b/ui/src/views/iam/AddUser.vue @@ -389,7 +389,6 @@ export default { if (this.isValidValueForKey(rawParams, 'timezone') && rawParams.timezone.length > 0) { params.timezone = rawParams.timezone } - console.log('rawParams.passwordChangeRequired', rawParams.passwordChangeRequired) if (this.isAdminOrDomainAdmin() && rawParams.passwordChangeRequired === true) { params.passwordchangerequired = rawParams.passwordChangeRequired } diff --git a/ui/src/views/iam/ForceChangePassword.vue b/ui/src/views/iam/ForceChangePassword.vue index b2c5f7110f42..ea156ca7811e 100644 --- a/ui/src/views/iam/ForceChangePassword.vue +++ b/ui/src/views/iam/ForceChangePassword.vue @@ -166,9 +166,9 @@ export default { password: values.password, currentpassword: values.currentpassword } - postAPI('updateUser', params).then(() => { + postAPI('updateUser', params).then(async () => { this.$localStorage.remove(PASSWORD_CHANGE_REQUIRED) - this.handleLogout() + await this.handleLogout() this.isSubmitted = true }).catch(error => { console.error(error) From ea916de2ed00d777f8067268a1d53dcd75b4fb75 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Thu, 8 Jan 2026 14:57:36 +0530 Subject: [PATCH 13/13] improve code coverage --- .../cloud/user/AccountManagerImplTest.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java index 227f570b1994..0577b8dd89ae 100644 --- a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java +++ b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java @@ -23,6 +23,7 @@ import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -1605,4 +1606,104 @@ public void testcheckCallerApiPermissionsForUserOperationsNotAllowedApis() { accountManagerImpl.checkCallerApiPermissionsForUserOrAccountOperations(accountMock); } + + @Test(expected = InvalidParameterValueException.class) + public void testPasswordChangeRequiredWithSamlThrowsException() { + accountManagerImpl.createUser( + "user", "pass", "fn", "ln", "e@mail.com", + "UTC", "acct", 1L, null, + User.Source.SAML2, true + ); + } + + @Test(expected = InvalidParameterValueException.class) + public void testPasswordChangeRequiredWithLdapSourceThrows() { + accountManagerImpl.createUser( + "user", "pass", "fn", "ln", "e@mail.com", + "UTC", "acct", 1L, null, + User.Source.LDAP, true); + } + + @Test(expected = CloudRuntimeException.class) + public void testDomainNotFound() { + Mockito.when(_domainMgr.getDomain(1L)).thenReturn(null); + accountManagerImpl.createUser( + "user", "pass", "fn", "ln", "e@mail.com", + "UTC", "acct", 1L, null, + User.Source.UNKNOWN, false); + } + + @Test(expected = CloudRuntimeException.class) + public void testCreateUserInactiveDomain() { + Mockito.when(domainVoMock.getState()).thenReturn(Domain.State.Inactive); + Mockito.when(_domainMgr.getDomain(Mockito.anyLong())).thenReturn(domainVoMock); + accountManagerImpl.createUser( + "user", "pass", "fn", "ln", "e@mail.com", + "UTC", "acct", 1L, null, + User.Source.NATIVE, false); + } + + @Test(expected = InvalidParameterValueException.class) + public void testCreateUserCheckAccess() { + Mockito.when(domainVoMock.getState()).thenReturn(Domain.State.Active); + Mockito.when(_domainMgr.getDomain(Mockito.anyLong())).thenReturn(domainVoMock); + Mockito.doNothing().when(accountManagerImpl).checkAccess(Mockito.any(Account.class), Mockito.any(Domain.class)); + accountManagerImpl.createUser( + "user", "pass", "fn", "ln", "e@mail.com", + "UTC", "acct", 1L, null, + User.Source.NATIVE, false); + } + + @Test(expected = InvalidParameterValueException.class) + public void testCreateUserMissingOrProjectAccount() { + Mockito.when(domainVoMock.getState()).thenReturn(Domain.State.Active); + Mockito.when(_accountDao.findEnabledAccount(Mockito.anyString(), Mockito.anyLong())).thenReturn(accountMock); + Mockito.when(accountMock.getType()).thenReturn(Account.Type.PROJECT); + Mockito.when(_domainMgr.getDomain(Mockito.anyLong())).thenReturn(domainVoMock); + Mockito.doNothing().when(accountManagerImpl).checkAccess(Mockito.any(Account.class), Mockito.any(Domain.class)); + accountManagerImpl.createUser( + "user", "pass", "fn", "ln", "e@mail.com", + "UTC", "acct", 1L, null, + User.Source.NATIVE, false); + } + + @Test + public void testCreateUserSuccess() { + Account rootAdminAccount = Mockito.mock(Account.class); + Mockito.when(rootAdminAccount.getId()).thenReturn(1L); + Mockito.when(accountManagerImpl.isRootAdmin(1L)).thenReturn(true); + User callingUser = Mockito.mock(User.class); + CallContext.register(callingUser, rootAdminAccount); + + String newPassword = "newPassword"; + configureUserMockAuthenticators(newPassword); + Mockito.doNothing().when(accountManagerImpl).checkAccess(any(Account.class), any(Domain.class)); + Mockito.doReturn(accountMock).when(accountManagerImpl).getAccount(Mockito.anyLong()); + Mockito.doNothing().when(passwordPolicyMock).verifyIfPasswordCompliesWithPasswordPolicies(Mockito.anyString(), Mockito.anyString(), Mockito.anyLong()); + Mockito.when(_domainMgr.getDomain(Mockito.anyLong())).thenReturn(domainVoMock); + Mockito.when(domainVoMock.getState()).thenReturn(Domain.State.Active); + + Mockito.when(_accountDao.findEnabledAccount(Mockito.anyString(), Mockito.anyLong())).thenReturn(accountMock); + Mockito.when(accountMock.getId()).thenReturn(10L); + Mockito.when(accountMock.getType()).thenReturn(Account.Type.NORMAL); + + Mockito.when(userAccountDaoMock.validateUsernameInDomain(Mockito.anyString(), Mockito.anyLong())).thenReturn(true); + Mockito.when(userDaoMock.findUsersByName(Mockito.anyString())).thenReturn(Collections.emptyList()); + UserVO createdUser = new UserVO(); + String userMockUUID = "userMockUUID"; + createdUser.setUuid(userMockUUID); + Mockito.when(userDaoMock.persist(Mockito.any(UserVO.class))).thenReturn(createdUser); + UserVO userResultVO = accountManagerImpl.createUser( + "user", newPassword, "fn", "ln", "e@mail.com", + "UTC", "acct", 1L, null, + User.Source.NATIVE, false + ); + Assert.assertNotNull(userResultVO); + UserVO userResultPasswordChangeVO = accountManagerImpl.createUser( + "user", newPassword, "fn", "ln", "e@mail.com", + "UTC", "acct", 1L, null, + User.Source.NATIVE, true + ); + Assert.assertNotNull(userResultVO); + } }