UserServiceImpl.java
/*
* Copyright 2018 Global Crop Diversity Trust
*
* 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.genesys.server.service.impl;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.Period;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
import javax.persistence.EntityManager;
import javax.persistence.EntityNotFoundException;
import org.apache.commons.collections4.ListUtils;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.validator.routines.EmailValidator;
import org.genesys.blocks.security.NoUserFoundException;
import org.genesys.blocks.security.NotUniqueUserException;
import org.genesys.blocks.security.SecurityContextUtil;
import org.genesys.blocks.security.UserException;
import org.genesys.blocks.security.model.BasicUser.AccountType;
import org.genesys.blocks.security.service.BasicUserService;
import org.genesys.blocks.security.service.CustomAclService;
import org.genesys.blocks.security.service.PasswordPolicy.PasswordPolicyException;
import org.genesys.blocks.security.service.impl.BasicUserServiceImpl;
import org.genesys.server.exception.NotFoundElement;
import org.genesys.server.model.Partner;
import org.genesys.server.model.QPartner;
import org.genesys.server.model.UserRole;
import org.genesys.server.model.impl.QUser;
import org.genesys.server.model.impl.User;
import org.genesys.server.persistence.PartnerRepository;
import org.genesys.server.persistence.UserRepository;
import org.genesys.server.service.ContentService;
import org.genesys.server.service.EMailService;
import org.genesys.server.service.UserService;
import org.genesys.server.service.filter.UserFilter;
import org.genesys.spring.TransactionHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.CacheManager;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.acls.domain.BasePermission;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.querydsl.core.BooleanBuilder;
import lombok.extern.slf4j.Slf4j;
@Service(value = "userService")
@Transactional(readOnly = true)
@Slf4j
public class UserServiceImpl extends BasicUserServiceImpl<UserRole, User> implements UserService {
/// A non-password used for system and Google accounts
private static final String THIS_IS_NOT_A_PASSWORD = "THIS-IS-NOT-A-PASSWORD";
@Autowired
private UserRepository userRepository;
private final List<UserRole> availableRoles = ListUtils.unmodifiableList(Arrays.asList(UserRole.values()));
private EmailValidator emailValidator = EmailValidator.getInstance();
@Autowired
private CustomAclService aclService;
@Autowired
private PartnerRepository partnerRepository;
@Autowired
private ContentService contentService;
@Autowired
private EMailService eMailService;
@Value("${frontend.url}")
private String frontendUrl;
@Value("${base.url}")
private String apiUrl;
@Autowired
private EntityManager entityManager;
/** The cache manager. */
@Autowired(required = false)
private CacheManager cacheManager;
@Override
public void afterPropertiesSet() throws Exception {
super.afterPropertiesSet();
try {
asAdmin(() -> {
// Ensure system-wide user roles are defined
for (UserRole systemRole : UserRole.values()) {
log.warn("Ensuring SID for role {}", systemRole.getAuthority());
aclService.ensureAuthoritySid(systemRole.getAuthority());
}
return true;
});
} catch (Throwable e) {
log.warn("Could not add system-wide roles: {}", e.getMessage());
}
}
private <T> T asAdmin(Callable<T> callable) throws Exception {
UserDetails administrator = loadUserByUsername(BasicUserService.SYSTEM_ADMIN);
List<GrantedAuthority> authorities = Lists.newArrayList(UserRole.ADMINISTRATOR);
authorities.addAll(administrator.getAuthorities());
Authentication authentication = new UsernamePasswordAuthenticationToken(administrator, null, authorities);
return TransactionHelper.asUser(authentication, callable);
}
@Override
public List<UserRole> listAvailableRoles() {
return availableRoles;
}
@Override
protected JpaRepository<User, Long> getUserRepository() {
return userRepository;
}
@Override
protected User createSystemAdministrator(String username) throws UserException {
final User admin = createUser(username, "System Administrator", null, AccountType.SYSTEM);
setRoles(admin, Sets.newHashSet(UserRole.ADMINISTRATOR));
return admin;
}
@Override
public List<UserRole> getDefaultUserRoles() {
return List.of(UserRole.USER, UserRole.EVERYONE);
}
@Override
@Transactional
public User createUser(String email, String fullName, String password, AccountType accountType) throws UserException {
log.info("Creating user email={} fullName={}", email, fullName);
final User user = new User();
user.setEmail(email);
user.setFullName(fullName);
user.setAccountType(accountType);
super.setPassword(user, password);
return deepLoad(userRepository.save(user));
}
@Override
@Transactional
@Cacheable(cacheNames = { "userDetails" }, key = "#username", unless = "#result == null")
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.warn("Loading user details {}", username);
final User user = getUserByEmail(username);
if (user == null) {
log.debug("User does not exist: {}", username);
throw new UsernameNotFoundException(username);
}
final boolean enabled = user.isEnabled();
final boolean accountNonLocked = !user.isAccountLocked();
if (!accountNonLocked) {
log.warn("Account is locked for user={} until={}", user.getEmail(), user.getLockedUntil());
}
if (!enabled) {
log.warn("Account is disabled for user={}", user.getEmail());
}
if (user.getLockedUntil() != null && accountNonLocked) {
log.warn("Account can be unlocked for user={}", user.getEmail());
user.setLockedUntil(null);
userRepository.save(user);
}
/*
* Order of authorities is extremely important in ACL!
* First the dynamic runtime authorities are added, then all the default authorities.
*/
var runtimeAuthorities = new LinkedHashSet<GrantedAuthority>(20);
runtimeAuthorities.addAll(getDynamicAuthorities(user));
runtimeAuthorities.addAll(user.getRoles());
getDefaultUserRoles().forEach(runtimeAuthorities::remove); // Remove them so they are added to the end
runtimeAuthorities.addAll(getDefaultUserRoles());
user.setRuntimeAuthorities(new ArrayList<>(runtimeAuthorities));
return user;
}
@Override
// FIXME Re-enable this
// @PreAuthorize("hasRole('ADMINISTRATOR')")
public Page<User> listUsers(Pageable pageable) {
return userRepository.findAll(pageable);
}
@Override
@Transactional
@PreAuthorize("hasRole('ADMINISTRATOR') || principal.id == #user.id")
public User updateUser(User user, String email, String fullName) throws UserException {
if (!emailValidator.isValid(email)) {
log.warn("Invalid email provided: {}", email);
throw new UserException("Invalid email provided: " + email);
}
evictFromCache(user);
return super.updateUser(user, email, fullName);
}
protected User updateUser(User user) throws UserException {
try {
evictFromCache(user);
return userRepository.save(user);
} catch (final DataIntegrityViolationException e) {
throw new NotUniqueUserException(e, user.getEmail());
} catch (final EmptyResultDataAccessException e) {
throw new NoUserFoundException(e, user.getId());
} catch (final RuntimeException e) {
throw new UserException(e);
}
}
@Override
@Transactional
@PreAuthorize("hasRole('ADMINISTRATOR')")
public void setAccountActive(UUID uuid, boolean enabled) throws UserException {
final User user = userRepository.findByUuid(uuid.toString());
if (user == null) {
throw new NoUserFoundException("Record not found by UUID=" + uuid);
}
if (!enabled && user.getRoles().contains(UserRole.ADMINISTRATOR)) {
throw new SecurityException("Can't disable ADMINISTRATOR accounts");
}
user.setActive(enabled);
updateUser(user);
log.warn("User account for user={} enabled={}", user.getEmail(), enabled);
}
/**
* Checks if the passwords are equal.
*
* @param rawPassword password entered by user
* @param encodedPassword current encoded user's password
* @return true if passwords are equal
*/
@Override
public boolean checkPasswordsMatch(String rawPassword, String encodedPassword) {
return passwordEncoder.matches(rawPassword, encodedPassword);
}
@Override
public User getMe() {
final User user = userRepository.findById(SecurityContextUtil.getCurrentUser().getId()).orElse(null);
if (user != null) {
user.getRoles().size();
}
return user;
}
@Override
public User getUserByEmail(String email) {
final User user = userRepository.findByEmail(email);
return deepLoad(user);
}
/**
* This allows us to add dynamic authorities, e.g. membership in a Partner.
*/
@Override
protected Set<GrantedAuthority> getDynamicAuthorities(User user) {
// Has role on Partner
final HashSet<Long> partnersIds = new HashSet<>();
partnersIds.addAll(aclService.listObjectIdentityIdsForSid(Partner.class, user, BasePermission.WRITE));
partnersIds.addAll(aclService.listObjectIdentityIdsForSid(Partner.class, user, BasePermission.CREATE));
final Set<GrantedAuthority> newAuthorities = new HashSet<>(partnersIds.size());
partnerRepository.findAll(QPartner.partner.id.in(partnersIds)).forEach(partner -> {
newAuthorities.add(new SimpleGrantedAuthority(partner.getAuthorityName()));
log.debug("Adding partner SID {}", partner.getAuthorityName());
});
return newAuthorities;
}
@Override
public User getUser(UUID uuid) {
final User user = userRepository.findByUuid(uuid.toString());
return deepLoad(user);
}
@Override
public User getUser(UUID uuid, Integer version) throws NoUserFoundException, ConcurrencyFailureException {
final User user = getUser(uuid);
if (user == null) {
throw new NoUserFoundException("Record not found by uuid=" + uuid);
}
if (version != null && !user.getVersion().equals(version)) {
log.warn("Record versions don't match anymore");
throw new ConcurrencyFailureException("Object version changed to " + user.getVersion() + ", you provided " + version);
}
return user;
}
@Override
public boolean exists(String username) {
return userRepository.findByEmail(username) != null;
}
@Override
@Transactional
public void userEmailValidated(UUID uuid) {
log.info("Validating user email for uuid={}", uuid);
final User user = userRepository.findByUuid(uuid.toString());
Set<UserRole> userRoles = user.getRoles();
if (userRoles == null) {
log.debug("User roles are null, creating role set");
user.setRoles(userRoles = new HashSet<UserRole>());
}
// Since it's a set, we can just add
userRoles.add(UserRole.VALIDATEDUSER);
try {
updateUser(user);
addRoleToCurrentUser(user, UserRole.VALIDATEDUSER.getName());
log.info("Ensured VALIDATEDUSER role for user {}", user);
} catch (final UserException e) {
}
}
@Override
@Transactional
public void addVettedUserRole(UUID uuid) {
final User user = userRepository.findByUuid(uuid.toString());
final Set<UserRole> userRoles = user.getRoles();
userRoles.add(UserRole.VETTEDUSER);
try {
updateUser(user);
addRoleToCurrentUser(user, UserRole.VETTEDUSER.getName());
log.info("Add role VETTEDUSER for user {}", user);
} catch (final UserException e) {
log.error(e.getMessage(), e);
}
}
private void addRoleToCurrentUser(User user, String role) {
final Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof User) {
if (!((User) principal).getId().equals(user.getId())) {
log.warn("Not adding role, user != principal");
return;
}
} else {
log.warn("Not adding role to current principal: {}", principal);
return;
}
final List<GrantedAuthority> authorities = new ArrayList<>(SecurityContextHolder.getContext().getAuthentication().getAuthorities());
for (final GrantedAuthority authority : authorities) {
if (authority.getAuthority().equals(role)) {
return;
}
}
final SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role);
authorities.add(0, simpleGrantedAuthority); // Insert authority to top of the list
final Authentication authentication = new UsernamePasswordAuthenticationToken(principal, principal, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
@Override
public List<User> autocompleteUser(String email) {
if (StringUtils.isBlank(email) || email.length() < 4)
return Collections.emptyList();
return userRepository.autocompleteByEmail(email + "%", PageRequest.of(0, 10, Sort.by("email")));
}
@Override
@PreAuthorize("isAuthenticated()")
public List<User> autocompleteUser(final String email, final int limit) {
if (StringUtils.isBlank(email) || email.length() < 1) {
return Collections.emptyList();
}
return userRepository.autocomplete(email, PageRequest.of(0, Integer.min(100, limit), Sort.by("email")));
}
@Override
@Transactional
public User setAccountType(User user, AccountType accountType) {
User u = userRepository.findById(user.getId()).orElseThrow(() -> new EntityNotFoundException("User not found."));
u.setAccountType(accountType);
if (accountType != AccountType.LOCAL) {
user.setPassword(THIS_IS_NOT_A_PASSWORD);
}
evictFromCache(u);
return userRepository.save(u);
}
/**
* Set FTP application password for the user.
*/
@Override
@Transactional
@PreAuthorize("hasRole('ADMINISTRATOR') || (hasRole('VETTEDUSER') && principal.id == #user.id)")
public void setFtpPassword(final User user, final String ftpPassword) throws PasswordPolicyException {
assureGoodPassword(ftpPassword);
user.setFtpPassword(passwordEncoder.encode(ftpPassword));
evictFromCache(user);
userRepository.save(user);
}
@Override
@Transactional
@PreAuthorize("isFullyAuthenticated()")
public void disableMyAccount() throws UserException {
User currentUser = SecurityContextUtil.getMe();
User u = userRepository.findById(currentUser.getId()).orElseThrow(() -> new EntityNotFoundException("User not found."));
if (u.hasRole(UserRole.ADMINISTRATOR.getName())) {
throw new UserException("Refusing to disable active administrator account");
}
Calendar expires = Calendar.getInstance();
expires.add(Calendar.MONTH, 1);
u.setAccountExpires(expires.toInstant());
u.setActive(false);
// u.setAccountType(AccountType.DELETED);
evictFromCache(u);
userRepository.save(u);
}
@Override
@Transactional
@PreAuthorize("hasRole('ADMINISTRATOR')")
public User extendAccount(User user) {
Calendar expires = Calendar.getInstance();
expires.add(Calendar.MONTH, 3);
if (user.getAccountExpires() == null || user.getAccountExpires().isBefore(expires.toInstant())) {
user.setAccountExpires(expires.toInstant());
user.setActive(true);
evictFromCache(user);
return deepLoad(userRepository.save(user));
}
return deepLoad(user);
}
/* (non-Javadoc)
* @see org.genesys.server.service.UserService#archiveUser(org.genesys.server.model.impl.User)
*/
@Override
@Transactional
public User archiveUser(User user) throws UserException {
user = userRepository.findById(user.getId()).orElseThrow(NoUserFoundException::new);
if (user.hasRole(UserRole.ADMINISTRATOR.getName())) {
throw new UserException("Refusing to disable active administrator account");
}
log.warn("Archiving user {}", user.getEmail());
Instant now = Instant.now();
user.setAccountExpires(now);
user.setActive(false);
user.setAccountType(AccountType.LOCAL);
// user.setAccountType(AccountType.DELETED);
user.setEmail("deleted@" + now.getEpochSecond() + now.get(ChronoField.MILLI_OF_SECOND));
user.setPassword(THIS_IS_NOT_A_PASSWORD);
user.setFtpPassword(null);
user.setFullName("USER ACCOUNT DELETED");
user.setShortName("deleted" + now.getEpochSecond() + now.get(ChronoField.MILLI_OF_SECOND));
user.setPasswordExpires(now);
user.getRoles().clear();
updateUser(user);
log.warn("Removing ACL entries for {}", user.getEmail());
aclEntryRepository.deleteAll(user.getAclEntries());
return user;
}
/* (non-Javadoc)
* @see org.genesys.server.service.UserService#list(org.genesys.server.service.filter.UserFilter, org.springframework.data.domain.Pageable)
*/
@PreAuthorize("hasRole('ADMINISTRATOR')")
@Override
public Page<User> list(UserFilter filter, Pageable page) {
Page<User> res = userRepository.findAll(new BooleanBuilder().and(filter.buildPredicate()), page);
return new PageImpl<>(res.getContent(), page, res.getTotalElements());
}
@PreAuthorize("hasRole('ADMINISTRATOR')")
@Override
public void sendEmail(Set<UUID> userUuids, String template, String subject) {
var uuids = userUuids.stream().map(UUID::toString).collect(Collectors.toSet());
var users = userRepository.findAll(QUser.user.uuid.in(uuids));
Map<String, Object> root = new HashMap<>();
root.put("frontendUrl", frontendUrl);
root.put("apiUrl", apiUrl);
for (User user : users) {
root.put("user", user);
var messageBody = contentService.processTemplate(template, root);
eMailService.sendMail(subject, messageBody, user.getEmail());
}
}
@Override
public User loadOrRegisterUser(OidcUser oidcUser, String provider) {
String email = oidcUser.getEmail();
if (StringUtils.isBlank(email)) {
throw new UsernameNotFoundException("Provided email is blank");
}
User user = getUserByEmail(email);
if (user != null) {
if (user.getAccountType() == AccountType.LOCAL) {
// Change to OAuth
try {
var updatedUser = user;
updatedUser.setAccountType(AccountType.valueOf(provider.toUpperCase()));
var password = "INV" + passwordEncoder.encode(RandomStringUtils.random(32, true, true));
updatedUser.setPassword(password);
user = asAdmin(() -> TransactionHelper.executeInTransaction(false, () -> updateUser(updatedUser)));
} catch (Exception e) {
log.warn("Error upgrading local account for {} to {}: {}", email, provider.toUpperCase(), e.getMessage(), e);
throw new AuthenticationServiceException("Could not upgrade to OAuth login", e);
}
} else if (!StringUtils.equalsIgnoreCase(user.getAccountType().name(), provider)) {
throw new AuthenticationServiceException("You must authenticate with ".concat(user.getAccountType().name()));
}
} else {
try {
user = asAdmin(() -> TransactionHelper.executeInTransaction(false, () -> {
var password = "INV" + passwordEncoder.encode(RandomStringUtils.random(32, true, true));
return createUser(email, oidcUser.getFullName(), password, AccountType.valueOf(provider.toUpperCase()));
}));
} catch (Exception e) {
log.warn("Error creating a local account for {}: {}", email, e.getMessage(), e);
throw new AuthenticationServiceException("Could not register user", e);
}
}
deepLoad(user);
/*
* Order of authorities is extremely important in ACL!
* First the dynamic runtime authorities are added, then all the default authorities.
*/
var runtimeAuthorities = new LinkedHashSet<GrantedAuthority>(20);
runtimeAuthorities.addAll(getDynamicAuthorities(user));
runtimeAuthorities.addAll(user.getRoles());
runtimeAuthorities.removeAll(getDefaultUserRoles()); // Remove them so they are added to the end
runtimeAuthorities.addAll(getDefaultUserRoles());
user.setRuntimeAuthorities(new ArrayList<>(runtimeAuthorities));
entityManager.detach(user);
return user;
}
@Override
@Transactional
public void updateLastLogin(String userName) throws NoUserFoundException {
User u = getUserByEmail(userName);
if (u == null) {
throw new NoUserFoundException("No such user.");
}
log.warn("Updating last login timestamp for {}", userName);
// Set account to expire 1 year after last login
userRepository.updateLastLogin(u, Instant.now(), OffsetDateTime.now().plus(Period.ofYears(1)).toInstant());
}
@Override
@Transactional
@PreAuthorize("hasRole('ADMINISTRATOR')")
public void setAccountLock(long userId, boolean locked) throws NoUserFoundException {
evictFromCache(getUser(userId));
super.setAccountLock(userId, locked);
}
@Override
@Transactional
public void setAccountLockLocal(long userId, boolean locked) throws NoUserFoundException {
evictFromCache(getUser(userId));
super.setAccountLockLocal(userId, locked);
}
@Override
@Transactional
@PreAuthorize("hasRole('ADMINISTRATOR') || principal.id == #user.id")
public User changePassword(User user, String password) throws PasswordPolicyException {
evictFromCache(user);
return super.changePassword(user, password);
}
@Override
@Transactional
public User setRoles(User user, Set<UserRole> newRoles) {
evictFromCache(user);
return super.setRoles(user, newRoles);
}
@Override
@Transactional
@PreAuthorize("hasRole('ADMINISTRATOR')")
public void deleteUser(User user) {
evictFromCache(user);
super.deleteUser(user);
}
@Override
@Transactional
public User updateUserPreferences(UserPreferences preferences) throws UserException {
var user = getMe();
if (user == null) {
throw new NoUserFoundException();
}
if (preferences.language != null) {
user.setLanguage(preferences.language);
}
if (preferences.timezone != null) {
user.setTimezone(preferences.timezone);
}
user = userRepository.saveAndFlush(user);
evictFromCache(user);
entityManager.detach(user);
return user;
}
@Override
@Transactional
public User updateUserPreference(String setting, boolean value) throws UserException {
var user = getMe();
if (user == null) {
throw new NoUserFoundException();
}
Instant instant = Instant.now();
if ("aiOptin".equals(setting)) {
user.setAiOptin(value);
user.setAiDate(instant);
} else if ("newsletterOptin".equals(setting)) {
user.setNewsletterOptin(value);
user.setNewsletterDate(instant);
} else {
throw new NotFoundElement("Setting \"" + setting + "\" not found.");
}
user = userRepository.saveAndFlush(user);
evictFromCache(user);
entityManager.detach(user);
return user;
}
private void evictFromCache(final User user) {
if (user != null && user.getUsername() != null && cacheManager != null) {
var cache = cacheManager.getCache("userDetails");
if (cache != null) cache.evict(user.getUsername());
}
}
}