diff --git a/jmix-core/core/src/main/java/io/jmix/core/security/UserManager.java b/jmix-core/core/src/main/java/io/jmix/core/security/UserManager.java index d0f9de3b18..f928d725db 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/security/UserManager.java +++ b/jmix-core/core/src/main/java/io/jmix/core/security/UserManager.java @@ -61,22 +61,46 @@ UserDetails changePassword(String userName, @Nullable String oldPassword, @Nulla /** * Generates new passwords for passed users and saves changes to the database immediately. + *

+ * If the user entity has a field marked with + * {@code @io.jmix.security.user.PasswordChangeRequired}, the field is set to + * {@code true} so that the user is required to change the password at the next logon. * * @param users users which need reset passwords * @return map which contains new passwords for the passed users */ default Map resetPasswords(Set users) { - return resetPasswords(users, true); + return resetPasswords(users, true, true); } /** * Generates new passwords for passed users. + *

+ * If the user entity has a field marked with + * {@code @io.jmix.security.user.PasswordChangeRequired}, the field is set to + * {@code true} so that the user is required to change the password at the next logon. * * @param users users which need reset passwords * @param saveChanges whether to save changes to the database * @return map which contains new passwords for the passed users */ - Map resetPasswords(Set users, boolean saveChanges); + default Map resetPasswords(Set users, boolean saveChanges) { + return resetPasswords(users, saveChanges, true); + } + + /** + * Generates new passwords for passed users. + * + * @param users users which need reset passwords + * @param saveChanges whether to save changes to the database + * @param requireChangeAtNextLogon if {@code true} and the user entity has a field marked with + * {@code @io.jmix.security.user.PasswordChangeRequired}, the field is set + * to {@code true} so that the user is required to change the password at + * the next logon + * @return map which contains new passwords for the passed users + */ + Map resetPasswords(Set users, boolean saveChanges, + boolean requireChangeAtNextLogon); /** * Resets 'remember me' token for the specific user. diff --git a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/change-password-view.css b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/change-password-view.css new file mode 100644 index 0000000000..83b2f7cdc9 --- /dev/null +++ b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/change-password-view.css @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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. + */ + +.force-change-password-view .dialog-close-button { + display: none; +} diff --git a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/index.css b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/index.css index 76ecf81b6c..31f5d1e737 100644 --- a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/index.css +++ b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/index.css @@ -14,6 +14,7 @@ * limitations under the License. */ +@import './change-password-view.css'; @import './reset-password-view.css'; @import './role-assignment-view.css'; @import './substitute-user-view.css'; \ No newline at end of file diff --git a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/reset-password-view.css b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/reset-password-view.css index 478948344f..9ad84ae280 100644 --- a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/reset-password-view.css +++ b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-aura/src/addons/security/reset-password-view.css @@ -23,6 +23,10 @@ margin-top: var(--vaadin-padding-l); } +.reset-password-view .required-change-checkbox { + margin-top: var(--vaadin-padding-m); +} + .reset-password-view vaadin-password-field[readonly][class~='reset-password-field']::part(input-field) { --vaadin-input-field-border-color: transparent; } diff --git a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/change-password-view.css b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/change-password-view.css new file mode 100644 index 0000000000..83b2f7cdc9 --- /dev/null +++ b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/change-password-view.css @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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. + */ + +.force-change-password-view .dialog-close-button { + display: none; +} diff --git a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/index.css b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/index.css index 76ecf81b6c..31f5d1e737 100644 --- a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/index.css +++ b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/index.css @@ -14,6 +14,7 @@ * limitations under the License. */ +@import './change-password-view.css'; @import './reset-password-view.css'; @import './role-assignment-view.css'; @import './substitute-user-view.css'; \ No newline at end of file diff --git a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/reset-password-view.css b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/reset-password-view.css index dbf291276b..e613c39296 100644 --- a/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/reset-password-view.css +++ b/jmix-flowui/flowui-themes/src/main/resources/META-INF/resources/themes/jmix-lumo/src/addons/security/reset-password-view.css @@ -23,6 +23,10 @@ margin-top: var(--lumo-space-l); } +.reset-password-view .required-change-checkbox { + margin-top: var(--lumo-space-m); +} + .reset-password-view .warning-icon { padding: var(--lumo-space-xs); } diff --git a/jmix-security/security-data/src/main/java/io/jmix/securitydata/user/AbstractDatabaseUserRepository.java b/jmix-security/security-data/src/main/java/io/jmix/securitydata/user/AbstractDatabaseUserRepository.java index af9da3b77c..b5d44714f0 100644 --- a/jmix-security/security-data/src/main/java/io/jmix/securitydata/user/AbstractDatabaseUserRepository.java +++ b/jmix-security/security-data/src/main/java/io/jmix/securitydata/user/AbstractDatabaseUserRepository.java @@ -36,6 +36,7 @@ import io.jmix.security.role.assignment.RoleAssignment; import io.jmix.security.role.assignment.RoleAssignmentRepository; import io.jmix.security.role.assignment.RoleAssignmentRoleType; +import io.jmix.security.user.PasswordChangeRequiredSupport; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; @@ -79,6 +80,8 @@ public abstract class AbstractDatabaseUserRepository impl protected ApplicationEventPublisher eventPublisher; @Autowired protected RoleGrantedAuthorityUtils roleGrantedAuthorityUtils; + @Autowired + protected PasswordChangeRequiredSupport passwordChangeRequiredSupport; /** * Helps create authorities from roles. @@ -204,6 +207,7 @@ public T changePassword(String userName, @Nullable String oldPassword, @Nullable Preconditions.checkNotNullArgument(newPassword, "Null new password"); T userDetails = loadUserByUsername(userName); changePassword(userDetails, oldPassword, newPassword); + passwordChangeRequiredSupport.setPasswordChangeRequired(userDetails, false); if (saveChanges) { userDetails = dataManager.save(userDetails); @@ -225,7 +229,8 @@ private void changePassword(T userDetails, @Nullable String oldPassword, @Nullab } @Override - public Map resetPasswords(Set users, boolean saveChanges) { + public Map resetPasswords(Set users, boolean saveChanges, + boolean requireChangeAtNextLogon) { Map usernamePasswordMap = new LinkedHashMap<>(); SaveContext saveContext = new SaveContext(); @@ -244,6 +249,7 @@ public Map resetPasswords(Set users, boolean s success = true; } while (!success); + passwordChangeRequiredSupport.setPasswordChangeRequired(userDetails, requireChangeAtNextLogon); saveContext.saving(userDetails); usernamePasswordMap.put(userDetails, newPassword); } diff --git a/jmix-security/security-data/src/test/groovy/password_change_required/PasswordChangeRequiredTest.groovy b/jmix-security/security-data/src/test/groovy/password_change_required/PasswordChangeRequiredTest.groovy new file mode 100644 index 0000000000..0f4d2f5daa --- /dev/null +++ b/jmix-security/security-data/src/test/groovy/password_change_required/PasswordChangeRequiredTest.groovy @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 password_change_required + +import io.jmix.core.CoreConfiguration +import io.jmix.core.UnconstrainedDataManager +import io.jmix.core.security.PasswordNotMatchException +import io.jmix.core.security.UserManager +import io.jmix.data.DataConfiguration +import io.jmix.eclipselink.EclipselinkConfiguration +import io.jmix.security.SecurityConfiguration +import io.jmix.securitydata.SecurityDataConfiguration +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.test.context.ContextConfiguration +import password_change_required.test_support.PasswordChangeRequiredTestConfiguration +import spock.lang.Specification +import test_support.TestContextInititalizer +import test_support.entity.TestUser + +@ContextConfiguration( + classes = [CoreConfiguration, DataConfiguration, EclipselinkConfiguration, + SecurityConfiguration, SecurityDataConfiguration, PasswordChangeRequiredTestConfiguration], + initializers = [TestContextInititalizer] +) +class PasswordChangeRequiredTest extends Specification { + + @Autowired + UserManager userManager + @Autowired + UnconstrainedDataManager dataManager + @Autowired + PasswordEncoder passwordEncoder + @Autowired + JdbcTemplate jdbcTemplate + + def cleanup() { + jdbcTemplate.update('delete from TEST_USER') + } + + private TestUser createUser(String username, String rawPassword, boolean changePasswordFlag) { + def user = dataManager.create(TestUser) + user.username = username + user.password = passwordEncoder.encode(rawPassword) + user.changePasswordAtNextLogon = changePasswordFlag + return dataManager.save(user) + } + + private TestUser reload(String username) { + return dataManager.load(TestUser).query('e.username = :u').parameter('u', username).one() + } + + def "changePassword resets the changePasswordAtNextLogon flag"() { + given: + createUser('john', 'oldPwd', true) + + when: + userManager.changePassword('john', 'oldPwd', 'newPwd') + + then: + def reloaded = reload('john') + reloaded.changePasswordAtNextLogon == false + passwordEncoder.matches('newPwd', reloaded.password) + } + + def "changePassword in-memory updates the flag when saveChanges is false"() { + given: + createUser('alice', 'oldPwd', true) + + when: + def result = userManager.changePassword('alice', 'oldPwd', 'newPwd', false) as TestUser + + then: + result.changePasswordAtNextLogon == false + + and: 'database still has the original flag value because changes were not saved' + reload('alice').changePasswordAtNextLogon == true + } + + def "changePassword keeps the flag when PasswordNotMatchException is thrown"() { + given: + createUser('mary', 'samePwd', true) + + when: + userManager.changePassword('mary', 'samePwd', 'samePwd') + + then: + thrown(PasswordNotMatchException) + + and: + reload('mary').changePasswordAtNextLogon == true + } + + def "resetPasswords with requireChangeAtNextLogon=true sets the flag to true"() { + given: + def user = createUser('bob', 'oldPwd', false) + + when: + def passwords = userManager.resetPasswords([user] as Set, true, true) + + then: + passwords.size() == 1 + def reloaded = reload('bob') + reloaded.changePasswordAtNextLogon == true + !passwordEncoder.matches('oldPwd', reloaded.password) + } + + def "resetPasswords with requireChangeAtNextLogon=false keeps the flag false"() { + given: + def user = createUser('eve', 'oldPwd', false) + + when: + userManager.resetPasswords([user] as Set, true, false) + + then: + reload('eve').changePasswordAtNextLogon == false + } + + def "default resetPasswords overload defaults to requireChangeAtNextLogon=true"() { + given: + def user = createUser('carol', 'oldPwd', false) + + when: + userManager.resetPasswords([user] as Set) + + then: + reload('carol').changePasswordAtNextLogon == true + } +} diff --git a/jmix-security/security-data/src/test/java/password_change_required/test_support/PasswordChangeRequiredTestConfiguration.java b/jmix-security/security-data/src/test/java/password_change_required/test_support/PasswordChangeRequiredTestConfiguration.java new file mode 100644 index 0000000000..472001317f --- /dev/null +++ b/jmix-security/security-data/src/test/java/password_change_required/test_support/PasswordChangeRequiredTestConfiguration.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 password_change_required.test_support; + +import io.jmix.core.security.UserRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import test_support.SecurityDataTestConfiguration; +import test_support.repository.TestUserRepository; + +/** + * Test configuration for the {@link io.jmix.securitydata.user.AbstractDatabaseUserRepository} integration test. + * Imports the common security-data test infrastructure and overrides the default in-memory user repository + * with {@link TestUserRepository} based on the {@code TEST_USER} table. + */ +@Configuration +@Import(SecurityDataTestConfiguration.class) +public class PasswordChangeRequiredTestConfiguration { + + @Bean("test_userRepository") + @Primary + public UserRepository testUserRepository() { + return new TestUserRepository(); + } +} diff --git a/jmix-security/security-data/src/test/java/test_support/entity/TestUser.java b/jmix-security/security-data/src/test/java/test_support/entity/TestUser.java new file mode 100644 index 0000000000..8ff74f67b2 --- /dev/null +++ b/jmix-security/security-data/src/test/java/test_support/entity/TestUser.java @@ -0,0 +1,115 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 test_support.entity; + +import io.jmix.core.entity.annotation.JmixGeneratedValue; +import io.jmix.core.entity.annotation.SystemLevel; +import io.jmix.core.metamodel.annotation.JmixEntity; +import io.jmix.security.user.PasswordChangeRequired; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.persistence.Version; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; +import java.util.UUID; + +@Entity(name = "test_User") +@JmixEntity +@Table(name = "TEST_USER") +public class TestUser implements UserDetails { + + @Id + @Column(name = "ID") + @JmixGeneratedValue + private UUID id; + + @Version + @Column(name = "VERSION", nullable = false) + private Integer version; + + @Column(name = "USERNAME", nullable = false) + private String username; + + @SystemLevel + @Column(name = "PASSWORD") + private String password; + + @PasswordChangeRequired + @Column(name = "CHANGE_PASSWORD_AT_NEXT_LOGON") + private Boolean changePasswordAtNextLogon = false; + + @Transient + private Collection authorities; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Integer getVersion() { + return version; + } + + public void setVersion(Integer version) { + this.version = version; + } + + @Override + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + @Override + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public Boolean getChangePasswordAtNextLogon() { + return changePasswordAtNextLogon; + } + + public void setChangePasswordAtNextLogon(Boolean changePasswordAtNextLogon) { + this.changePasswordAtNextLogon = changePasswordAtNextLogon; + } + + @Override + public Collection getAuthorities() { + return authorities != null ? authorities : Collections.emptyList(); + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/jmix-security/security-data/src/test/java/test_support/repository/TestUserRepository.java b/jmix-security/security-data/src/test/java/test_support/repository/TestUserRepository.java new file mode 100644 index 0000000000..caed0b4efe --- /dev/null +++ b/jmix-security/security-data/src/test/java/test_support/repository/TestUserRepository.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 test_support.repository; + +import io.jmix.securitydata.user.AbstractDatabaseUserRepository; +import test_support.entity.TestUser; + +public class TestUserRepository extends AbstractDatabaseUserRepository { + + @Override + protected Class getUserClass() { + return TestUser.class; + } +} diff --git a/jmix-security/security-data/src/test/resources/test_support/liquibase/test-changelog.xml b/jmix-security/security-data/src/test/resources/test_support/liquibase/test-changelog.xml index 5e4c7d324f..ac422ec229 100644 --- a/jmix-security/security-data/src/test/resources/test_support/liquibase/test-changelog.xml +++ b/jmix-security/security-data/src/test/resources/test_support/liquibase/test-changelog.xml @@ -126,6 +126,20 @@ + + + + + + + + + + + + + + diff --git a/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/authentication/PasswordChangeRequiredViewListener.java b/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/authentication/PasswordChangeRequiredViewListener.java new file mode 100644 index 0000000000..f4aa4debb1 --- /dev/null +++ b/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/authentication/PasswordChangeRequiredViewListener.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 io.jmix.securityflowui.authentication; + +import com.google.common.base.Strings; +import io.jmix.core.security.CurrentAuthentication; +import io.jmix.flowui.DialogWindows; +import io.jmix.flowui.UiProperties; +import io.jmix.flowui.event.view.ViewOpenedEvent; +import io.jmix.flowui.view.DialogWindow; +import io.jmix.flowui.view.View; +import io.jmix.flowui.view.ViewInfo; +import io.jmix.flowui.view.ViewRegistry; +import io.jmix.security.user.PasswordChangeRequiredSupport; +import io.jmix.securityflowui.view.changepassword.ChangePasswordView; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +/** + * Opens a forced {@link ChangePasswordView} dialog on the main view if the currently logged in user is required + * to change the password at the next logon. + *

+ * Activates only when the user entity has a field marked with + * {@link io.jmix.security.user.PasswordChangeRequired @PasswordChangeRequired} and the value is {@code true}. + * Otherwise, this listener does nothing. + * + * @see PasswordChangeRequiredSupport + */ +@Component("sec_PasswordChangeRequiredViewListener") +public class PasswordChangeRequiredViewListener { + + @Autowired + protected CurrentAuthentication currentAuthentication; + @Autowired + protected DialogWindows dialogWindows; + @Autowired + protected PasswordChangeRequiredSupport passwordChangeRequiredSupport; + @Autowired + protected UiProperties uiProperties; + @Autowired + protected ViewRegistry viewRegistry; + + @EventListener + public void onViewOpened(ViewOpenedEvent event) { + View view = event.getSource(); + if (!shouldOpenDialog(view)) { + return; + } + + if (!currentAuthentication.isSet()) { + return; + } + + UserDetails user = currentAuthentication.getUser(); + if (!passwordChangeRequiredSupport.isPasswordChangeRequired(user)) { + return; + } + + // A single navigation may open more than one view (e.g. main view and the default view inside it), + // each firing a ViewOpenedEvent. Avoid stacking several forced dialogs on top of each other. + if (isChangePasswordDialogOpened()) { + return; + } + + openForcedChangePasswordDialog(view, user.getUsername()); + } + + protected boolean shouldOpenDialog(View view) { + Class viewClass = view.getClass(); + // Skip the ChangePasswordView itself to avoid recursion. + if (isViewOfId(viewClass, "changePasswordView")) { + return false; + } + + String loginViewId = uiProperties.getLoginViewId(); + // Skip the login view so the dialog is not opened before authentication. + // Any other view (main, default, or one navigated to via bookmark/URL) is a valid target. + return Strings.isNullOrEmpty(loginViewId) || !isViewOfId(viewClass, loginViewId); + + } + + protected boolean isViewOfId(Class viewClass, String viewId) { + try { + ViewInfo viewInfo = viewRegistry.getViewInfo(viewId); + return viewInfo.getControllerClass().isAssignableFrom(viewClass); + } catch (IllegalArgumentException e) { + return false; + } + } + + protected void openForcedChangePasswordDialog(View origin, String username) { + DialogWindow dialog = dialogWindows.view(origin, ChangePasswordView.class) + .build(); + + ChangePasswordView view = dialog.getView(); + view.setUsername(username); + view.setCurrentPasswordRequired(false); + view.setForced(true); + + dialog.addClassName("force-change-password-view"); + + dialog.setCloseOnEsc(false); + dialog.setCloseOnOutsideClick(false); + + dialog.open(); + } + + protected boolean isChangePasswordDialogOpened() { + return dialogWindows.getOpenedDialogWindows().getDialogs().stream() + .anyMatch(ChangePasswordView.class::isInstance); + } +} diff --git a/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/role/UiMinimalPolicies.java b/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/role/UiMinimalPolicies.java index d9370a9be6..f683c89f4b 100644 --- a/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/role/UiMinimalPolicies.java +++ b/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/role/UiMinimalPolicies.java @@ -26,7 +26,7 @@ public interface UiMinimalPolicies { - @ViewPolicy(viewIds = {"inputDialog", "multiValueSelectDialog", "sec_SubstituteUserView"}) + @ViewPolicy(viewIds = {"inputDialog", "multiValueSelectDialog", "sec_SubstituteUserView", "changePasswordView"}) void systemDialogs(); @EntityPolicy(entityClass = KeyValueEntity.class, actions = EntityPolicyAction.READ) diff --git a/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/view/changepassword/ChangePasswordView.java b/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/view/changepassword/ChangePasswordView.java index 8a0fe3de9c..7bbb8de023 100644 --- a/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/view/changepassword/ChangePasswordView.java +++ b/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/view/changepassword/ChangePasswordView.java @@ -21,28 +21,23 @@ import com.vaadin.flow.component.textfield.PasswordField; import io.jmix.core.MetadataTools; import io.jmix.core.common.util.Preconditions; +import io.jmix.core.security.CurrentAuthentication; import io.jmix.core.security.PasswordNotMatchException; import io.jmix.core.security.UserManager; import io.jmix.core.security.UserRepository; import io.jmix.flowui.Notifications; import io.jmix.flowui.component.validation.ValidationErrors; +import io.jmix.flowui.kit.action.Action; import io.jmix.flowui.kit.action.ActionPerformedEvent; import io.jmix.flowui.util.OperationResult; -import io.jmix.flowui.view.DialogMode; -import io.jmix.flowui.view.MessageBundle; -import io.jmix.flowui.view.StandardOutcome; -import io.jmix.flowui.view.StandardView; -import io.jmix.flowui.view.Subscribe; -import io.jmix.flowui.view.ViewComponent; -import io.jmix.flowui.view.ViewController; -import io.jmix.flowui.view.ViewDescriptor; -import io.jmix.flowui.view.ViewValidation; +import io.jmix.flowui.view.*; +import io.jmix.security.user.PasswordChangeRequiredSupport; import io.jmix.securityflowui.password.PasswordValidation; +import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; -import org.jspecify.annotations.Nullable; import java.util.Objects; @ViewController("changePasswordView") @@ -56,6 +51,9 @@ public class ChangePasswordView extends StandardView { @ViewComponent protected PasswordField currentPasswordField; + @ViewComponent + protected Action closeAction; + @ViewComponent protected MessageBundle messageBundle; @Autowired @@ -72,9 +70,14 @@ public class ChangePasswordView extends StandardView { protected UserRepository userRepository; @Autowired protected MetadataTools metadataTools; + @Autowired + protected CurrentAuthentication currentAuthentication; + @Autowired + protected PasswordChangeRequiredSupport passwordChangeRequiredSupport; protected String username; protected UserDetails user; + protected boolean forced = false; /** * @return username for which should be changed password @@ -110,17 +113,49 @@ public void setCurrentPasswordRequired(boolean required) { currentPasswordField.setVisible(required); } + /** + * @return {@code true} if the dialog is opened in the forced mode + */ + public boolean isForced() { + return forced; + } + + /** + * Sets the forced mode. In the forced mode the user cannot close the dialog without changing the password. + *

+ * Default value is {@code false}. + * + * @param forced forced mode flag + */ + public void setForced(boolean forced) { + this.forced = forced; + } + @Subscribe public void onBeforeShow(BeforeShowEvent event) { Preconditions.checkNotNullArgument(username, messageBundle.getMessage("changePasswordView.emptyUsernameMessage")); user = userRepository.loadUserByUsername(username); + + closeAction.setVisible(!forced); + if (currentPasswordField.isVisible()) currentPasswordField.focus(); } + @Subscribe + public void onBeforeClose(BeforeCloseEvent event) { + if (forced && !event.closedWith(StandardOutcome.SAVE)) { + event.preventClose(); + } + } + @Override public String getPageTitle() { + if (forced) { + return messageBundle.getMessage("changePasswordView.forcedTitle"); + } + return username != null ? String.format(messageBundle.formatMessage("changePasswordView.title", username)) : super.getPageTitle(); @@ -134,17 +169,22 @@ public void onSaveActionPerformed(ActionPerformedEvent event) { changePassword(username, getPassword(), null) .then(() -> { + refreshCurrentUserPasswordChangeRequiredFlag(); notifications.create(messageBundle.getMessage("changePasswordView.passwordChanged")) .withType(Notifications.Type.SUCCESS) .withPosition(Notification.Position.MIDDLE) .show(); close(StandardOutcome.SAVE); - }).otherwise(() -> { - viewValidation.showValidationErrors(ValidationErrors.of( - messageBundle.getMessage("changePasswordView.currentPasswordWarning"))); - }); + }).otherwise(() -> viewValidation.showValidationErrors(ValidationErrors.of( + messageBundle.getMessage("changePasswordView.currentPasswordWarning")))); + } - close(StandardOutcome.SAVE); + protected void refreshCurrentUserPasswordChangeRequiredFlag() { + UserDetails currentUser = currentAuthentication.getUser(); + if (Objects.equals(currentUser.getUsername(), username)) { + // Sync the principal cached in SecurityContext so that the view listener does not reopen the dialog. + passwordChangeRequiredSupport.setPasswordChangeRequired(currentUser, false); + } } @Subscribe("closeAction") diff --git a/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/view/resetpassword/ResetPasswordView.java b/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/view/resetpassword/ResetPasswordView.java index 7217efd465..0dd6664fe5 100644 --- a/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/view/resetpassword/ResetPasswordView.java +++ b/jmix-security/security-flowui/src/main/java/io/jmix/securityflowui/view/resetpassword/ResetPasswordView.java @@ -41,6 +41,7 @@ import io.jmix.flowui.backgroundtask.BackgroundWorker; import io.jmix.flowui.backgroundtask.TaskLifeCycle; import io.jmix.flowui.component.UiComponentUtils; +import io.jmix.flowui.component.checkbox.JmixCheckbox; import io.jmix.flowui.component.grid.DataGrid; import io.jmix.flowui.component.textfield.JmixPasswordField; import io.jmix.flowui.icon.Icons; @@ -50,6 +51,7 @@ import io.jmix.flowui.model.CollectionContainer; import io.jmix.flowui.util.UnknownOperationResult; import io.jmix.flowui.view.*; +import io.jmix.security.user.PasswordChangeRequiredSupport; import io.jmix.securityflowui.view.resetpassword.model.UserPasswordValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -80,6 +82,8 @@ public class ResetPasswordView extends StandardView { @Autowired protected ApplicationEventPublisher applicationEventPublisher; @Autowired + protected PasswordChangeRequiredSupport passwordChangeRequiredSupport; + @Autowired protected UiAsyncTasks uiAsyncTasks; @Autowired protected BackgroundWorker backgroundWorker; @@ -109,6 +113,8 @@ public class ResetPasswordView extends StandardView { protected DataGrid passwordsDataGrid; @ViewComponent protected JmixButton closeBtn; + @ViewComponent + protected JmixCheckbox requireChangeAtNextLogonField; @ViewComponent protected VerticalLayout progressBarLayout; @@ -130,6 +136,7 @@ public class ResetPasswordView extends StandardView { protected Set users; protected Dialog cancelDialog; + protected boolean requireChangeAtNextLogon = true; @Subscribe public void onInit(InitEvent event) { @@ -141,6 +148,18 @@ public void onReady(ReadyEvent event) { if (!isSingleSelected()) { updateMultiSelectComponentsLabels(); } + + updateRequireChangeAtNextLogonFieldVisibility(); + } + + protected void updateRequireChangeAtNextLogonFieldVisibility() { + if (users == null || users.isEmpty()) { + return; + } + + Class userClass = users.iterator().next().getClass(); + boolean supported = passwordChangeRequiredSupport.findFlagProperty(userClass) != null; + requireChangeAtNextLogonField.setVisible(supported); } @Subscribe @@ -198,6 +217,7 @@ public String getPageTitle() { @Subscribe("generateBtn") protected void onGenerateBtnClick(ClickEvent event) { + requireChangeAtNextLogon = Boolean.TRUE.equals(requireChangeAtNextLogonField.getValue()); configureComponentsBeforeGeneration(); generationTaskHandler = backgroundWorker.handle(createBackgroundTask()); @@ -362,15 +382,16 @@ public List run(TaskLifeCycle taskLifeCycle) throws int i = 0; for (UserDetails userDetails : users) { - Map userPasswordMap = - userManager.resetPasswords(Collections.singleton(userDetails), false); + Map userPasswordMap = userManager.resetPasswords( + Collections.singleton(userDetails), false, requireChangeAtNextLogon); Map.Entry userPassword = userPasswordMap.entrySet().iterator().next(); - String username = userPassword.getKey().getUsername(); + UserDetails refreshedUser = userPassword.getKey(); + String username = refreshedUser.getUsername(); String password = userPassword.getValue(); usernamePasswordMap.put(username, password); - saveContext.saving(userPassword.getKey()); + saveContext.saving(refreshedUser); result.add(createPasswordValue(username, password)); taskLifeCycle.publish(++i); diff --git a/jmix-security/security-flowui/src/main/resources/io/jmix/securityflowui/messages.properties b/jmix-security/security-flowui/src/main/resources/io/jmix/securityflowui/messages.properties index 8afe5e1edc..1938ec964d 100644 --- a/jmix-security/security-flowui/src/main/resources/io/jmix/securityflowui/messages.properties +++ b/jmix-security/security-flowui/src/main/resources/io/jmix/securityflowui/messages.properties @@ -213,6 +213,7 @@ io.jmix.securityflowui.view.changepassword/changePasswordView.passwordsDoNotMatc io.jmix.securityflowui.view.changepassword/changePasswordView.passwordChanged=Password successfully changed io.jmix.securityflowui.view.changepassword/changePasswordView.emptyUsernameMessage=Dialog cannot be opened without username io.jmix.securityflowui.view.changepassword/changePasswordView.title=Change password for [%s] +io.jmix.securityflowui.view.changepassword/changePasswordView.forcedTitle=Password change required io.jmix.securityflowui.view.resetpassword/resetPasswordView.resetPasswordsTitle = Generate new passwords for selected users io.jmix.securityflowui.view.resetpassword/resetPasswordView.resetSinglePasswordTitle = Generate a new password for [%s] @@ -230,6 +231,7 @@ io.jmix.securityflowui.view.resetpassword/resetPasswordView.cancelDialog.title = io.jmix.securityflowui.view.resetpassword/resetPasswordView.cancelDialog.message = Do you want to cancel passwords generation? io.jmix.securityflowui.view.resetpassword/resetPasswordView.exportActionButton.text = Excel io.jmix.securityflowui.view.resetpassword/resetPasswordView.copyButton.tooltip = Copy to clipboard +io.jmix.securityflowui.view.resetpassword/resetPasswordView.requireChangeAtNextLogon = Require change at next logon io.jmix.securityflowui.view.resetpassword.model/UserPasswordValue.username = Username io.jmix.securityflowui.view.resetpassword.model/UserPasswordValue.password = Password diff --git a/jmix-security/security-flowui/src/main/resources/io/jmix/securityflowui/view/resetpassword/reset-password-view.xml b/jmix-security/security-flowui/src/main/resources/io/jmix/securityflowui/view/resetpassword/reset-password-view.xml index 6ca8328af3..e843280e79 100644 --- a/jmix-security/security-flowui/src/main/resources/io/jmix/securityflowui/view/resetpassword/reset-password-view.xml +++ b/jmix-security/security-flowui/src/main/resources/io/jmix/securityflowui/view/resetpassword/reset-password-view.xml @@ -33,6 +33,10 @@ + diff --git a/jmix-security/security/src/main/java/io/jmix/security/user/PasswordChangeRequired.java b/jmix-security/security/src/main/java/io/jmix/security/user/PasswordChangeRequired.java new file mode 100644 index 0000000000..069d51467a --- /dev/null +++ b/jmix-security/security/src/main/java/io/jmix/security/user/PasswordChangeRequired.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 io.jmix.security.user; + +import io.jmix.core.entity.annotation.MetaAnnotation; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Marks a Boolean field of a user entity that indicates whether the user is required to change + * the password at the next logon. + *

+ * When this annotation is present on a field: + *

+ * Only one field of a class can have this annotation. + */ +@Target({FIELD}) +@Retention(RUNTIME) +@MetaAnnotation +public @interface PasswordChangeRequired { +} diff --git a/jmix-security/security/src/main/java/io/jmix/security/user/PasswordChangeRequiredSupport.java b/jmix-security/security/src/main/java/io/jmix/security/user/PasswordChangeRequiredSupport.java new file mode 100644 index 0000000000..93c736a5a3 --- /dev/null +++ b/jmix-security/security/src/main/java/io/jmix/security/user/PasswordChangeRequiredSupport.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 io.jmix.security.user; + +import io.jmix.core.Metadata; +import io.jmix.core.entity.EntityValues; +import io.jmix.core.metamodel.model.MetaClass; +import io.jmix.core.metamodel.model.MetaProperty; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.jspecify.annotations.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Helper for working with the user entity field marked with the {@link PasswordChangeRequired} annotation. + *

+ * If the user entity does not have such a field, all methods of this class behave as no-ops: + * {@link #isPasswordChangeRequired(Object)} returns {@code false} and {@link #setPasswordChangeRequired(Object, boolean)} + * does nothing. This guarantees that the feature is fully optional and does not affect projects whose + * user entity is not annotated. + */ +@Component("sec_PasswordChangeRequiredSupport") +public class PasswordChangeRequiredSupport { + + @Autowired + protected Metadata metadata; + + protected final ConcurrentHashMap, Optional> propertyCache = new ConcurrentHashMap<>(); + + /** + * @return the {@link MetaProperty} corresponding to the field marked with {@link PasswordChangeRequired} + * in the given user class, or {@code null} if the class does not have such a field, or the field is not + * registered in the metamodel + */ + @Nullable + public MetaProperty findFlagProperty(Class userClass) { + return propertyCache.computeIfAbsent(userClass, this::lookupFlagProperty) + .orElse(null); + } + + /** + * @return {@code true} if the given user has the field marked with {@link PasswordChangeRequired} and its + * value is {@code true} + */ + public boolean isPasswordChangeRequired(@Nullable Object user) { + if (!EntityValues.isEntity(user)) { + return false; + } + + MetaProperty property = findFlagProperty(user.getClass()); + if (property == null) { + return false; + } + + Boolean value = EntityValues.getValue(user, property.getName()); + return Boolean.TRUE.equals(value); + } + + /** + * Sets the value of the field marked with {@link PasswordChangeRequired} on the given user. + * Does nothing if the user is not a Jmix entity or does not have such a field. + */ + public void setPasswordChangeRequired(Object user, boolean value) { + if (!EntityValues.isEntity(user)) { + return; + } + + MetaProperty property = findFlagProperty(user.getClass()); + if (property == null) { + return; + } + + EntityValues.setValue(user, property.getName(), value); + } + + protected Optional lookupFlagProperty(Class userClass) { + List annotatedFields = Arrays.stream(FieldUtils.getAllFields(userClass)) + .filter(f -> f.isAnnotationPresent(PasswordChangeRequired.class)) + .toList(); + if (annotatedFields.isEmpty()) { + return Optional.empty(); + } + + if (annotatedFields.size() > 1) { + String names = annotatedFields.stream() + .map(Field::getName) + .collect(Collectors.joining(", ")); + throw new IllegalStateException( + "Class '%s' has multiple fields annotated with @PasswordChangeRequired: [%s]. Only one is allowed." + .formatted(userClass.getName(), names)); + } + + Field field = annotatedFields.get(0); + Class fieldType = field.getType(); + if (fieldType != Boolean.class && fieldType != boolean.class) { + throw new IllegalStateException(("Field '%s' in class '%s' annotated with @PasswordChangeRequired" + + " must be of type Boolean or boolean, but is '%s'.") + .formatted(field.getName(), userClass.getName(), fieldType.getName())); + } + + MetaClass metaClass = metadata.findClass(userClass); + if (metaClass == null) { + return Optional.empty(); + } + + return Optional.ofNullable(metaClass.findProperty(field.getName())); + } +} diff --git a/jmix-security/security/src/main/java/io/jmix/security/user/package-info.java b/jmix-security/security/src/main/java/io/jmix/security/user/package-info.java new file mode 100644 index 0000000000..22b39becc9 --- /dev/null +++ b/jmix-security/security/src/main/java/io/jmix/security/user/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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. + */ + +@NullMarked +package io.jmix.security.user; + +import org.jspecify.annotations.NullMarked; diff --git a/jmix-security/security/src/test/groovy/password_change_required/PasswordChangeRequiredSupportTest.groovy b/jmix-security/security/src/test/groovy/password_change_required/PasswordChangeRequiredSupportTest.groovy new file mode 100644 index 0000000000..ba48f1133b --- /dev/null +++ b/jmix-security/security/src/test/groovy/password_change_required/PasswordChangeRequiredSupportTest.groovy @@ -0,0 +1,132 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 password_change_required + +import io.jmix.core.Metadata +import io.jmix.security.user.PasswordChangeRequiredSupport +import org.springframework.beans.factory.annotation.Autowired +import test_support.SecuritySpecification +import test_support.entity.TestUserWithFlag +import test_support.entity.TestUserWithTwoFlags +import test_support.entity.TestUserWithWrongFieldType +import test_support.entity.TestUserWithoutFlag + +class PasswordChangeRequiredSupportTest extends SecuritySpecification { + + @Autowired + PasswordChangeRequiredSupport support + + @Autowired + Metadata metadata + + def "flag property is found on entity annotated with @PasswordChangeRequired"() { + when: + def property = support.findFlagProperty(TestUserWithFlag) + + then: + property != null + property.name == 'changePasswordAtNextLogon' + } + + def "flag property is not found on entity without @PasswordChangeRequired"() { + expect: + support.findFlagProperty(TestUserWithoutFlag) == null + } + + def "isPasswordChangeRequired returns false for entity without the flag field"() { + given: + def user = metadata.create(TestUserWithoutFlag) + + expect: + !support.isPasswordChangeRequired(user) + } + + def "isPasswordChangeRequired reflects the value of the annotated field"() { + given: + def user = metadata.create(TestUserWithFlag) + + expect: 'default value is false' + !support.isPasswordChangeRequired(user) + + when: + user.setChangePasswordAtNextLogon(true) + + then: + support.isPasswordChangeRequired(user) + } + + def "isPasswordChangeRequired returns false for null user"() { + expect: + !support.isPasswordChangeRequired(null) + } + + def "isPasswordChangeRequired returns false for non-entity object"() { + expect: + !support.isPasswordChangeRequired('not an entity') + } + + def "setPasswordChangeRequired updates the field"() { + given: + def user = metadata.create(TestUserWithFlag) + user.setChangePasswordAtNextLogon(true) + + when: + support.setPasswordChangeRequired(user, false) + + then: + user.changePasswordAtNextLogon == false + + when: + support.setPasswordChangeRequired(user, true) + + then: + user.changePasswordAtNextLogon == true + } + + def "setPasswordChangeRequired is a no-op for entity without the flag field"() { + given: + def user = metadata.create(TestUserWithoutFlag) + + when: + support.setPasswordChangeRequired(user, true) + + then: + noExceptionThrown() + } + + def "findFlagProperty fails with a clear error when more than one field is annotated"() { + when: + support.findFlagProperty(TestUserWithTwoFlags) + + then: + def ex = thrown(IllegalStateException) + ex.message.contains(TestUserWithTwoFlags.name) + ex.message.contains('flagOne') + ex.message.contains('flagTwo') + } + + def "findFlagProperty fails with a clear error when the annotated field has a wrong type"() { + when: + support.findFlagProperty(TestUserWithWrongFieldType) + + then: + def ex = thrown(IllegalStateException) + ex.message.contains(TestUserWithWrongFieldType.name) + ex.message.contains('flagAsString') + ex.message.contains('Boolean') + } +} diff --git a/jmix-security/security/src/test/java/test_support/entity/TestUserWithFlag.java b/jmix-security/security/src/test/java/test_support/entity/TestUserWithFlag.java new file mode 100644 index 0000000000..38b09cb10f --- /dev/null +++ b/jmix-security/security/src/test/java/test_support/entity/TestUserWithFlag.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 test_support.entity; + +import io.jmix.core.metamodel.annotation.JmixEntity; +import io.jmix.security.user.PasswordChangeRequired; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity(name = "test_UserWithFlag") +@JmixEntity +@Table(name = "TEST_USER_WITH_FLAG") +public class TestUserWithFlag extends BaseEntity { + + @Column(name = "USERNAME") + private String username; + + @PasswordChangeRequired + @Column(name = "CHANGE_PASSWORD_AT_NEXT_LOGON") + private Boolean changePasswordAtNextLogon = false; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Boolean getChangePasswordAtNextLogon() { + return changePasswordAtNextLogon; + } + + public void setChangePasswordAtNextLogon(Boolean changePasswordAtNextLogon) { + this.changePasswordAtNextLogon = changePasswordAtNextLogon; + } +} diff --git a/jmix-security/security/src/test/java/test_support/entity/TestUserWithTwoFlags.java b/jmix-security/security/src/test/java/test_support/entity/TestUserWithTwoFlags.java new file mode 100644 index 0000000000..6816c4dc5d --- /dev/null +++ b/jmix-security/security/src/test/java/test_support/entity/TestUserWithTwoFlags.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 test_support.entity; + +import io.jmix.core.metamodel.annotation.JmixEntity; +import io.jmix.security.user.PasswordChangeRequired; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity(name = "test_UserWithTwoFlags") +@JmixEntity +@Table(name = "TEST_USER_WITH_TWO_FLAGS") +public class TestUserWithTwoFlags extends BaseEntity { + + @PasswordChangeRequired + @Column(name = "FLAG_ONE") + private Boolean flagOne = false; + + @PasswordChangeRequired + @Column(name = "FLAG_TWO") + private Boolean flagTwo = false; +} diff --git a/jmix-security/security/src/test/java/test_support/entity/TestUserWithWrongFieldType.java b/jmix-security/security/src/test/java/test_support/entity/TestUserWithWrongFieldType.java new file mode 100644 index 0000000000..f9938f6589 --- /dev/null +++ b/jmix-security/security/src/test/java/test_support/entity/TestUserWithWrongFieldType.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 test_support.entity; + +import io.jmix.core.metamodel.annotation.JmixEntity; +import io.jmix.security.user.PasswordChangeRequired; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity(name = "test_UserWithWrongFieldType") +@JmixEntity +@Table(name = "TEST_USER_WITH_WRONG_FIELD_TYPE") +public class TestUserWithWrongFieldType extends BaseEntity { + + @Column(name = "USERNAME") + private String username; + + @PasswordChangeRequired + @Column(name = "FLAG_AS_STRING") + private String flagAsString; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getFlagAsString() { + return flagAsString; + } + + public void setFlagAsString(String flagAsString) { + this.flagAsString = flagAsString; + } +} diff --git a/jmix-security/security/src/test/java/test_support/entity/TestUserWithoutFlag.java b/jmix-security/security/src/test/java/test_support/entity/TestUserWithoutFlag.java new file mode 100644 index 0000000000..6898c476ae --- /dev/null +++ b/jmix-security/security/src/test/java/test_support/entity/TestUserWithoutFlag.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed 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 test_support.entity; + +import io.jmix.core.metamodel.annotation.JmixEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity(name = "test_UserWithoutFlag") +@JmixEntity +@Table(name = "TEST_USER_WITHOUT_FLAG") +public class TestUserWithoutFlag extends BaseEntity { + + @Column(name = "USERNAME") + private String username; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/jmix-templates/content/project/application-kotlin/src/main/kotlin/${project_rootPath}/entity/User.kt b/jmix-templates/content/project/application-kotlin/src/main/kotlin/${project_rootPath}/entity/User.kt index 768e355229..d65de55649 100644 --- a/jmix-templates/content/project/application-kotlin/src/main/kotlin/${project_rootPath}/entity/User.kt +++ b/jmix-templates/content/project/application-kotlin/src/main/kotlin/${project_rootPath}/entity/User.kt @@ -8,6 +8,7 @@ import io.jmix.core.metamodel.annotation.DependsOnProperties import io.jmix.core.metamodel.annotation.InstanceName import io.jmix.core.metamodel.annotation.JmixEntity import io.jmix.security.authentication.JmixUserDetails +import io.jmix.security.user.PasswordChangeRequired import jakarta.persistence.* import jakarta.validation.constraints.Email import org.springframework.security.core.GrantedAuthority @@ -56,6 +57,10 @@ open class User : JmixUserDetails, HasTimeZone { @get:JvmName("getTimeZoneId_") var timeZoneId: String? = null + @PasswordChangeRequired + @Column(name = "PASSWORD_CHANGE_REQUIRED") + var passwordChangeRequired: Boolean? = false + @Transient private var userAuthorities: Collection = emptyList() diff --git a/jmix-templates/content/project/application-kotlin/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml b/jmix-templates/content/project/application-kotlin/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml index 7788c05590..2efdc8d53b 100644 --- a/jmix-templates/content/project/application-kotlin/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml +++ b/jmix-templates/content/project/application-kotlin/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml @@ -25,6 +25,7 @@ + diff --git a/jmix-templates/content/project/application-kotlin/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties b/jmix-templates/content/project/application-kotlin/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties index 328a35f23f..e8cd3847a1 100644 --- a/jmix-templates/content/project/application-kotlin/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties +++ b/jmix-templates/content/project/application-kotlin/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties @@ -9,6 +9,7 @@ ${project_rootPackage}.entity/User.password=Password ${project_rootPackage}.entity/User.email=Email ${project_rootPackage}.entity/User.timeZoneId=Time zone ${project_rootPackage}.entity/User.active=Active +${project_rootPackage}.entity/User.passwordChangeRequired=Password change required ${project_rootPackage}.entity/User.version=Version ${project_rootPackage}.view.main/MainView.title=${project_projectPrintableName} diff --git a/jmix-templates/content/project/application-tabmod/src/main/java/${project_rootPath}/entity/User.java b/jmix-templates/content/project/application-tabmod/src/main/java/${project_rootPath}/entity/User.java index 2c5a451b9f..ca87cb7a25 100644 --- a/jmix-templates/content/project/application-tabmod/src/main/java/${project_rootPath}/entity/User.java +++ b/jmix-templates/content/project/application-tabmod/src/main/java/${project_rootPath}/entity/User.java @@ -8,6 +8,7 @@ import io.jmix.core.metamodel.annotation.InstanceName; import io.jmix.core.metamodel.annotation.JmixEntity; import io.jmix.security.authentication.JmixUserDetails; +import io.jmix.security.user.PasswordChangeRequired; import org.springframework.security.core.GrantedAuthority; import jakarta.persistence.*; import jakarta.validation.constraints.Email; @@ -55,6 +56,10 @@ public class User implements JmixUserDetails, HasTimeZone { @Column(name = "TIME_ZONE_ID") private String timeZoneId; + @PasswordChangeRequired + @Column(name = "PASSWORD_CHANGE_REQUIRED") + private Boolean passwordChangeRequired = false; + @Transient private Collection authorities; @@ -123,6 +128,14 @@ public void setLastName(final String lastName) { this.lastName = lastName; } + public Boolean getPasswordChangeRequired() { + return passwordChangeRequired; + } + + public void setPasswordChangeRequired(final Boolean passwordChangeRequired) { + this.passwordChangeRequired = passwordChangeRequired; + } + @Override public Collection getAuthorities() { return authorities != null ? authorities : Collections.emptyList(); diff --git a/jmix-templates/content/project/application-tabmod/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml b/jmix-templates/content/project/application-tabmod/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml index 7788c05590..2efdc8d53b 100644 --- a/jmix-templates/content/project/application-tabmod/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml +++ b/jmix-templates/content/project/application-tabmod/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml @@ -25,6 +25,7 @@ + diff --git a/jmix-templates/content/project/application-tabmod/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties b/jmix-templates/content/project/application-tabmod/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties index 328a35f23f..e8cd3847a1 100644 --- a/jmix-templates/content/project/application-tabmod/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties +++ b/jmix-templates/content/project/application-tabmod/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties @@ -9,6 +9,7 @@ ${project_rootPackage}.entity/User.password=Password ${project_rootPackage}.entity/User.email=Email ${project_rootPackage}.entity/User.timeZoneId=Time zone ${project_rootPackage}.entity/User.active=Active +${project_rootPackage}.entity/User.passwordChangeRequired=Password change required ${project_rootPackage}.entity/User.version=Version ${project_rootPackage}.view.main/MainView.title=${project_projectPrintableName} diff --git a/jmix-templates/content/project/application/src/main/java/${project_rootPath}/entity/User.java b/jmix-templates/content/project/application/src/main/java/${project_rootPath}/entity/User.java index 2c5a451b9f..ca87cb7a25 100644 --- a/jmix-templates/content/project/application/src/main/java/${project_rootPath}/entity/User.java +++ b/jmix-templates/content/project/application/src/main/java/${project_rootPath}/entity/User.java @@ -8,6 +8,7 @@ import io.jmix.core.metamodel.annotation.InstanceName; import io.jmix.core.metamodel.annotation.JmixEntity; import io.jmix.security.authentication.JmixUserDetails; +import io.jmix.security.user.PasswordChangeRequired; import org.springframework.security.core.GrantedAuthority; import jakarta.persistence.*; import jakarta.validation.constraints.Email; @@ -55,6 +56,10 @@ public class User implements JmixUserDetails, HasTimeZone { @Column(name = "TIME_ZONE_ID") private String timeZoneId; + @PasswordChangeRequired + @Column(name = "PASSWORD_CHANGE_REQUIRED") + private Boolean passwordChangeRequired = false; + @Transient private Collection authorities; @@ -123,6 +128,14 @@ public void setLastName(final String lastName) { this.lastName = lastName; } + public Boolean getPasswordChangeRequired() { + return passwordChangeRequired; + } + + public void setPasswordChangeRequired(final Boolean passwordChangeRequired) { + this.passwordChangeRequired = passwordChangeRequired; + } + @Override public Collection getAuthorities() { return authorities != null ? authorities : Collections.emptyList(); diff --git a/jmix-templates/content/project/application/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml b/jmix-templates/content/project/application/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml index 7788c05590..2efdc8d53b 100644 --- a/jmix-templates/content/project/application/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml +++ b/jmix-templates/content/project/application/src/main/resources/${project_rootPath}/liquibase/changelog/010-init-user.xml @@ -25,6 +25,7 @@ + diff --git a/jmix-templates/content/project/application/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties b/jmix-templates/content/project/application/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties index 328a35f23f..e8cd3847a1 100644 --- a/jmix-templates/content/project/application/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties +++ b/jmix-templates/content/project/application/src/main/resources/${project_rootPath}/messages_${current_locale.code}.properties @@ -9,6 +9,7 @@ ${project_rootPackage}.entity/User.password=Password ${project_rootPackage}.entity/User.email=Email ${project_rootPackage}.entity/User.timeZoneId=Time zone ${project_rootPackage}.entity/User.active=Active +${project_rootPackage}.entity/User.passwordChangeRequired=Password change required ${project_rootPackage}.entity/User.version=Version ${project_rootPackage}.view.main/MainView.title=${project_projectPrintableName} diff --git a/jmix-translations/content/io/jmix/securityflowui/messages.properties b/jmix-translations/content/io/jmix/securityflowui/messages.properties index 8afe5e1edc..1938ec964d 100644 --- a/jmix-translations/content/io/jmix/securityflowui/messages.properties +++ b/jmix-translations/content/io/jmix/securityflowui/messages.properties @@ -213,6 +213,7 @@ io.jmix.securityflowui.view.changepassword/changePasswordView.passwordsDoNotMatc io.jmix.securityflowui.view.changepassword/changePasswordView.passwordChanged=Password successfully changed io.jmix.securityflowui.view.changepassword/changePasswordView.emptyUsernameMessage=Dialog cannot be opened without username io.jmix.securityflowui.view.changepassword/changePasswordView.title=Change password for [%s] +io.jmix.securityflowui.view.changepassword/changePasswordView.forcedTitle=Password change required io.jmix.securityflowui.view.resetpassword/resetPasswordView.resetPasswordsTitle = Generate new passwords for selected users io.jmix.securityflowui.view.resetpassword/resetPasswordView.resetSinglePasswordTitle = Generate a new password for [%s] @@ -230,6 +231,7 @@ io.jmix.securityflowui.view.resetpassword/resetPasswordView.cancelDialog.title = io.jmix.securityflowui.view.resetpassword/resetPasswordView.cancelDialog.message = Do you want to cancel passwords generation? io.jmix.securityflowui.view.resetpassword/resetPasswordView.exportActionButton.text = Excel io.jmix.securityflowui.view.resetpassword/resetPasswordView.copyButton.tooltip = Copy to clipboard +io.jmix.securityflowui.view.resetpassword/resetPasswordView.requireChangeAtNextLogon = Require change at next logon io.jmix.securityflowui.view.resetpassword.model/UserPasswordValue.username = Username io.jmix.securityflowui.view.resetpassword.model/UserPasswordValue.password = Password diff --git a/jmix-translations/content/io/jmix/securityflowui/messages_ru.properties b/jmix-translations/content/io/jmix/securityflowui/messages_ru.properties index 774b2f5b8b..8843f41017 100644 --- a/jmix-translations/content/io/jmix/securityflowui/messages_ru.properties +++ b/jmix-translations/content/io/jmix/securityflowui/messages_ru.properties @@ -213,6 +213,7 @@ io.jmix.securityflowui.view.changepassword/changePasswordView.passwordsDoNotMatc io.jmix.securityflowui.view.changepassword/changePasswordView.passwordChanged=Пароль успешно изменен io.jmix.securityflowui.view.changepassword/changePasswordView.emptyUsernameMessage=Диалог не может быть открыт без имени пользователя io.jmix.securityflowui.view.changepassword/changePasswordView.title=Сменить пароль для пользователя [%s] +io.jmix.securityflowui.view.changepassword/changePasswordView.forcedTitle=Обязательная смена пароля io.jmix.securityflowui.view.resetpassword/resetPasswordView.resetPasswordsTitle = Генерация нового пароля выбранным пользователям io.jmix.securityflowui.view.resetpassword/resetPasswordView.resetSinglePasswordTitle = Генерация нового пароля для [%s] @@ -230,6 +231,7 @@ io.jmix.securityflowui.view.resetpassword/resetPasswordView.cancelDialog.title = io.jmix.securityflowui.view.resetpassword/resetPasswordView.cancelDialog.message = Вы действительно хотите отменить генерацию паролей? io.jmix.securityflowui.view.resetpassword/resetPasswordView.exportActionButton.text = Excel io.jmix.securityflowui.view.resetpassword/resetPasswordView.copyButton.tooltip = Копировать в буфер обмена +io.jmix.securityflowui.view.resetpassword/resetPasswordView.requireChangeAtNextLogon = Требовать смены пароля при следующем входе в систему io.jmix.securityflowui.view.resetpassword.model/UserPasswordValue.username = Имя пользователя io.jmix.securityflowui.view.resetpassword.model/UserPasswordValue.password = Пароль