#Spring Boot+Jakarta: Conditionally change validation message

1 messages · Page 1 of 1 (latest)

hasty otter
#

I am making a custom password validator. The annotation:

@Documented
@Constraint(validatedBy = PasswordConstraint.class)
@Target({ElementType.FIELD})
public @interface Password {
    String message() default "";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

The constraint which checks the length of the password and if password contains letters, digits and symbols.

public class PasswordConstraint implements ConstraintValidator<Password, String> {
    @Override
    public void initialize(Password password) {
        ConstraintValidator.super.initialize(password);
    }

    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        int min = 8;
        String symbols = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";

        if (password.length() < min) {
            return false;
        }
        if (password.chars().noneMatch(Character::isAlphabetic))
            return false;
        else {
            boolean lower = password.chars().anyMatch(Character::isLowerCase);
            boolean upper = password.chars().anyMatch(Character::isUpperCase);

            if (!(lower && upper))
                return false;
        }

        if (password.chars().noneMatch(Character::isDigit))
            return false;
        return password.chars().anyMatch(c -> symbols.indexOf(c) != 0);
    }
}

How do I dynamically change the message for each case?

broken marlinBOT
#

<@&1004656351647117403> please have a look, thanks.

short stump
#

You're better to just force a larger minimum length than 'complexity'.

Adding those restrictions makes hash-attacks easier. Longer passwords is a much more effective way to increase costs for attackers.

  1. Rate-limit the login endpoint
  2. Use strong salted-hashing and a large minimum password length.

Store the user identity+credentials separately to application data, accessible only via the rate-limited API, so that a breach of the main application can't expose the hash database.

short stump
#

As to the message. I think you add violations to the context.

glacial garden
#

As a user, I would rather see ALL the constraint violation messages instead of piecemeal just because of some early return that you have going on. Consider renaming isValid() to validate and have it returning a List<ValidationMessage> instead of boolean, where an empty list means it's a valid password.

short stump
#

Yes, you should be able to record all of the violations before returning.

hasty otter
#

The DTO object.

@Data
@ConfirmPassword
public class RegisterDTO {
    @NotBlank(message = "{email.NotBlank}")
    @Email(message = "{email.Email}")
    private String email;
    @NotBlank(message = "{password.NotBlank}")
    @Password
    private String password;
    @NotBlank(message = "{password2.NotBlank}")
    private String password2;
    @NotBlank(message = "{phone.NotBlank}")
    @Pattern(regexp = "^\\+381 \\d{2} \\d{6,7}$", message = "{phone.Pattern}")
    private String phone;
    @NotBlank(message = "{firstName.NotBlank}")
    @Pattern(regexp = "^\\p{L}*$", message = "{firstName.Alpha}")
    private String firstName;
    @NotBlank(message = "{lastName.NotBlank}")
    @Pattern(regexp = "^\\p{L}*$", message = "{lastName.Alpha}")
    private String lastName;
    @NotNull(message = "{birthDate.NotNull}")
    @Adult
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate birthDate;
}
#

I tried changing the message via the validator context, but that didn't work.

public class PasswordConstraint implements ConstraintValidator<Password, String> {
    @Override
    public void initialize(Password password) {
        ConstraintValidator.super.initialize(password);
    }

    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        int min = 8;
        String symbols = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";

        if (password.length() < min) {
            context.buildConstraintViolationWithTemplate("{password.Min}").addConstraintViolation();
            return false;
        }
        if (password.chars().noneMatch(Character::isAlphabetic)) {
            context.buildConstraintViolationWithTemplate("{password.Letters}").addConstraintViolation();
            return false;
        } else {
            boolean lower = password.chars().anyMatch(Character::isLowerCase);
            boolean upper = password.chars().anyMatch(Character::isUpperCase);

            if (!(lower && upper)) {
                context.buildConstraintViolationWithTemplate("{password.Mixed}").addConstraintViolation();
                return false;
            }
        }

        if (password.chars().noneMatch(Character::isDigit)) {
            context.buildConstraintViolationWithTemplate("{password.Numbers}").addConstraintViolation();
            return false;
        }

        if (password.chars().noneMatch(c -> symbols.indexOf(c) != 0)) {
            context.buildConstraintViolationWithTemplate("{password.Symbols}").addConstraintViolation();
            return false;
        }

        return true;
    }
}
hasty otter
#

RegisterDTO:

@Data
@ConfirmPassword
public class RegisterDTO {
    @NotBlank(message = "{email.NotBlank}")
    @Email(message = "{email.Email}")
    private String email;
    @NotBlank(message = "{password.NotBlank}")
    @Password
    private String password;
    @NotBlank(message = "{password2.NotBlank}")
    private String password2;
    @NotBlank(message = "{phone.NotBlank}")
    @Pattern(regexp = "^\\+381 \\d{2} \\d{6,7}$", message = "{phone.Pattern}")
    private String phone;
    @NotBlank(message = "{firstName.NotBlank}")
    @Pattern(regexp = "^\\p{L}*$", message = "{firstName.Alpha}")
    private String firstName;
    @NotBlank(message = "{lastName.NotBlank}")
    @Pattern(regexp = "^\\p{L}*$", message = "{lastName.Alpha}")
    private String lastName;
    @NotNull(message = "{birthDate.NotNull}")
    @Adult
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate birthDate;
}

PhoneCheck:

public interface PhoneCheck {
}

PhoneSequence that groups Default and PhoneCheck:

@GroupSequence({Default.class, PhoneCheck.class})
public interface PhoneSequence {
}
#

Updated password constraint that validates password:

public class PasswordConstraint implements ConstraintValidator<Password, String> {
    @Override
    public void initialize(Password password) {
        ConstraintValidator.super.initialize(password);
    }

    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        int min = 8;
        String symbols = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";

        if (password == null || password.isEmpty()) return true;

        if (password.length() < min) {
            context.buildConstraintViolationWithTemplate("{password.Min}").addConstraintViolation();
            return false;
        }

        if (password.chars().noneMatch(Character::isAlphabetic)) {
            context.buildConstraintViolationWithTemplate("{password.Letters}").addConstraintViolation();
            return false;
        } else {
            boolean upper = password.chars().anyMatch(Character::isUpperCase);
            boolean lower = password.chars().anyMatch(Character::isLowerCase);

            if (!(upper && lower)) {
                context.buildConstraintViolationWithTemplate("{password.Mixed}").addConstraintViolation();
                return false;
            }
        }

        if (password.chars().noneMatch(Character::isDigit)) {
            context.buildConstraintViolationWithTemplate("{password.Numbers}").addConstraintViolation();
            return false;
        }

        if (password.chars().noneMatch(c -> symbols.indexOf(c) >= 0)) {
            context.buildConstraintViolationWithTemplate("{password.Symbols}").addConstraintViolation();
            return false;
        }

        return true;
    }
}
#

Also, read the first post which explains what the constraint does.

#

register POST method of AuthController:

@PostMapping("/registracija")
    public String register(Model model, HttpServletRequest request, RedirectAttributes attributes,
        @ModelAttribute("dto") @Validated(PhoneSequence.class) RegisterDTO dto, BindingResult result) {
        // 2 Input validation & sanitization
        if (result.hasErrors()) {
            model.addAttribute("dto", dto);

            // 5 Security logging
            logger.error("Registration failed due to validation errors.");
            return "auth/register";
        }

        if (userServ.existsByEmail(dto.getEmail())) {
            String fail = "Korisnik sa ovom imejl adresom već postoji.";
            attributes.addFlashAttribute("fail", fail);

            // 5 Security logging
            logger.error("Registration failed because an user with the provided email address already exists.");
            return "redirect:/registracija";
        }

        if (userServ.existsByPhone(dto.getPhone())) {
            String fail = "Korisnik sa ovim brojem telefona već postoji.";
            attributes.addFlashAttribute("fail", fail);

            // 5 Security logging
            logger.error("Registration failed because an user with the provided phone number already exists.");
            return "redirect:/registracija";
        }

        CustomUser user = userServ.create(dto);

        try {
            request.login(user.getEmail(), user.getPassword());

            // 5 Security logging
            logger.info("User {} registered successfully.", user);
            return "redirect:/oglasi";
        } catch (ServletException e) {
            throw new RuntimeException(e);
        }
    } // [1]

It validates the DTO first, then it checks if an user with the email or phone already exists, then creates and registers a new user.

#

Why doesn't it show validation error message for each password check case???

hasty otter
#

If I enter aaaaaaaa, the app doesn't check if password is in mixed case.
If I enter aAaAaAaA, the app doesn't check if password has numbers.
If I enter 11111111, the app checks if password has any letters and correctly shows the error message.
If I enter a1a1a1a1, the app doesn't check if password is in mixed case.
If I enter aA11aA11, the app checks if password has any symbols and correctly shows the error message.
If I enter aA11aA11!, the password is correct and no errors are shown.

#

Please answer me.

hasty otter
#

ANYONE?

hasty otter
#

Spring+Hibernate: Conditionally change validation message

#

Bump.

hasty otter
#

Spring+Jakarta: Conditionally change validation message

#

Spring Boot+Jakarta: Conditionally change validation message

hasty otter
#

@everyone