From a00dd7110cb806b83d2afc19d4a77695699a948d Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Mon, 15 Dec 2025 18:04:53 +0530 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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)