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);
		}
	}
}