PasswordPersonalDataValidator.java

package com.albertsilva.dev.asjcatalog.validation.user.validator;

import java.util.ArrayList;
import java.util.List;

import com.albertsilva.dev.asjcatalog.validation.user.annotation.PasswordPersonalData;
import com.albertsilva.dev.asjcatalog.validation.user.contract.PasswordPersonalDataCandidate;
import com.albertsilva.dev.asjcatalog.web.exception.response.FieldMessage;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

/**
 * Validator de {@link PasswordPersonalData}: impede que a senha <em>contenha</em>
 * dados pessoais do próprio candidato a usuário.
 *
 * <p>
 * <b>Comportamento:</b> candidato {@code null} ou senha {@code null} ⇒
 * <b>válido</b>. A senha é normalizada ({@code trim()} + {@code toLowerCase()})
 * e comparada, por <b>substring</b> ({@code contains}), com três valores, também
 * normalizados: primeiro nome, sobrenome e a parte do e-mail antes do
 * <b>primeiro</b> {@code @} (só se o e-mail contiver {@code @}). Valores
 * {@code null} ou com menos de 3 caracteres são
 * ignorados. Nomes compostos (com espaço) são comparados inteiros e, como a senha
 * forte não aceita espaços, tendem a nunca casar.
 * </p>
 *
 * <p>
 * <b>Violação:</b> no máximo <b>uma</b>, {@code {user.password.personalData}},
 * associada ao campo {@code password} (mesmo que mais de um dado case). Não
 * consulta banco, HTTP nem autenticação. Usa {@code FieldMessage}, do pacote de
 * exceções da camada web, como estrutura de acúmulo.
 * </p>
 */
public class PasswordPersonalDataValidator
        implements ConstraintValidator<PasswordPersonalData, PasswordPersonalDataCandidate> {

    /**
     * Quantidade mínima de caracteres que um token deve possuir
     * para ser considerado durante a validação.
     */
    private static final int MIN_TOKEN_LENGTH = 3;

    /**
     * Verifica se a senha contém nome, sobrenome ou prefixo do e-mail do candidato.
     *
     * @param candidate dados do usuário (pode ser {@code null})
     * @param context   contexto usado para registrar a violação no campo
     *                  {@code password}
     * @return {@code true} se nenhum dado pessoal for encontrado na senha
     */
    @Override
    public boolean isValid(PasswordPersonalDataCandidate candidate, ConstraintValidatorContext context) {

        if (candidate == null) {
            return true;
        }

        List<FieldMessage> errors = new ArrayList<>();

        validatePasswordDoesNotContainPersonalData(candidate, errors);

        addErrors(errors, context);

        return errors.isEmpty();
    }

    /**
     * Valida se a senha não contém informações pessoais do usuário.
     *
     * @param candidate dados do usuário utilizados na validação
     * @param errors    lista de erros encontrados
     */
    private void validatePasswordDoesNotContainPersonalData(PasswordPersonalDataCandidate candidate,
            List<FieldMessage> errors) {

        if (candidate.password() == null) {
            return;
        }

        String normalizedPassword = candidate.password().trim().toLowerCase();

        validateToken(normalizedPassword, candidate.firstName(), errors);
        validateToken(normalizedPassword, candidate.lastName(), errors);

        if (candidate.email() != null && candidate.email().contains("@")) {

            String emailPrefix = candidate.email().split("@")[0];

            validateToken(normalizedPassword, emailPrefix, errors);
        }
    }

    /**
     * Verifica se um determinado dado pessoal está presente na senha.
     *
     * <p>
     * Apenas tokens com tamanho mínimo de
     * {@value #MIN_TOKEN_LENGTH} caracteres são considerados,
     * reduzindo falsos positivos durante a validação.
     * </p>
     *
     * @param password senha normalizada
     * @param value    dado pessoal a ser verificado
     * @param errors   lista de erros encontrados
     */
    private void validateToken(String password, String value, List<FieldMessage> errors) {

        if (value == null) {
            return;
        }

        String normalized = value.trim().toLowerCase();

        if (normalized.length() < MIN_TOKEN_LENGTH) {
            return;
        }

        boolean alreadyExists = errors.stream().anyMatch(error -> error.fieldName().equals("password")
                && error.message().equals("{user.password.personalData}"));

        if (password.contains(normalized) && !alreadyExists) {

            errors.add(new FieldMessage("password", "{user.password.personalData}"));
        }
    }

    /**
     * Registra no contexto do Bean Validation todas as violações
     * encontradas durante a validação.
     *
     * <p>
     * Cada violação é associada ao atributo {@code password},
     * permitindo sua exibição correta no frontend e a resolução
     * automática da mensagem pelo {@code MessageSource}.
     * </p>
     *
     * @param errors  lista de erros encontrados
     * @param context contexto do Bean Validation
     */
    private void addErrors(List<FieldMessage> errors, ConstraintValidatorContext context) {

        if (errors.isEmpty()) {
            return;
        }

        context.disableDefaultConstraintViolation();

        for (FieldMessage error : errors) {

            context.buildConstraintViolationWithTemplate(error.message()).addPropertyNode(error.fieldName())
                    .addConstraintViolation();
        }
    }
}