StrongPasswordValidator.java

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

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

import com.albertsilva.dev.asjcatalog.validation.user.annotation.StrongPassword;

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

/**
 * Validator de {@link StrongPassword}: aplica regras de composição à senha.
 *
 * <p>
 * <b>Comportamento:</b> {@code null} ⇒ <b>válido</b>. Qualquer outro valor,
 * <b>inclusive {@code ""} e texto só com espaços</b>, é avaliado. Todas as
 * regras são verificadas (não há interrupção na primeira falha) e <b>cada
 * regra violada gera uma violação própria</b>, com chave específica:
 * </p>
 * <ul>
 * <li>{@code user.password.whitespace}: contém algum espaço em branco
 * ({@code Character.isWhitespace});</li>
 * <li>{@code user.password.uppercase}: nenhuma letra A-Z;</li>
 * <li>{@code user.password.lowercase}: nenhuma letra a-z;</li>
 * <li>{@code user.password.number}: nenhum dígito 0-9;</li>
 * <li>{@code user.password.specialCharacter}: nenhum caractere fora de
 * A-Z, a-z, 0-9 e espaço — <b>letras acentuadas contam como "especial"</b>;</li>
 * <li>{@code user.password.common}: a senha (após {@code trim} e minúsculas) é
 * exatamente uma das 9 senhas comuns da lista;</li>
 * <li>{@code user.password.sequence}: os dígitos da senha, tomados em
 * conjunto, contêm 6 ou mais dígitos consecutivos crescentes ou decrescentes
 * (ver {@code validateNumericSequences}).</li>
 * </ul>
 *
 * <p>
 * <b>Não verifica tamanho</b> (mínimo/máximo ficam a cargo de {@code @Size} nos
 * DTOs, quando existe) <b>nem dados pessoais</b> (ver
 * {@link PasswordPersonalDataValidator}). Todas as verificações de letras e
 * dígitos são ASCII. Não há dependências, banco, HTTP ou rede.
 * </p>
 */
