1/23/2024, 9:27:10 PM

Confirm Password Validation for forms in Spring Boot and Kotlin

Spring Boot’s Bean Validation system comes with a lot of built-in annotations that can be applied to fields like NotBlank, Email, etc.

What it does not include though is a validation for a very common use case for registration and password reset forms which uses the confirm password field to prevent accidental typos while setting the password.

However, thanks to the dynamic annotation based nature of Spring Boot’s validation system, it’s very easy to create a custom annotation that handles this use case for us.

Implementation

The implementation is made up of three parts that come together to perform the validation. The validation itself is designed to be as generic as possible so that it can be reused for multiple data classes.

Step 1 - The Data Interface

An interface that defines the common password fields, which allows the validation logic to be type-safe.

interface ConfirmPasswordInterface {
    val password: String
    val confirmPassword: String
}

Step 2 - The Custom Annotation

The annotation itself with its properties. Of particular note is the @Target attribute, which specifies this as a class level annotation instead of the usual field level annotations.

The field message defines the error message sent back in the response that can be overridden to pass a custom error message if needed.

The @Constraint annotation defines the actual validator implementation. The ConfirmPasswordValidator class will be used for the checks.

@Target(AnnotationTarget.CLASS)
@Constraint(validatedBy = [ConfirmPasswordValidator::class])
@Retention(AnnotationRetention.RUNTIME)
annotation class ConfirmPassword(
    val message: String = "The passwords do not match",
    val groups: Array<KClass<Any>> = [],
    val payload: Array<KClass<Payload>> = [],
)

Step 3 - The Custom Validator Class

The validator class that contains the validation logic and performs the actual validation on the fields by inheriting the ConstraintValidator interface from Spring, which implements an isValid function that Spring calls to perform the validation.

The form data class marked with our annotation is passed as a parameter to the isValid. Since the data class implements our interface, we can directly access the properties here without having to resort to reflection.

Since the annotation is applied to the class instead of a field, Spring is unable to attach the error message to the correct field. To resolve this, we can use the context parameter to attach the error message to the correct field ourselves.

class ConfirmPasswordValidator : ConstraintValidator<PasswordConfirmation, ConfirmPasswordInterface> {
    override fun isValid(request: ConfirmPasswordInterface, context: ConstraintValidatorContext): Boolean {
        val isValid = request.password == request.passwordConfirmation

        if (!isValid) {
            context.disableDefaultConstraintViolation()
            context
                .buildConstraintViolationWithTemplate(context.defaultConstraintMessageTemplate)
                .addPropertyNode("passwordConfirmation")
                .addConstraintViolation()
        }

        return isValid
    }
}

Conclusion

That’s it. While I prefer to keep all the three parts in the same file, you can keep then in separate files if that is how your codebase or style guidelines prefer.

To implement this validation on data classes, all you have to do is implement a data class with the ConfirmPasswordInterface and then attach the ConfirmPassword annotation to the class.

Below is an example of a simple registration form validator. In this example, the data class also overrides the password fields to add more validations to the fields themselves.

@ConfirmPassword
data class RegisterRequest(
    @field:NotBlank(message = "This field is required")
    @field:Size(max = 255, message = "This field should be smaller that 255 characters")
    val firstName: String,

    @field:NotBlank(message = "This field is required")
    @field:Size(max = 255, message = "This field should be smaller that 255 characters")
    val lastName: String,

    @field:NotBlank(message = "This field is required")
    @field:Email(message = "The email address is invalid")
    @field:Size(max = 255, message = "This field should be smaller that 255 characters")
    val email: String,

    @field:NotBlank(message = "This field is required")
    override val password: String,

    @field:NotBlank(message = "This field is required")
    override val confirmPassword: String,
) : ConfirmPasswordInterface