AccountService.java
package com.albertsilva.dev.asjcatalog.service;
import java.util.Optional;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
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.Role;
import com.albertsilva.dev.asjcatalog.domain.user.User;
import com.albertsilva.dev.asjcatalog.dto.user.request.AuthenticatedUserUpdateRequest;
import com.albertsilva.dev.asjcatalog.dto.user.request.PasswordUpdateRequest;
import com.albertsilva.dev.asjcatalog.dto.user.request.UserRegisterRequest;
import com.albertsilva.dev.asjcatalog.dto.user.response.UserResponse;
import com.albertsilva.dev.asjcatalog.mapper.user.UserMapper;
import com.albertsilva.dev.asjcatalog.repository.RoleRepository;
import com.albertsilva.dev.asjcatalog.repository.UserRepository;
import com.albertsilva.dev.asjcatalog.security.auth.AuthenticatedUserService;
import com.albertsilva.dev.asjcatalog.service.exception.InvalidTokenException;
import com.albertsilva.dev.asjcatalog.service.exception.PasswordUpdateException;
import com.albertsilva.dev.asjcatalog.service.exception.ResourceNotFoundException;
import jakarta.mail.MessagingException;
/**
* Serviço de aplicação dos fluxos de <b>conta do próprio usuário</b>, chamados
* por {@code AccountController}. Os casos de uso administrativos sobre
* usuários ficam em {@code UserService}.
*
* <p>
* <b>Fluxos por e-mail (sem usuário autenticado):</b>
* {@link #register(UserRegisterRequest)},
* {@link #confirmEmail(String)}, {@link #resendActivationEmail(String)},
* {@link #requestPasswordRecovery(String)} e
* {@link #resetPassword(String, String)}. Coordenam {@code UserRepository},
* {@code TokenService} (tokens de ativação/recuperação) e {@code EmailService}
* (envio do e-mail).
* </p>
*
* <p>
* <b>Fluxos do usuário autenticado:</b>
* {@link #updateAuthenticatedUser(AuthenticatedUserUpdateRequest)},
* {@link #updatePassword(PasswordUpdateRequest)} e
* {@link #getAuthenticatedUser()}. A identidade vem de
* {@code AuthenticatedUserService}, que lê o claim {@code userId} do JWT
* presente no {@code SecurityContext}; nenhum desses métodos recebe o id do
* usuário como parâmetro.
* </p>
*
* <p>
* <b>Não implementado:</b> {@link #deactivateAccount()} apenas lança
* {@link UnsupportedOperationException}.
* </p>
*
* <p>
* <b>Transações:</b> todos os métodos, exceto {@code deactivateAccount}, são
* transacionais (somente {@code getAuthenticatedUser} é {@code readOnly}).
* {@code TokenService} participa da mesma transação. Em {@code confirmEmail}
* e {@code updatePassword}, as alterações em entidades gerenciadas são gravadas
* no commit, sem {@code save} explícito.
* </p>
*
* <p>
* <b>Autorização:</b> não é feita aqui (controllers e configuração de
* segurança). <b>E-mails:</b> o retorno dos métodos {@code ...Async} de
* {@code EmailService} ({@code CompletableFuture}) é ignorado, então falhas de
* envio não chegam a este service.
* </p>
*/
@Service
public class AccountService {
private static final Logger logger = LoggerFactory.getLogger(AccountService.class);
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final TokenService tokenService;
private final EmailService emailService;
private final AuthenticatedUserService authenticatedUserService;
/**
* Construtor para injeção de dependências.
*
* @param userRepository repositório de usuários
* @param roleRepository repositório de roles
* @param userMapper responsável pela conversão entre DTOs e entidades
* @param passwordEncoder responsável pela codificação de senhas
* @param tokenService serviço de gerenciamento de tokens
* @param emailService serviço de envio de e-mails
*/
public AccountService(UserRepository userRepository, RoleRepository roleRepository, UserMapper userMapper,
PasswordEncoder passwordEncoder, TokenService tokenService, EmailService emailService,
AuthenticatedUserService authenticatedUserService) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder;
this.tokenService = tokenService;
this.emailService = emailService;
this.authenticatedUserService = authenticatedUserService;
}
/**
* Registra uma conta de usuário comum, inativa até a confirmação do e-mail.
*
* <p>
* Sequência:
* </p>
* <ol>
* <li>busca a role fixa {@code ROLE_OPERATOR};</li>
* <li>o mapper cria o {@link User} a partir do request, apenas com essa role
* (nome e e-mail como recebidos, sem normalização);</li>
* <li>codifica a senha e chama {@code deactivate()};</li>
* <li>salva o usuário;</li>
* <li>cria um token de tipo {@link TokenType#ACTIVATION} por
* {@code TokenService};</li>
* <li>chama {@code EmailService.sendActivationEmailAsync} (nome, e-mail e
* valor do token) e descarta o {@code CompletableFuture} retornado;</li>
* <li>devolve o {@link UserResponse}.</li>
* </ol>
*
* <p>
* <b>Falhas no envio do e-mail:</b> são capturadas por {@code EmailService}
* e devolvidas apenas no futuro descartado; não interrompem o registro nem
* revertem a transação. Apesar de {@code throws MessagingException} na
* assinatura, nada no corpo do método lança essa exceção verificada (o método
* de envio chamado não a declara). Como não há {@code @EnableAsync} no
* projeto, o envio provavelmente ocorre na própria thread e dentro da
* transação, antes do commit.
* </p>
*
* <p>
* Validações fora deste método: unicidade e formato do e-mail (inclui
* consulta DNS), força da senha e dados pessoais na senha, feitas nos
* validators do DTO.
* </p>
*
* @param request dados do registro
* @return dados do usuário criado (inativo)
* @throws MessagingException declarada na assinatura, mas não lançada pela
* implementação atual
* @throws IllegalStateException se a role {@code ROLE_OPERATOR} não existir
*/
@Transactional
public UserResponse register(UserRegisterRequest request) throws MessagingException {
logger.debug("Registrando novo usuário. email: {}", request.email());
Role operator = roleRepository.findByAuthority("ROLE_OPERATOR")
.orElseThrow(() -> new IllegalStateException("Role 'ROLE_OPERATOR' não encontrada no banco de dados"));
User user = userMapper.toEntity(request, Set.of(operator));
user.setPassword(passwordEncoder.encode(request.password()));
user.deactivate();
user = userRepository.save(user);
Token activationToken = tokenService.createActivationToken(user);
emailService.sendActivationEmailAsync(user.getFirstName(), user.getEmail(), activationToken.getToken());
logger.info("Usuário registrado com sucesso. id: {}", user.getId());
return userMapper.toResponse(user);
}
/**
* Ativa a conta associada a um token de ativação.
*
* <p>
* Sequência: {@code TokenService.findAndValidateToken(valor, ACTIVATION)};
* obtém o usuário do token (carregamento tardio, dentro da transação);
* {@code user.activate()}; {@code token.disable()}. Não há {@code save}
* explícito: as alterações são gravadas no commit. O método não verifica se a
* conta já estava ativa.
* </p>
*
* @param tokenValue valor do token recebido pelo usuário
* @throws ResourceNotFoundException se não existir token com esse valor
* ({@code error.token.notFound})
* @throws InvalidTokenException se o token não for de ativação, estiver
* desabilitado ou tiver expirado (lançada
* por {@code Token.validate})
*/
@Transactional
public void confirmEmail(String tokenValue) {
Token token = tokenService.findAndValidateToken(tokenValue, TokenType.ACTIVATION);
User user = token.getUser();
user.activate();
token.disable();
}
/**
* Inicia a recuperação de senha sem revelar se o e-mail existe.
*
* <p>
* Busca o usuário por {@code findByEmail} (comparação exata). Se existir,
* desabilita os tokens de recuperação anteriores
* ({@code TokenService.disableAllPasswordRecoveryTokens}), cria um novo token
* de tipo {@link TokenType#PASSWORD_RECOVERY} e chama
* {@code EmailService.sendPasswordRecoveryEmailAsync} (retorno descartado). Se
* não existir, <b>nada acontece</b> e nenhuma exceção é lançada, de modo que o
* chamador observa o mesmo resultado nos dois casos.
* </p>
*
* <p>
* Como os tokens anteriores são desabilitados, só o link da solicitação mais
* recente é aceito. O método não verifica se a conta está ativa.
* </p>
*
* @param email e-mail da conta, comparado exatamente como gravado
*/
@Transactional
public void requestPasswordRecovery(String email) {
userRepository.findByEmail(email).ifPresent(user -> {
tokenService.disableAllPasswordRecoveryTokens(user);
Token token = tokenService.createPasswordRecoveryToken(user);
emailService.sendPasswordRecoveryEmailAsync(user, token.getToken());
});
}
/**
* Reenvia o e-mail de ativação para uma conta ainda inativa.
*
* <p>
* Busca o usuário por {@code findByEmail} (comparação exata). Se não
* existir, ou se já estiver ativo, retorna sem fazer nada e sem exceção.
* Caso contrário: desabilita os tokens de ativação ainda não desabilitados
* do usuário (inclusive vencidos), cria um novo token de ativação e chama
* {@code EmailService.sendActivationEmailAsync} (retorno descartado).
* </p>
*
* @param email e-mail da conta, comparado exatamente como gravado
* @throws MessagingException declarada na assinatura, mas não lançada pela
* implementação atual
*/
@Transactional
public void resendActivationEmail(String email) throws MessagingException {
Optional<User> entity = userRepository.findByEmail(email);
if (entity.isEmpty()) {
return;
}
User user = entity.get();
if (user.isActive()) {
return;
}
tokenService.disableAllActivationTokens(user);
Token token = tokenService.createActivationToken(user);
emailService.sendActivationEmailAsync(user.getFirstName(), user.getEmail(), token.getToken());
}
/**
* Redefine a senha do usuário a partir de um token de recuperação.
*
* <p>
* Sequência: {@code TokenService.findAndValidateToken(valor,
* PASSWORD_RECOVERY)}; obtém o usuário do token; grava a senha codificada;
* desabilita o token; {@code userRepository.save(user)}.
* </p>
*
* <p>
* O método não ativa a conta, não desabilita outros tokens de recuperação do
* mesmo usuário e não interage com tokens OAuth2 já emitidos. A força da
* senha é validada no DTO; não há comparação com a senha atual.
* </p>
*
* @param tokenValue valor do token recebido pelo usuário
* @param password nova senha (em texto)
* @throws ResourceNotFoundException se não existir token com esse valor
* @throws InvalidTokenException se o token não for de recuperação de senha,
* estiver desabilitado ou tiver expirado
*/
@Transactional
public void resetPassword(String tokenValue, String password) {
Token token = tokenService.findAndValidateToken(tokenValue, TokenType.PASSWORD_RECOVERY);
User user = token.getUser();
user.setPassword(passwordEncoder.encode(password));
token.disable();
userRepository.save(user);
}
/**
* Atualiza nome, sobrenome e e-mail do usuário autenticado.
*
* <p>
* <b>Identidade:</b> vem de {@code AuthenticatedUserService
* .getAuthenticatedUser()}, que lê o claim {@code userId} do JWT no
* {@code SecurityContext} e carrega o usuário por id. O método não recebe um
* id, então só atua sobre o usuário do token.
* </p>
*
* <p>
* Aplica {@code trim} ao nome e ao sobrenome e {@code trim} + minúsculas ao
* e-mail (é o único fluxo do sistema em que o e-mail é normalizado ao gravar),
* salva e devolve o {@link UserResponse}. A unicidade do novo e-mail é
* validada antes, pelo DTO ({@code @UniqueEmailForAuthenticatedUser}). Não há
* confirmação do novo e-mail, o indicador {@code active} não muda e nenhum
* token é reemitido; portanto o claim {@code username} de um JWT já emitido
* continua com o e-mail antigo.
* </p>
*
* @param request novos dados do perfil
* @return dados atualizados do usuário
* @throws com.albertsilva.dev.asjcatalog.service.exception.AuthenticatedUserNotFoundException
* se não houver JWT válido, claim {@code userId} ou usuário
* correspondente
*/
@Transactional
public UserResponse updateAuthenticatedUser(AuthenticatedUserUpdateRequest request) {
User user = authenticatedUserService.getAuthenticatedUser();
user.setFirstName(request.firstName().trim());
user.setLastName(request.lastName().trim());
user.setEmail(request.email().trim().toLowerCase());
user = userRepository.save(user);
return userMapper.toResponse(user);
}
/**
* Altera a senha do usuário autenticado.
*
* <p>
* Passos, nesta ordem (as verificações de senha lançam
* {@link PasswordUpdateException}):
* </p>
* <ol>
* <li>{@code newPassword} igual a {@code confirmPassword}
* ({@code error.account.password.confirmationMismatch});</li>
* <li>usuário autenticado carregado por {@code AuthenticatedUserService};</li>
* <li>{@code currentPassword} confere com o hash atual
* ({@code error.account.password.currentInvalid});</li>
* <li>a nova senha é diferente da atual
* ({@code error.account.password.sameAsCurrent}).</li>
* </ol>
*
* <p>
* Em seguida grava a senha codificada, sem {@code save} explícito (a alteração
* da entidade gerenciada é gravada no commit). Tamanho e força da senha são
* validados antes, no DTO. Tokens OAuth2 já emitidos não são afetados por este
* método.
* </p>
*
* @param request senha atual, nova senha e confirmação
* @throws PasswordUpdateException se alguma verificação falhar
* @throws com.albertsilva.dev.asjcatalog.service.exception.AuthenticatedUserNotFoundException
* se não houver JWT válido, claim {@code userId} ou usuário
* correspondente
*/
@Transactional
public void updatePassword(PasswordUpdateRequest request) {
validatePasswordConfirmation(request);
User user = authenticatedUserService.getAuthenticatedUser();
validateCurrentPassword(request.currentPassword(), user);
validateNewPassword(request.newPassword(), user);
user.setPassword(passwordEncoder.encode(request.newPassword()));
}
/**
* Devolve os dados do usuário autenticado (o do JWT da requisição), incluindo
* suas roles. As roles são carregadas sob demanda durante a conversão, dentro
* da transação de leitura.
*
* @return dados do usuário autenticado
* @throws com.albertsilva.dev.asjcatalog.service.exception.AuthenticatedUserNotFoundException
* se não houver JWT válido, claim {@code userId} ou usuário
* correspondente
*/
@Transactional(readOnly = true)
public UserResponse getAuthenticatedUser() {
User user = authenticatedUserService.getAuthenticatedUser();
return userMapper.toResponse(user);
}
/**
* <b>Não implementado.</b> Sempre lança {@link UnsupportedOperationException}
* (mensagem "Unimplemented method 'deactivateAccount'"), sem transação e sem
* efeito sobre os dados. É chamado por {@code AccountController}, que publica o
* endpoint {@code POST /api/v1/accounts/deactivate}.
*
* @throws UnsupportedOperationException sempre
*/
public void deactivateAccount() {
throw new UnsupportedOperationException("Unimplemented method 'deactivateAccount'");
}
/**
* Exige que {@code newPassword} e {@code confirmPassword} sejam iguais.
*
* @throws PasswordUpdateException ({@code error.account.password.confirmationMismatch})
*/
private void validatePasswordConfirmation(PasswordUpdateRequest request) {
if (!request.newPassword().equals(request.confirmPassword())) {
throw new PasswordUpdateException("error.account.password.confirmationMismatch");
}
}
/**
* Exige que a senha informada confira, via {@code PasswordEncoder.matches},
* com o hash guardado no usuário.
*
* @throws PasswordUpdateException ({@code error.account.password.currentInvalid})
*/
private void validateCurrentPassword(String currentPassword, User user) {
if (!passwordEncoder.matches(currentPassword, user.getPassword())) {
throw new PasswordUpdateException("error.account.password.currentInvalid");
}
}
/**
* Exige que a nova senha seja diferente da atual (comparada com o hash por
* {@code PasswordEncoder.matches}).
*
* @throws PasswordUpdateException ({@code error.account.password.sameAsCurrent})
*/
private void validateNewPassword(String newPassword, User user) {
if (passwordEncoder.matches(newPassword, user.getPassword())) {
throw new PasswordUpdateException("error.account.password.sameAsCurrent");
}
}
}