public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {

    private static final Pattern UPPERCASE_PATTERN = Pattern.compile(".*[A-Z].*");

    private static final Pattern LOWERCASE_PATTERN = Pattern.compile(".*[a-z].*");

    private static final Pattern NUMBER_PATTERN = Pattern.compile(".*\\d.*");

    private static final Pattern SPECIAL_CHARACTER_PATTERN = Pattern.compile(".*[^a-zA-Z0-9\\s].*");

    /**
     * Senhas amplamente conhecidas por serem inseguras.
     */
    private static final Set<String> COMMON_PASSWORDS = Set.of("123456", "1234567", "12345678", "password", "admin",
            "qwerty", "abc123", "111111", "123123");

    /**
     * Comprimento mínimo de uma sequência numérica considerada insegura.
     */
    private static final int MIN_NUMERIC_SEQUENCE_LENGTH = 6;

    /**
     * Avalia todas as regras da senha e registra uma violação por regra violada.
     *
     * @param value   senha informada (pode ser {@code null})
     * @param context contexto usado para registrar as violações
     * @return {@code true} se for {@code null} ou não violar nenhuma regra
     */
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {

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

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

        validateWhitespace(value, errors);
        validateUppercase(value, errors);
        validateLowercase(value, errors);
        validateNumber(value, errors);
        validateSpecialCharacter(value, errors);
        validateCommonPasswords(value, errors);
        validateNumericSequences(value, errors);

        addErrors(errors, context);

        return errors.isEmpty();
    }

    /**
     * Registra {@code user.password.whitespace} se a senha contiver qualquer
     * caractere para o qual {@code Character.isWhitespace} seja verdadeiro.
     */
    private void validateWhitespace(String password, List<String> errors) {

        boolean containsWhitespace = password.chars().anyMatch(Character::isWhitespace);

        if (containsWhitespace) {
            errors.add("{user.password.whitespace}");
        }
    }

    /** Registra {@code user.password.uppercase} se não houver letra A-Z. */
    private void validateUppercase(String password, List<String> errors) {

        if (!UPPERCASE_PATTERN.matcher(password).matches()) {
            errors.add("{user.password.uppercase}");
        }
    }

    /** Registra {@code user.password.lowercase} se não houver letra a-z. */
    private void validateLowercase(String password, List<String> errors) {

        if (!LOWERCASE_PATTERN.matcher(password).matches()) {
            errors.add("{user.password.lowercase}");
        }
    }

    /** Registra {@code user.password.number} se não houver dígito 0-9. */
    private void validateNumber(String password, List<String> errors) {

        if (!NUMBER_PATTERN.matcher(password).matches()) {
            errors.add("{user.password.number}");
        }
    }

    /**
     * Registra {@code user.password.specialCharacter} se não houver nenhum caractere
     * fora de A-Z, a-z, 0-9 e espaço (letras acentuadas satisfazem esta regra).
     */
    private void validateSpecialCharacter(String password, List<String> errors) {

        if (!SPECIAL_CHARACTER_PATTERN.matcher(password).matches()) {
            errors.add("{user.password.specialCharacter}");
        }
    }

    /**
     * Registra {@code user.password.common} se a senha, após {@code trim()} e
     * {@code toLowerCase()}, for <b>exatamente igual</b> a uma das senhas da lista
     * interna (comparação de igualdade, não de conteúdo). Como todas as entradas
     * da lista são só dígitos ou só letras minúsculas, uma senha assim já viola
     * também as regras de maiúscula e de caractere especial.
     *
     * @param password senha informada
     * @param errors   lista que acumula as chaves de mensagem das violações
     */
    private void validateCommonPasswords(String password, List<String> errors) {

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

        if (COMMON_PASSWORDS.contains(normalizedPassword)) {
            errors.add("{user.password.common}");
        }
    }

    /**
     * Detecta sequências numéricas previsíveis.
     *
     * <p>
     * Extrai <b>todos os dígitos da senha, concatenados</b> (descartando os demais
     * caracteres) e procura, nessa cadeia, {@value #MIN_NUMERIC_SEQUENCE_LENGTH}
     * ou mais dígitos consecutivos em que cada um é exatamente o anterior mais 1
     * (por exemplo, {@code 123456}) ou menos 1 (por exemplo, {@code 654321}). Como
     * os dígitos são concatenados, dígitos <em>separados</em> por outros
     * caracteres na senha também formam sequência (por exemplo, {@code a1b2c3d4e5f6}).
     * Se a cadeia tiver menos de 6 dígitos, nada é verificado. No máximo uma
     * violação {@code user.password.sequence} é registrada.
     * </p>
     *
     * @param password senha informada
     * @param errors   lista que acumula as chaves de mensagem das violações
     */
    private void validateNumericSequences(String password, List<String> errors) {

        String digits = password.replaceAll("\\D", "");

        if (digits.length() < MIN_NUMERIC_SEQUENCE_LENGTH) {
            return;
        }

        int ascending = 1;
        int descending = 1;

        for (int i = 1; i < digits.length(); i++) {

            int previous = digits.charAt(i - 1);
            int current = digits.charAt(i);

            if (current == previous + 1) {
                ascending++;
            } else {
                ascending = 1;
            }

            if (current == previous - 1) {
                descending++;
            } else {
                descending = 1;
            }

            if (ascending >= MIN_NUMERIC_SEQUENCE_LENGTH || descending >= MIN_NUMERIC_SEQUENCE_LENGTH) {

                errors.add("{user.password.sequence}");
                return;
            }
        }
    }

    /**
     * Se houver erros, desabilita a violação padrão ({@code {user.password.strong}})
     * e registra cada chave acumulada como violação separada, sem nó de propriedade
     * (herda o campo anotado).
     */
    private void addErrors(List<String> errors, ConstraintValidatorContext context) {

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

        context.disableDefaultConstraintViolation();

        for (String error : errors) {
            context.buildConstraintViolationWithTemplate(error).addConstraintViolation();
        }
    }
}