Skip to content
Merged
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 @@ -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.
* <p>
* 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<UserDetails, String> resetPasswords(Set<UserDetails> users) {
return resetPasswords(users, true);
return resetPasswords(users, true, true);
}

/**
* Generates new passwords for passed users.
* <p>
* 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<UserDetails, String> resetPasswords(Set<UserDetails> users, boolean saveChanges);
default Map<UserDetails, String> resetPasswords(Set<UserDetails> 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<UserDetails, String> resetPasswords(Set<UserDetails> users, boolean saveChanges,
boolean requireChangeAtNextLogon);

/**
* Resets 'remember me' token for the specific user.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,6 +80,8 @@ public abstract class AbstractDatabaseUserRepository<T extends UserDetails> impl
protected ApplicationEventPublisher eventPublisher;
@Autowired
protected RoleGrantedAuthorityUtils roleGrantedAuthorityUtils;
@Autowired
protected PasswordChangeRequiredSupport passwordChangeRequiredSupport;

/**
* Helps create authorities from roles.
Expand Down Expand Up @@ -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);
Expand All @@ -225,7 +229,8 @@ private void changePassword(T userDetails, @Nullable String oldPassword, @Nullab
}

@Override
public Map<UserDetails, String> resetPasswords(Set<UserDetails> users, boolean saveChanges) {
public Map<UserDetails, String> resetPasswords(Set<UserDetails> users, boolean saveChanges,
boolean requireChangeAtNextLogon) {
Map<UserDetails, String> usernamePasswordMap = new LinkedHashMap<>();
SaveContext saveContext = new SaveContext();

Expand All @@ -244,6 +249,7 @@ public Map<UserDetails, String> resetPasswords(Set<UserDetails> users, boolean s
success = true;
} while (!success);

passwordChangeRequiredSupport.setPasswordChangeRequired(userDetails, requireChangeAtNextLogon);
saveContext.saving(userDetails);
usernamePasswordMap.put(userDetails, newPassword);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading