Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,6 +39,8 @@
requestHasSensitiveInfo = true, responseHasSensitiveInfo = true)
public class UpdateUserCmd extends BaseCmd {

@Inject
private RegionService _regionService;

/////////////////////////////////////////////////////
//////////////// API parameters /////////////////////
Expand Down Expand Up @@ -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 ///////////////////////
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ public class LoginCmdResponse extends AuthenticationCmdResponse {
@Param(description = "Management Server ID that the user logged to", since = "4.21.0.0")
private String managementServerId;

@SerializedName(value = ApiConstants.PASSWORD_CHANGE_REQUIRED)
@Param(description = "Indicates whether the User is required to change password on next login.", since = "4.23.0")
private String passwordChangeRequired;

public String getUsername() {
return username;
}
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "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();
Expand Down Expand Up @@ -317,4 +321,12 @@ 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -280,12 +283,19 @@ public ListResponse<? extends BaseResponse> listApis(User user, String name) {
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(account, "accountName", "uuid")));
}

if (role.getRoleType() == RoleType.Admin && role.getId() == RoleType.Admin.getId()) {
logger.info(String.format("Account [%s] is Root Admin, all APIs are allowed.",
ReflectionToStringBuilderUtils.reflectOnlySelectedFields(account, "accountName", "uuid")));
// Limit APIs on first login requiring password change
UserAccount userAccount = accountService.getUserAccountById(user.getId());
Map<String, String> userAccDetails = userAccount.getDetails();
if (MapUtils.isNotEmpty(userAccDetails) && "true".equalsIgnoreCase(userAccDetails.get(UserDetailVO.PasswordChangeRequired))) {
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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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);
}
Comment on lines 74 to 82
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test setup mocks getUserAccountById to return null for user details, but there's no test case that verifies the new password change required functionality. The new logic in ApiDiscoveryServiceImpl that restricts APIs when passwordchangerequired is true is not covered by any test. Consider adding test cases for: 1) user with passwordchangerequired=true should only get limited APIs, and 2) user with passwordchangerequired=false should follow normal flow.

Copilot uses AI. Check for mistakes.

private User getTestUser() {
Expand Down
13 changes: 13 additions & 0 deletions server/src/main/java/com/cloud/api/ApiServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1227,6 +1230,9 @@ private ResponseObject createLoginResponse(HttpSession session) {
if (ApiConstants.MANAGEMENT_SERVER_ID.equalsIgnoreCase(attrName)) {
response.setManagementServerId(attrObj.toString());
}
if (PASSWORD_CHANGE_REQUIRED.equalsIgnoreCase(attrName)) {
response.setPasswordChangeRequired(attrObj.toString());
}
}
}
response.setResponseName("loginresponse");
Expand Down Expand Up @@ -1327,6 +1333,13 @@ public ResponseObject loginUser(final HttpSession session, final String username
final String sessionKey = Base64.encodeBase64URLSafeString(sessionKeyBytes);
session.setAttribute(ApiConstants.SESSIONKEY, sessionKey);

Map<String, String> userAccDetails = userAcct.getDetails();
if (MapUtils.isNotEmpty(userAccDetails)) {
String needPwdChangeStr = userAccDetails.get(UserDetailVO.PasswordChangeRequired);
if ("true".equalsIgnoreCase(needPwdChangeStr)) {
session.setAttribute(PASSWORD_CHANGE_REQUIRED, true);
}
}
return createLoginResponse(session);
}
throw new CloudAuthenticationException("Failed to authenticate user " + username + " in domain " + domainId + "; please provide valid credentials");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}

Expand Down Expand Up @@ -288,4 +291,8 @@ public boolean isUser2faEnabled() {
public Boolean getApiKeyAccess() {
return apiKeyAccess;
}

public Boolean isPasswordChangeRequired() {
return passwordChangeRequired;
}
}
23 changes: 23 additions & 0 deletions server/src/main/java/com/cloud/user/AccountManagerImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1580,9 +1582,28 @@ 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())) {
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);
}
}

// Remove passwordChangeRequired if user updating own pwd or admin has not enforced it
if (isCallerSameAsUser || !isPasswordResetRequired) {
_userDetailsDao.removeDetail(user.getId(), PasswordChangeRequired);
}
}
}

@Override
public void verifyCallerPrivilegeForUserOrAccountOperations(Account userAccount) {
logger.debug(String.format("Verifying whether the caller has the correct privileges based on the user's role type and API permissions: %s", userAccount));
Expand Down Expand Up @@ -2841,6 +2862,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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions ui/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -3123,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.",
Expand Down Expand Up @@ -3368,6 +3370,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.",
Expand Down Expand Up @@ -3671,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.",
Expand Down
5 changes: 5 additions & 0 deletions ui/src/config/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ export const constantRouterMap = [
path: 'resetPassword',
name: 'resetPassword',
component: () => import(/* webpackChunkName: "auth" */ '@/views/auth/ResetPassword')
},
{
path: 'forceChangePassword',
name: 'forceChangePassword',
component: () => import(/* webpackChunkName: "auth" */ '@/views/iam/ForceChangePassword')
}
]
},
Expand Down
Loading
Loading