UserServiceImpl.java
/*
* Copyright 2019 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.gringlobal.service.impl;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
import javax.persistence.EntityManager;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
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.persistence.AclEntryPersistence;
import org.genesys.blocks.security.service.CustomAclService;
import org.genesys.blocks.security.service.PasswordPolicy;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.api.exception.ReusedPasswordException;
import org.gringlobal.model.Cooperator;
import org.gringlobal.model.QSysUser;
import org.gringlobal.model.SysGroup;
import org.gringlobal.model.SysGroupUserMap;
import org.gringlobal.model.SysUser;
import org.gringlobal.model.community.CommunityCodeValues;
import org.gringlobal.model.security.UserRole;
import org.gringlobal.oauth2.server.TenantRepository;
import org.gringlobal.persistence.CooperatorRepository;
import org.gringlobal.persistence.SysGroupUserMapRepository;
import org.gringlobal.persistence.SysUserRepository;
import org.gringlobal.service.LanguageService;
import org.gringlobal.service.UserService;
import org.gringlobal.service.filter.SysUserFilter;
import org.gringlobal.spring.TransactionHelper;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import com.google.common.collect.Lists;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
@Service(value = "userService")
@Transactional(readOnly = true)
@Validated
@Slf4j
public class UserServiceImpl implements UserService, InitializingBean {
@Autowired
protected CustomAclService aclService;
@Autowired
private EntityManager entityManager;
@Autowired
private SysUserRepository userRepository;
@Autowired
private SysGroupUserMapRepository sysGroupUserMapRepository;
@Autowired
private PasswordPolicy passwordPolicy;
@Autowired
private CooperatorRepository cooperatorRepository;
@Autowired
private LanguageService languageService;
@Autowired
@Qualifier("soapPasswordEncoder")
private PasswordEncoder soapPasswordEncoder;
// TODO Read from .properties
private static final int PASS_HISTORY = 2;
private static final String PASSWORDS_SEPARATOR = ":";
@Autowired
private TenantRepository oauthTenantRegistrationRepository;
@Autowired
protected AclEntryPersistence aclEntryRepository;
@Override
public void afterPropertiesSet() throws Exception {
}
private <T> T asAdmin(Callable<T> callable) throws Exception {
UserDetails administrator = loadUserByUsername("administrator");
List<GrantedAuthority> authorities = Lists.newArrayList(UserRole.ADMINISTRATOR);
authorities.addAll(administrator.getAuthorities());
Authentication authentication = new UsernamePasswordAuthenticationToken(administrator, null, authorities);
// ensure AIA folder has write permissions
return TransactionHelper.asUser(authentication, callable);
}
@Override
@Cacheable(value="usercache", key = "#username", unless = "#result == null")
public SysUser loadUserByUsername(final String username) throws UsernameNotFoundException {
final SysUser user = userRepository.findByUsername(username);
log.debug("Found user {} for name: {}", user, username);
if (user == null) {
throw new UsernameNotFoundException("User username=" + username + " not found");
}
lazyLoad(user);
log.debug("User {}#{} has authorities: {}", user.getUsername(), user.getId(), user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(
",")));
entityManager.detach(user);
return user;
}
@Override
public SysUser loadSysUserByEmail(final String email) throws UsernameNotFoundException {
Optional<SysUser> userOptional = userRepository.findOne(QSysUser.sysUser.cooperator().email.eq(email));
if (userOptional.isEmpty()) {
throw new UsernameNotFoundException("User with email=" + email + " not found");
}
SysUser user = userOptional.get();
log.debug("Found user {} for email: {}", user, email);
lazyLoad(user);
log.debug("User {}#{} has authorities: {}", user.getUsername(), user.getId(), user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(
",")));
entityManager.detach(user);
return user;
}
@Override
@Transactional
public SysUser loadOrRegisterUser(OidcUser oidcUser, String provider) throws AuthenticationException {
String email = oidcUser.getEmail();
if (StringUtils.isBlank(email)) {
throw new UsernameNotFoundException("Provided email is blank");
}
Optional<SysUser> userOptional = userRepository.findOne(QSysUser.sysUser.cooperator().email.eq(email));
SysUser user;
if (userOptional.isPresent()) {
user = userOptional.get();
if (!StringUtils.equalsIgnoreCase(user.getProvider(), provider)) {
throw new AuthenticationServiceException("You must authenticate with ".concat(user.getProvider()));
}
} else {
try {
asAdmin(() -> {
log.warn("getCountry {}", oidcUser.getAddress().getCountry());
log.warn("getLocality {}", oidcUser.getAddress().getLocality());
log.warn("getStreetAddress {}", oidcUser.getAddress().getStreetAddress());
// Build Cooperator
Cooperator cooperator = new Cooperator();
cooperator.setFirstName(oidcUser.getGivenName());
cooperator.setLastName(oidcUser.getFamilyName());
var lang = languageService.getLanguage(Locale.forLanguageTag(oidcUser.getLocale()));
if (lang == null) {
lang = languageService.getLanguage(Locale.getDefault());
}
// Check if we have existing records for Cooperator unique key: firstName, lastName, addressLine1, organization, geographyId
var existingCooperators = cooperatorRepository.findAll(Example.of(cooperator));
if (existingCooperators.size() == 0) {
cooperator.setStatusCode(CommunityCodeValues.COOPERATOR_STATUS_ACTIVE.value);
cooperator.setEmail(email);
cooperator.setSysLang(lang);
cooperator.setPrimaryPhone(oidcUser.getPhoneNumber());
cooperator.setAddressLine1(oidcUser.getAddress().getFormatted());
cooperator = cooperatorRepository.save(cooperator);
} else if (existingCooperators.size() == 1) {
// There is one Cooperator with this name, address?
var oneCooperator = existingCooperators.get(0);
if (StringUtils.equalsIgnoreCase(oneCooperator.getEmail(), email)) {
cooperator = oneCooperator; // Use the one with the same email, there is no SysUser for it yet
} else if (oneCooperator.getEmail() == null){
// Just link it
cooperator = oneCooperator;
cooperator.setEmail(email);
cooperator = cooperatorRepository.save(cooperator);
} else if (oneCooperator.getSecondaryEmail() == null){
// Email mismatch, switch emails
cooperator = oneCooperator;
cooperator.setSecondaryEmail(cooperator.getEmail());
cooperator.setEmail(email);
cooperator = cooperatorRepository.save(cooperator);
} else {
// Needs to be a new cooperator
cooperator.setStatusCode(CommunityCodeValues.COOPERATOR_STATUS_ACTIVE.value);
cooperator.setEmail(email);
cooperator.setSysLang(lang);
cooperator.setPrimaryPhone(oidcUser.getPhoneNumber());
cooperator.setAddressLine1(oidcUser.getAddress().getFormatted());
if (StringUtils.isBlank(cooperator.getAddressLine1())) {
cooperator.setAddressLine1("Login with " + provider);
}
cooperator = cooperatorRepository.save(cooperator);
}
} else {
// We have potentially multiple existing Cooperators with this name
// Any one with the same email?
var sameEmail = existingCooperators.stream().filter(c -> StringUtils.equalsIgnoreCase(c.getEmail(), email)).collect(Collectors.toList());
if (sameEmail.size() > 0) {
cooperator = sameEmail.get(0);
} else {
// Needs to be a new cooperator
cooperator.setStatusCode(CommunityCodeValues.COOPERATOR_STATUS_ACTIVE.value);
cooperator.setEmail(email);
cooperator.setPrimaryPhone(oidcUser.getPhoneNumber());
cooperator.setSysLang(lang);
cooperator.setPrimaryPhone(oidcUser.getPhoneNumber());
cooperator.setAddressLine1("Login with " + provider);
cooperator = cooperatorRepository.save(cooperator);
}
}
SysUser userForSave = new SysUser();
userForSave.setIsEnabled("Y");
userForSave.setCooperator(cooperator);
userForSave.setUsername(email);
// Set a random password with INV prefix so that it doesn't decode
userForSave.setPassword("INV" + soapPasswordEncoder.encode(RandomStringUtils.random(32, true, true)));
userForSave.setProvider(provider);
create(userForSave);
return true;
});
user = userRepository.findOne(QSysUser.sysUser.cooperator().email.eq(email)).orElseThrow(() -> new UsernameNotFoundException("User not found"));
} catch (Exception e) {
log.warn("Error creating a local account for {}: {}", email, e.getMessage(), e);
throw new AuthenticationServiceException("Could not register user", e);
}
}
lazyLoad(user);
entityManager.detach(user);
return user;
}
@Override
public SysUser loadSysUser(Long id) throws NoUserFoundException {
SysUser user = userRepository.findById(id).orElseThrow(() -> new NoUserFoundException("No such user"));
lazyLoad(user);
return user;
}
@Override
@Transactional
@PreAuthorize("hasAuthority('GROUP_ADMINS')")
@CacheEvict(value="usercache", key = "#result.username")
public SysUser setAccountActive(Long id, boolean enabled) throws UserException {
SysUser user = loadSysUser(id);
if (SecurityContextUtil.getCurrentUser().getId().equals(id) && enabled == false) {
throw new AccessDeniedException("You can't disable your own account");
}
// Remove user from ADMINS group first, then disable
if (!enabled && user.getAuthorities().stream().anyMatch(a -> a.getAuthority().equalsIgnoreCase(SysGroup.ADMIN_AUTHORITY))) {
throw new AccessDeniedException("Can't disable ADMINISTRATOR accounts");
}
user.setIsEnabled(enabled ? "Y" : "N");
user = updateUser(user);
log.warn("User account for user={} enabled={}", user.getUsername(), enabled);
return user;
}
@Override
@Transactional
@PreAuthorize("hasAuthority('GROUP_ADMINS') || principal.id == #user.id")
@CacheEvict(value="usercache", key = "#user.username")
public void setPassword(SysUser user, String password) throws UserException {
user = loadSysUser(user.getId());
if (! user.isEnabled()) {
throw new InvalidApiUsageException("Password for disabled user accounts can't be changed!");
}
// Check password reuse
if (isPasswordReused(user, password)) {
throw new ReusedPasswordException("Cannot reuse this password");
}
passwordPolicy.assureGoodPassword(password);
// Trim past passwords
final List<String> pastPasswords = Lists.newArrayList(user.getPassword().split(PASSWORDS_SEPARATOR));
for (int i = pastPasswords.size() - 1; i >= 0 && i >= PASS_HISTORY; i--) {
pastPasswords.remove(i);
}
log.warn("Keeping {} old password hashes", pastPasswords.size());
user.setPassword(soapPasswordEncoder.encode(password) + PASSWORDS_SEPARATOR + pastPasswords.stream().collect(Collectors.joining(PASSWORDS_SEPARATOR)));
updateUser(user);
}
private boolean isPasswordReused(final SysUser sysUser, final String newPassword) {
final String[] oldHash = sysUser.getPassword().split(PASSWORDS_SEPARATOR);
for (int i = 0; i < oldHash.length; i++) {
if (i >= PASS_HISTORY) {
return false;
}
if (soapPasswordEncoder.matches(newPassword, oldHash[i])) {
return true;
}
}
return false;
}
@Override
@PreAuthorize("hasAuthority('GROUP_ADMINS')")
public Page<SysUser> list(SysUserFilter filter, Pageable page) {
BooleanBuilder predicate = new BooleanBuilder();
if (filter != null) {
predicate.and(filter.buildPredicate());
}
var usersPage = userRepository.findAll(predicate, page);
usersPage.getContent().forEach(user -> {
if (user.getCooperator() != null) {
user.getCooperator().getId();
user.getCooperator().lazyLoad();
}
});
return usersPage;
}
private List<GrantedAuthority> getDefaultUserAuthorities() {
return List.of(UserRole.USER, UserRole.EVERYONE);
}
/**
* Calculate final runtime authorities for the user.
* Order of authorities is important for ACL evaluation!
*/
private List<GrantedAuthority> getRuntimeAuthorities(SysUser user) {
var authorities = new LinkedHashSet<GrantedAuthority>(20);
if (CollectionUtils.isNotEmpty(user.getGroupMaps())) {
boolean isAdmin = user.getGroupMaps().stream().map(SysGroupUserMap::getSysGroup).filter(sysGroup -> sysGroup.getGroupTag().equals(SysGroup.ADMIN_GROUPTAG)).findFirst().orElse(null) != null;
if (isAdmin) {
authorities.add(UserRole.ADMINISTRATOR);
}
user.getGroupMaps().forEach(gm -> {
SysGroup sysGroup = gm.getSysGroup();
if (sysGroup.isEnabled()) {
authorities.add(sysGroup);
}
});
}
authorities.removeAll(getDefaultUserAuthorities());
authorities.addAll(getDefaultUserAuthorities());
return new ArrayList<>(authorities);
}
private SysUser lazyLoad(final SysUser user) {
assert user != null;
if (user.getCooperator() != null) {
user.getCooperator().getId();
user.getCooperator().lazyLoad();
}
if (user.getGroupMaps() != null) {
user.getGroupMaps().forEach(sysGroupUserMap -> Hibernate.initialize(sysGroupUserMap.getSysGroup()));
}
user.setRuntimeAuthorities(getRuntimeAuthorities(user));
return user;
}
@Override
@Transactional
@PreAuthorize("hasAuthority('GROUP_ADMINS')")
public SysUser create(SysUser source) throws PasswordPolicy.PasswordPolicyException {
SysUser user = new SysUser();
user.setIsEnabled("Y");
user.setCooperator(source.getCooperator());
user.setUsername(source.getUsername());
user.setProvider(source.getProvider());
String password = source.getPassword();
passwordPolicy.assureGoodPassword(password);
user.setPassword(soapPasswordEncoder.encode(password));
return userRepository.save(user);
}
@Override
@Transactional
@PreAuthorize("hasAuthority('GROUP_ADMINS')")
@CacheEvict(value="usercache", key = "#result.username")
public SysUser setSysGroups(SysUser sysUser, List<SysGroup> newGroups) throws NoUserFoundException {
final var user = loadSysUser(sysUser.getId());
var assignedSysGroups = user.getGroupMaps().stream().map(map -> (SysGroup) Hibernate.unproxy(map.getSysGroup())).collect(Collectors.toList());
if (newGroups.containsAll(assignedSysGroups) && assignedSysGroups.containsAll(newGroups)) {
log.debug("SysGroups {} match {}. No change.", newGroups, assignedSysGroups);
return user;
}
List<SysGroupUserMap> commonGroups = new ArrayList<>();
List<SysGroupUserMap> newGroupsToAssign = new ArrayList<>();
newGroups.forEach(sg -> {
if (assignedSysGroups.contains(sg)) {
commonGroups.add(user.getGroupMaps().stream().filter(map -> map.getSysGroup().equals(sg)).findFirst().get());
} else {
newGroupsToAssign.add(new SysGroupUserMap(user, sg));
}
});
if (CollectionUtils.isNotEmpty(commonGroups)) {
// keep common sys groups and delete remaining
sysGroupUserMapRepository.deleteAllBySysUserExceptSomeGroups(user, commonGroups.stream().map(SysGroupUserMap::getId).collect(Collectors.toList()));
} else {
// there are no common sys groups, so delete all
sysGroupUserMapRepository.deleteAllBySysUser(user);
}
user.getGroupMaps().clear();
user.getGroupMaps().addAll(sysGroupUserMapRepository.saveAll(newGroupsToAssign));
user.getGroupMaps().addAll(commonGroups);
return user;
}
@Override
@Transactional
@PreAuthorize("hasAuthority('GROUP_ADMINS')")
@Caching(evict = {
@CacheEvict(value="usercache", key = "#target.username", beforeInvocation = true),
@CacheEvict(value="usercache", key = "#result.username")
})
public SysUser update(SysUser updated, SysUser target) throws UserException {
SysUser user = loadSysUser(target.getId());
user.setIsEnabled(updated.getIsEnabled());
user.setCooperator(updated.getCooperator() == null ? null : cooperatorRepository.getReferenceById(updated.getCooperator().getId()));
var providerForUpdate = updated.getProvider();
if (StringUtils.isNotBlank(providerForUpdate) && !StringUtils.equals(providerForUpdate, user.getProvider())) {
if (oauthTenantRegistrationRepository.findByRegistrationId(providerForUpdate) == null) {
throw new UserException("Provider not supported: ".concat(providerForUpdate));
}
user.setProvider(providerForUpdate);
}
return updateUser(user);
}
@Override
public List<SysUser> autocompleteSysUsers(String username, int limit) {
BooleanExpression expression = QSysUser.sysUser.username.startsWithIgnoreCase(StringUtils.trimToEmpty(username));
return userRepository.findAll(expression, PageRequest.of(0, Integer.min(100, limit), Sort.by("username"))).getContent();
}
@Override
public SysUser getUserForCooperator(Cooperator cooperator) {
if (cooperator == null) {
return null;
}
SysUser bestUser = null;
Iterable<SysUser> cooperatorUsers = userRepository.findAll(QSysUser.sysUser.cooperator().eq(cooperator));
for (SysUser user : cooperatorUsers) {
if (bestUser == null) {
bestUser = user;
} else if (! bestUser.isEnabled() && user.isEnabled()) {
bestUser = user;
} else {
log.warn("Skipping {} for {}", user.getSid(), cooperator.getId());
}
}
return bestUser;
}
@Override
@Transactional
@PreAuthorize("hasAuthority('GROUP_ADMINS')")
@CacheEvict(value="usercache", key = "#sysUser.username")
public SysUser remove(SysUser sysUser) throws UserException {
var userForRemove = userRepository.findById(sysUser.getId()).orElseThrow(NoUserFoundException::new);
if (userForRemove.isEnabled() && userForRemove.getGroupMaps().stream().anyMatch(groupMap -> groupMap.getSysGroup().getAuthority().equalsIgnoreCase(SysGroup.ADMIN_AUTHORITY))) {
throw new AccessDeniedException("Can't remove enabled ADMINISTRATOR account");
}
log.warn("Archiving user {}", userForRemove.getUsername());
Instant now = Instant.now();
userForRemove.setIsEnabled("N");
userForRemove.setActive(false);
userForRemove.setUsername("deleted@" + now.toEpochMilli());
userForRemove.setCooperator(null);
userForRemove = updateUser(userForRemove);
sysGroupUserMapRepository.deleteAllBySysUser(userForRemove);
aclEntryRepository.deleteAll(userForRemove.getAclEntries());
return loadSysUser(userForRemove.getId());
}
private SysUser updateUser(SysUser user) throws UserException {
try {
return userRepository.save(user);
} catch (final DataIntegrityViolationException e) {
throw new NotUniqueUserException(e, user.getUsername());
} catch (final EmptyResultDataAccessException e) {
throw new NoUserFoundException(e, user.getId());
} catch (final RuntimeException e) {
throw new UserException(e);
}
}
}