TokenService.java

package com.albertsilva.dev.asjcatalog.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.albertsilva.dev.asjcatalog.domain.recovery.Token;
import com.albertsilva.dev.asjcatalog.domain.recovery.enums.TokenType;
import com.albertsilva.dev.asjcatalog.domain.user.User;
import com.albertsilva.dev.asjcatalog.repository.TokenRepository;
import com.albertsilva.dev.asjcatalog.service.exception.ResourceNotFoundException;

/**
 * Serviço que cria, valida e desabilita os {@link Token} usados nos fluxos de
 * ativação de conta e de recuperação de senha. É chamado apenas por
 * {@code AccountService}; usa somente {@code TokenRepository}.
 *
 * <p>
 * <b>Transações:</b> a anotação {@code @Transactional} está na classe, então
 * todos os métodos públicos são transacionais (escrita) e, quando chamados por
 * {@code AccountService}, participam da transação dele. Os métodos que
 * desabilitam tokens não chamam {@code save}: dependem da entidade
 * gerenciada ser gravada no commit.
 * </p>
 *
 * <p>
 * <b>Regras de validade:</b> a decisão de aceitar um token é do domínio
 * ({@code Token.validate(TokenType)}: tipo, desabilitado, expirado), e este
 * service apenas a aciona em {@link #findAndValidateToken(String, TokenType)}.
 * {@code Token.isValid()} não é usado por nenhum código de produção.
 * </p>
 *
 * <p>
 * <b>Configuração:</b> validade dos tokens lida das propriedades
 * {@code account.activation.token.hours} (ativação) e
 * {@code account.password-recovery.token.minutes} (recuperação).
 * </p>
 */
@Service
@Transactional
public class TokenService {

  @Value("${account.activation.token.hours}")
  private Long activationTokenExpirationHours;

  @Value("${account.password-recovery.token.minutes}")
  private Long passwordRecoveryTokenExpirationMinutes;

  private final TokenRepository tokenRepository;

  public TokenService(TokenRepository tokenRepository) {
    this.tokenRepository = tokenRepository;
  }

  /**
   * Cria e persiste um token de ativação para o usuário, com UUID aleatório e
   * validade de {@code account.activation.token.hours} horas
   * ({@code Token.activationToken}). Não desabilita tokens anteriores.
   *
   * @param user usuário dono do token (deve estar persistido)
   * @return token salvo
   */
  public Token createActivationToken(User user) {
    return tokenRepository.save(Token.activationToken(user, activationTokenExpirationHours));
  }

  /**
   * Cria e persiste um token de recuperação de senha para o usuário, com UUID
   * aleatório e validade de {@code account.password-recovery.token.minutes}
   * minutos ({@code Token.passwordRecoveryToken}). Não desabilita tokens
   * anteriores; quem faz isso é o chamador
   * ({@code AccountService.requestPasswordRecovery}), via
   * {@link #disableAllPasswordRecoveryTokens(User)}.
   *
   * @param user usuário dono do token (deve estar persistido)
   * @return token salvo
   */
  public Token createPasswordRecoveryToken(User user) {
    return tokenRepository.save(Token.passwordRecoveryToken(user, passwordRecoveryTokenExpirationMinutes));
  }

  /**
   * Desabilita todos os tokens de ativação do usuário que ainda não estão
   * desabilitados. A consulta não considera a expiração, então tokens vencidos
   * também são desabilitados. Chamado por
   * {@code AccountService.resendActivationEmail}.
   *
   * @param user usuário dono dos tokens
   */
  public void disableAllActivationTokens(User user) {

    List<Token> tokens = tokenRepository.findByUserAndTypeAndDisabledFalse(user, TokenType.ACTIVATION);

    tokens.forEach(Token::disable);
  }

  /**
   * Desabilita todos os tokens de recuperação de senha do usuário que ainda não
   * estão desabilitados (inclusive os vencidos). Chamado por
   * {@code AccountService.requestPasswordRecovery} antes de criar um novo token,
   * para que só o link mais recente seja aceito.
   *
   * @param user usuário dono dos tokens
   */
  public void disableAllPasswordRecoveryTokens(User user) {

    List<Token> tokens = tokenRepository.findByUserAndTypeAndDisabledFalse(user, TokenType.PASSWORD_RECOVERY);

    tokens.forEach(Token::disable);
  }

  /**
   * Busca um token pelo valor e o valida para a finalidade informada.
   *
   * <p>
   * Sequência: {@code TokenRepository.findByToken}; se não existir, lança
   * {@link ResourceNotFoundException}; depois chama
   * {@code Token.validate(type)}, que recusa tipo diferente, token
   * desabilitado ou expirado. O método <b>não</b> altera o token: quem o consome
   * deve desabilitá-lo em seguida (é o que {@code AccountService} faz).
   * </p>
   *
   * @param tokenValue valor do token recebido do usuário
   * @param type       finalidade esperada
   * @return token válido (o usuário dono tem carregamento tardio, e só pode ser
   *         lido enquanto a transação estiver aberta)
   * @throws ResourceNotFoundException ({@code error.token.notFound}) se não
   *                                   existir token com esse valor
   * @throws com.albertsilva.dev.asjcatalog.service.exception.InvalidTokenException
   *         se o tipo for diferente, o token estiver desabilitado ou tiver
   *         expirado
   */
  public Token findAndValidateToken(String tokenValue, TokenType type) {

    Token token = tokenRepository.findByToken(tokenValue)
        .orElseThrow(() -> new ResourceNotFoundException("error.token.notFound"));
    token.validate(type);
    return token;
  }
}