User.java
/*
* Copyright 2005-2025 the original author or authors.
*
* 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 org.openwms.core.uaa.impl;
import jakarta.persistence.Column;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Inheritance;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany;
import jakarta.persistence.PostLoad;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotEmpty;
import org.ameba.integration.jpa.ApplicationEntity;
import org.openwms.core.uaa.InvalidPasswordException;
import org.openwms.core.uaa.app.DefaultTimeProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.Assert;
import java.io.Serializable;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import static jakarta.persistence.CascadeType.ALL;
import static jakarta.persistence.CascadeType.MERGE;
import static jakarta.persistence.CascadeType.REFRESH;
/**
* An User represents a human user of the system. Typically an User is assigned to one or more {@code Roles} to define security constraints.
* Users can have their own configuration settings in form of {@code UserPreferences} and certain user details, encapsulated in an {@code
* UserDetails} object that tend to be extended by projects.
*
* @author Heiko Scherrer
* @GlossaryTerm
* @see UserDetails
* @see UserPassword
* @see Role
*/
@JacksonAware
@Entity
@Table(name = "COR_UAA_USER", uniqueConstraints = @UniqueConstraint(name = "UC_UAA_USER_NAME", columnNames = {"C_USERNAME"}))
@Inheritance
@DiscriminatorColumn(name = "C_TYPE")
@DiscriminatorValue("STANDARD")
public class User extends ApplicationEntity implements Serializable {
private static final Logger LOGGER = LoggerFactory.getLogger(User.class);
/** Unique identifier of this User (not nullable). */
@Column(name = "C_USERNAME", nullable = false)
@NotEmpty
private String username;
/** {@code true} if the User is authenticated by an external system, otherwise {@code false}. */
@Column(name = "C_EXTERN")
private boolean extern = false;
/** Date of the last password change. */
@Column(name = "C_LAST_PASSWORD_CHANGE")
private ZonedDateTime lastPasswordChange;
/** {@code true} if this User is locked and has no permission to login. */
@Column(name = "C_LOCKED")
private boolean locked = false;
/** The User's current password (only kept transient). */
@Transient
private String password;
/** The User's current password. */
@Column(name = "C_PASSWORD")
private String persistedPassword;
/** {@code true} if the User is enabled. This field can be managed by the UI application to lock the User manually. */
@Column(name = "C_ENABLED")
private boolean enabled = true;
/** Date when the account expires. After account expiration, the User cannot login anymore. */
@Column(name = "C_EXPIRATION_DATE")
private ZonedDateTime expirationDate;
/** The User's fullname (doesn't have to be unique). */
@Column(name = "C_FULLNAME")
private String fullname;
/** Email addresses. */
@OneToMany(mappedBy = "user", cascade = {ALL}, orphanRemoval = true)
private Set<Email> emailAddresses;
/** More detail information of the User. */
@Embedded
private UserDetails userDetails;
/** List of {@link Role}s assigned to the User. */
@ManyToMany(mappedBy = "users", cascade = {MERGE, REFRESH})
private List<Role> roles = new ArrayList<>();
/** Last passwords of the User. */
@OneToMany(mappedBy = "user", cascade = {ALL}, orphanRemoval = true)
private List<UserPassword> passwords = new ArrayList<>();
/** The number of passwords to keep in the password history. Default: {@value}. */
public static final short NUMBER_STORED_PASSWORDS = 3;
/* ----------------------------- constructors ------------------- */
/** Dear JPA... */
protected User() {
super();
loadLazy();
}
/**
* Create a new User with an username.
*
* @param username The unique name of the user
* @throws IllegalArgumentException when username is {@literal null} or empty
*/
public User(String username) {
super();
Assert.hasText(username, "Not allowed to create an User with an empty username");
this.username = username;
loadLazy();
}
/**
* Create a new User with a username.
*
* @param username The unique name of the user
* @param password The password of the user
* @throws IllegalArgumentException when username or password is {@literal null} or empty
*/
protected User(String username, String password) {
super();
Assert.hasText(username, "Not allowed to create an User with an empty username");
Assert.hasText(password, "Not allowed to create an User with an empty password");
this.username = username;
this.password = password;
}
/* ----------------------------- methods ------------------- */
/**
* After load, the saved password is copied to the transient one. The transient one can be overridden by the application to force a
* password change.
*/
@PostLoad
public void postLoad() {
loadLazy();
}
protected void loadLazy() {
password = persistedPassword;
}
@Override
public void setPersistentKey(String pKey) {
super.setPersistentKey(pKey);
}
@Override
public void setOl(long ol) {
super.setOl(ol);
}
/**
* Set the {@code password} and {@code persistedPassword} to {@literal null}.
*/
public void wipePassword() {
this.password = null;
this.persistedPassword = null;
}
public boolean addNewEmailAddress(Email email) {
Assert.notNull(email, "Email must not be null");
var existingOnes = getEmailAddressesInternal();
if (!existingOnes.contains(email)) {
email.setUser(this);
return existingOnes.add(email);
}
return false;
}
public boolean removeEmailAddress(Email email) {
Assert.notNull(email, "Email must not be null");
var existingOnes = getEmailAddressesInternal();
if (existingOnes.contains(email)) {
var existingEmail = existingOnes.stream().filter(e -> e.equals(email)).findFirst().orElseThrow();
return existingOnes.remove(existingEmail);
}
return false;
}
/**
* Return the unique username of the User.
*
* @return The current username
*/
public String getUsername() {
return username;
}
/**
* Change the username of the User.
*
* @param username The new username to set
*/
// Must be public for the MapStruct mapper
public void setUsername(String username) {
this.username = username;
}
/**
* Is the User authenticated by an external system?
*
* @return {@literal true} if so, otherwise {@literal false}
*/
public boolean isExternalUser() {
return extern;
}
/**
* Change the authentication method of the User.
*
* @param externalUser {@literal true} if the User was authenticated by an external system, otherwise {@literal false}.
*/
public void setExternalUser(boolean externalUser) {
extern = externalUser;
}
/**
* Return the date when the password has been changed the last time.
*
* @return The date when the password has been changed the last time
*/
public ZonedDateTime getLastPasswordChange() {
return lastPasswordChange;
}
/**
* Set the date when the password has been changed the last time.
*
* @param lastPasswordChange The date when the password has been changed the last time
*/
public void setLastPasswordChange(ZonedDateTime lastPasswordChange) {
this.lastPasswordChange = lastPasswordChange;
}
/**
* Supply {@code lastPasswordChange} to the consumer {@code c} if present.
*
* @param c The consumer
* @return This instance
*/
public User supplyLastPasswordChange(Consumer<ZonedDateTime> c) {
if (lastPasswordChange != null) {
c.accept(lastPasswordChange);
}
return this;
}
/**
* Check if the User is locked.
*
* @return {@literal true} if locked, otherwise {@literal false}
*/
public boolean isLocked() {
return locked;
}
/**
* Lock the User.
*
* @param locked {@literal true} to lock the User, {@literal false} to unlock
*/
public void setLocked(boolean locked) {
this.locked = locked;
}
/**
* Returns the current password of the User.
*
* @return The current password as String
*/
public String getPassword() {
return password;
}
/**
* Checks if the new password is a valid and change the password of this User.
*
* @param encodedPassword The new encoded password of this User
* @throws InvalidPasswordException in case changing the password is not allowed or the new password is not valid
*/
public void changePassword(String encodedPassword, String rawPassword, PasswordEncoder encoder) throws InvalidPasswordException {
if (persistedPassword != null && encoder.matches(rawPassword, persistedPassword)) {
LOGGER.debug("Password matches, no need to change");
return;
}
validateAgainstPasswordHistory(rawPassword, encoder);
storeOldPassword(password);
persistedPassword = encodedPassword;
password = encodedPassword;
lastPasswordChange = new DefaultTimeProvider().nowAsZonedDateTime();
}
/**
* Checks whether the password is going to change.
*
* @return {@literal true} when {@code password} is different to the originally persisted one, otherwise {@literal false}
*/
public boolean hasPasswordChanged() {
return (persistedPassword.equals(password));
}
/**
* Check whether the new password is in the history of former passwords.
*
* @param rawPassword The password to verify
*/
protected void validateAgainstPasswordHistory(String rawPassword, PasswordEncoder encoder) throws InvalidPasswordException {
for (var up : passwords) {
if (encoder.matches(rawPassword, up.getPassword())) {
throw new InvalidPasswordException("Password does not match the defined rules");
}
}
}
private void storeOldPassword(String oldPassword) {
if (oldPassword == null || oldPassword.isEmpty()) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("If the old password is null, do not store it in history");
}
return;
}
passwords.add(new UserPassword(this, oldPassword));
if (passwords.size() > NUMBER_STORED_PASSWORDS) {
passwords.sort(new PasswordComparator());
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Remove the old password from the history: [{}]", (passwords.get(passwords.size() - 1)));
}
var pw = passwords.get(passwords.size() - 1);
pw.setUser(null);
passwords.remove(pw);
}
}
/**
* Determines whether the User is enabled or not.
*
* @return {@literal true} if the User is enabled, otherwise {@literal false}
*/
public boolean isEnabled() {
return enabled;
}
/**
* Enable or disable the User.
*
* @param enabled {@literal true} when enabled, otherwise {@literal false}
*/
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
/**
* Return the date when the account expires.
*
* @return The expiration date
*/
public ZonedDateTime getExpirationDate() {
return expirationDate;
}
/**
* Change the date when the account expires.
*
* @param expDate The new expiration date to set
*/
public void setExpirationDate(ZonedDateTime expDate) {
expirationDate = expDate;
}
/**
* Returns a list of granted {@link Role}s.
*
* @return The list of granted {@link Role}s
*/
public List<Role> getRoles() {
return roles;
}
/**
* Supply {@code roles} to the consumer {@code c} if present.
*
* @param c The consumer
* @return This instance
*/
public User supplyRoles(Consumer<List<Role>> c) {
if (roles != null && !roles.isEmpty()) {
c.accept(roles);
}
return this;
}
/**
* Flatten {@link Role}s and {@link Grant}s and return a List of all {@link Grant}s assigned to this User.
*
* @return A list of all {@link Grant}s
*/
public List<SecurityObject> getGrants() {
var grants = new ArrayList<SecurityObject>();
for (var role : getRoles()) {
grants.addAll(role.getGrants());
}
return new ArrayList<>(grants);
}
/**
* Supply {@code grants} to the consumer {@code c} if present.
*
* @param c The consumer
* @return This instance
*/
public User supplyGrants(Consumer<List<SecurityObject>> c) {
var grants = getGrants();
if (grants != null && !grants.isEmpty()) {
c.accept(grants);
}
return this;
}
/**
* Add a new {@link Role} to the list of {@link Role}s.
*
* @param role The new {@link Role} to add
* @return see {@link java.util.Collection#add(Object)}
*/
public boolean addRole(Role role) {
return roles.add(role);
}
/**
* Set the {@link Role}s of this User. Existing {@link Role}s will be overridden.
*
* @param roles The new list of {@link Role}s
*/
public void setRoles(List<Role> roles) {
this.roles = roles;
}
/**
* Return the fullname of the User.
*
* @return The current fullname
*/
public String getFullname() {
return fullname;
}
/**
* Change the fullname of the User.
*
* @param fullname The new fullname to set
*/
public void setFullname(String fullname) {
this.fullname = fullname;
}
public User setFullname(Consumer<String> c) {
if (fullname != null && !fullname.isEmpty()) {
c.accept(fullname);
}
return this;
}
private Set<Email> getEmailAddressesInternal() {
if (emailAddresses == null) {
emailAddresses = new HashSet<>();
}
return emailAddresses;
}
public Set<Email> getEmailAddresses() {
return emailAddresses == null ? null : new HashSet<>(emailAddresses);
}
public void setEmailAddresses(Set<Email> emailAddresses) {
this.emailAddresses = emailAddresses;
}
public Optional<Email> getPrimaryEmailAddress() {
if (emailAddresses == null) {
return Optional.empty();
}
return emailAddresses.stream().filter(Email::isPrimary).findFirst();
}
/**
* Supply the primary {@code email} to the consumer {@code c} if present.
*
* @param c The consumer
* @return This instance
*/
public User supplyPrimaryEmailAddress(Consumer<Email> c) {
getPrimaryEmailAddress().ifPresent(c);
return this;
}
/**
* Return a list of recently used passwords.
*
* @return A list of recently used passwords
*/
public List<UserPassword> getPasswords() {
return passwords;
}
/**
* Return the details of the User.
*
* @return The userDetails
*/
public UserDetails getUserDetails() {
if (userDetails == null) {
userDetails = new UserDetails();
}
return userDetails;
}
/**
* Supply {@code userDetails} to the consumer {@code c} if present.
*
* @param c The consumer
* @return This instance
*/
public User supplyUserDetails(Consumer<UserDetails> c) {
if (userDetails != null) {
c.accept(userDetails);
}
return this;
}
/**
* Check whether this User has UserDetails set.
*
* @return {@literal true} if set, otherwise {@literal false}
*/
public boolean hasUserDetails() {
return userDetails != null;
}
/**
* Assign some details to the User.
*
* @param userDetails The userDetails to set
*/
public void setUserDetails(UserDetails userDetails) {
this.userDetails = userDetails;
}
/**
* {@inheritDoc}
* <p>
* Does not call the superclass. Uses the username for calculation.
*
* @see java.lang.Object#hashCode()
*/
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((username == null) ? 0 : username.hashCode());
return result;
}
/**
* {@inheritDoc}
* <p>
* Uses the username for comparison.
*
* @see java.lang.Object#equals(java.lang.Object)
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof User other)) {
return false;
}
if (username == null) {
return other.username == null;
} else {
return username.equals(other.username);
}
}
@Override
public String toString() {
return "User{" +
"username='" + username + '\'' +
", extern=" + extern +
", lastPasswordChange=" + lastPasswordChange +
", locked=" + locked +
", password='" + (password != null && !password.isEmpty() ? "*****" : "<NULL>") + '\'' +
", persistedPassword='" + (persistedPassword != null && !persistedPassword.isEmpty() ? "*****" : "<NULL>") + '\'' +
", enabled=" + enabled +
", expirationDate=" + expirationDate +
", fullname='" + fullname + '\'' +
", userDetails=" + userDetails +
'}';
}
/**
* A PasswordComparator sorts UserPassword by date ascending.
*
* @author Heiko Scherrer
*/
static class PasswordComparator implements Comparator<UserPassword>, Serializable {
/**
* {@inheritDoc}
*
* @see java.util.Comparator#compare(java.lang.Object, java.lang.Object)
*/
@Override
public int compare(UserPassword o1, UserPassword o2) {
return o2.getPasswordChanged().compareTo(o1.getPasswordChanged());
}
}
}