EMailVerificationServiceImpl.java

/*
 * Copyright 2020 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.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Callable;

import com.google.common.collect.Lists;
import lombok.extern.slf4j.Slf4j;
import org.genesys.blocks.security.UserException;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.model.AppResource;
import org.gringlobal.model.SysUser;
import org.gringlobal.model.VerificationToken;
import org.gringlobal.model.WebUser;
import org.gringlobal.service.AppResourceService;
import org.gringlobal.service.EMailService;
import org.gringlobal.service.EMailVerificationService;
import org.gringlobal.service.TemplatingService;
import org.gringlobal.service.TokenVerificationService;
import org.gringlobal.service.TokenVerificationService.*;
import org.gringlobal.service.UserService;
import org.gringlobal.service.WebUserService;
import org.gringlobal.spring.TransactionHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;


@Service
@Transactional(readOnly = true)
@Slf4j
public class EMailVerificationServiceImpl implements EMailVerificationService {

	@Autowired
	private TokenVerificationService tokenVerificationService;

	@Autowired
	private EMailService emailService;

	@Autowired
	private WebUserService webUserService;

	@Autowired
	private UserService userService;

	@Autowired
	private TemplatingService templatingService;

	@Autowired
	private AppResourceService appResourceService;

	@Value("${frontend.url}")
	private String frontendUrl;

	@Override
	@Transactional
	public void sendVerificationEmail(WebUser webUser) {

		// Generate new token
		final VerificationToken verificationToken = tokenVerificationService.generateToken("email-verification", webUser.getUsername());

		final Map<String, Object> scopes = new HashMap<>();
		scopes.put("url", frontendUrl);
		scopes.put("tokenUUID", verificationToken.getUuid());
		scopes.put("email", webUser.getUsername());
		scopes.put("tokenKey", verificationToken.getKey());

		final AppResource resource = appResourceService.getResource(AppResourceService.APP_NAME_GGCE, "email/webuser-verify-email.mustache", Locale.ENGLISH);
		String messageTitle;
		String messageTemplate;
		if (resource != null) {
			messageTitle = resource.getValueMember();
			messageTemplate = resource.getDisplayMember();
		} else {
			log.info("AppResource 'email/webuser-verify-email.mustache' not found, using bundled template");
			try {
				messageTitle = "Verify your email address";
				messageTemplate = Files.readString(Path.of(getClass().getResource("/email/webuser-verify-email.mustache").getPath()));
			} catch (IOException e) {
				log.warn("Failed to load message template, not sending email.", e);
				return;
			}
		}
		final String mailBody = templatingService.fillTemplate(messageTemplate, scopes);
		emailService.sendMail(messageTitle, mailBody, webUser.getUsername());
	}

	@Override
	@Transactional
	public void validateEMail(String tokenUuid, String key) throws Exception {
		final VerificationToken consumedToken = tokenVerificationService.consumeToken("email-verification", tokenUuid, key);
		WebUser webUser = (WebUser) webUserService.loadUserByUsername(consumedToken.getData());
		asAdmin(() -> {
			webUserService.setAccountActive(webUser.getId(), true);
			return true;
		});

	}

	/**
	 * User registration has been canceled. Remove user data if user not yet validated.
	 */
	@Override
	@Transactional
	public void cancelValidation(String tokenUuid) throws Exception {
		try {
			VerificationToken verificationToken = tokenVerificationService.fetchToken("email-verification", tokenUuid);

			final WebUser webUser = (WebUser) webUserService.loadUserByUsername(verificationToken.getData());

			if (webUser.isEnabled()) {
				throw new InvalidApiUsageException("User already validated");
			}

			asAdmin(() -> {
				webUserService.remove(webUser);
				return true;
			});

			tokenVerificationService.cancel(tokenUuid);
		} catch (final NoSuchVerificationTokenException e) {
			log.warn("No such token. Error message {}", e.getMessage());
			throw new InvalidApiUsageException("No such verification token", e);
		}
	}

	@Override
	public void sendPasswordResetEmail(String email, String username, String origin) {
		// Generate new token
		final VerificationToken verificationToken = tokenVerificationService.generateToken("password-reset", username);

		final Map<String, Object> scopes = new HashMap<>();
		scopes.put("url", origin);
		scopes.put("tokenUUID", verificationToken.getUuid());
		scopes.put("tokenKey", verificationToken.getKey());
		scopes.put("username", username);

		final AppResource resource = appResourceService.getResource(AppResourceService.APP_NAME_GGCE, "email/reset-password.mustache", Locale.ENGLISH);
		String messageTemplate;
		String messageTitle;
		if (resource != null) {
			messageTitle = resource.getValueMember();
			messageTemplate = resource.getDisplayMember();
		} else {
			log.info("AppResource 'email/reset-password.mustache' not found, using bundled template");
			try {
				messageTitle = "Reset password";
				messageTemplate = Files.readString(Path.of(getClass().getResource("/email/reset-password.mustache").getPath()));
			} catch (IOException e) {
				log.warn("Failed to load message template, not sending email.", e);
				return;
			}
		}
		final String mailBody = templatingService.fillTemplate(messageTemplate, scopes);
		emailService.sendMail(messageTitle, mailBody, email);
	}

	@Override
	@Transactional(rollbackFor = Throwable.class)
	public void changeWebUserPassword(final String tokenUuid, final String key, final String password, final String origin) throws NoSuchVerificationTokenException, TokenExpiredException {
		final VerificationToken consumedToken = tokenVerificationService.consumeToken("password-reset", tokenUuid, key);
		final WebUser user = (WebUser) webUserService.loadUserByUsername(consumedToken.getData());

		Authentication prevAuth = SecurityContextHolder.getContext().getAuthentication();
		try {
			log.warn("Setting temporary authorization for password reset for {}", user.getUsername());
			final UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
			SecurityContextHolder.getContext().setAuthentication(authToken);
			webUserService.setPassword(user.getId(), password);
			sendResetConfirmationEmail(user.getUsername(), user.getWebCooperator().getEmail(), origin);
		} finally {
			log.warn("Restoring authorization away from {}", user.getUsername());
			SecurityContextHolder.getContext().setAuthentication(prevAuth);
		}
	}

	@Override
	@Transactional(rollbackFor = Throwable.class)
	public void changeSysUserPassword(final String tokenUuid, final String key, final String password, final String origin) throws NoSuchVerificationTokenException, TokenExpiredException, UserException {
		final VerificationToken consumedToken = tokenVerificationService.consumeToken("password-reset", tokenUuid, key);
		final SysUser user =  userService.loadUserByUsername(consumedToken.getData());

		Authentication prevAuth = SecurityContextHolder.getContext().getAuthentication();
		try {
			log.warn("Setting temporary authorization for password reset for {}", user.getUsername());
			final UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
			SecurityContextHolder.getContext().setAuthentication(authToken);
			userService.setPassword(user, password);
			sendResetConfirmationEmail(user.getUsername(), user.getCooperator().getEmail(), origin);
		} finally {
			log.warn("Restoring authorization away from {}", user.getUsername());
			SecurityContextHolder.getContext().setAuthentication(prevAuth);
		}
	}

	@Override
	@Transactional
	public void cancelPasswordReset(String tokenUuid) {
		try {
			tokenVerificationService.cancel(tokenUuid);
		} catch (final NoSuchVerificationTokenException e) {
			log.warn("No such token. Error message {}", e.getMessage());
			throw new InvalidApiUsageException("No such verification token", e);
		}
	}

	private void sendResetConfirmationEmail(String username, String email, String url) {

		Date nowDate = new Date();
		String resetDate = new SimpleDateFormat("MMM dd, yyyy 'at' HH:mm z").format(nowDate);

		final Map<String, Object> scopes = new HashMap<>();
		scopes.put("username", username);
		scopes.put("resetDate", resetDate);
		scopes.put("url", url);

		final AppResource resource = appResourceService.getResource(AppResourceService.APP_NAME_GGCE, "email/reset-password-confirm.mustache", Locale.ENGLISH);
		String messageTemplate;
		String messageTitle;

		if (resource != null) {
			messageTitle = resource.getValueMember();
			messageTemplate = resource.getDisplayMember();
		} else {
			log.info("AppResource 'email/reset-password-confirm.mustache' not found, using bundled template");
			try {
				messageTitle = "Reset password confirmation";
				messageTemplate = Files.readString(Path.of(getClass().getResource("/email/reset-password-confirm.mustache").getPath()));
			} catch (IOException e) {
				log.warn("Failed to load message template, not sending email.", e);
				return;
			}
		}
		final String mailBody = templatingService.fillTemplate(messageTemplate, scopes);
		emailService.sendMail(messageTitle, mailBody, email);
	}

	private <T> T asAdmin(Callable<T> callable) throws Exception {
		UserDetails administrator = userService.loadUserByUsername("administrator");
		List<GrantedAuthority> authorities = Lists.newArrayList(new SimpleGrantedAuthority("ROLE_ADMINISTRATOR"));
		authorities.addAll(administrator.getAuthorities());
		Authentication authentication = new UsernamePasswordAuthenticationToken(administrator, null, authorities);
		return TransactionHelper.asUser(authentication, callable);
	}
}