Just another blog site

Spring Boot Passay Password Validation With I18n

In one of the previous posts, passay a java library for password validation is examined. In this post, we'll learn how we can implement passay with i18n(internationalization) so that we'll have unique error messages for each language.

  ·   6 min read

Photo by Hannah Wright on Unsplash

Photo by Hannah Wright on Unsplash

In the previous blog, we discussed how to create a password policy enforcer by using spring boot validators. Simply put, we created a custom validator class for a string filed on request class. This time, we’ll try to internationalize the error messages created by passay during password validation according to enforced policies.


Project Tech Stack

  • Java 17
  • Spring Framework (2.7.2 with Spring Data JPA, Spring Web, and Spring Validation)
  • Passay (For password constraints)
  • H2 Database (Database for test purposes)
  • Lombok (To reduce boilerplate code)
  • Springdoc Swagger-UI (For API testing)

Source Code: https://github.com/AykutBuyukkaya/spring-boot-password-validation-example/tree/message-internationalization

Pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.aykutbuyukaya</groupId>
    <artifactId>java-password-validation-example</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>java-password-validation-example</name>
    <description>java-password-validation-example</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.passay</groupId>
            <artifactId>passay</artifactId>
            <version>1.6.1</version>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-ui</artifactId>
            <version>1.6.9</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>


    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Passay is an open-source library for adding password policies for your application. With Passay, you can let your users create stronger passwords. You can set min-max length, character rules, dictionary rules, etc. You can find the source code for passay here.

@Password annotation

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = PasswordConstraintsValidator.class)
public @interface Password {

    String message() default "Invalid password!";

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

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

}

By this annotation, validation of a request field will be possible. I passed “Invalid password!” as a default error message if java fails to load the custom error messages we created.

PasswordConstraintsValidator

@Slf4j
public class PasswordConstraintsValidator implements ConstraintValidator<Password, String> {

    private static final String PASSAY_MESSAGE_FILE_PATH = "src/main/resources/passay_%s.properties";

    private final HttpServletRequest httpServletRequest;

    public PasswordConstraintsValidator(HttpServletRequest httpServletRequest) {
        this.httpServletRequest = httpServletRequest;
    }

    @Override
    public boolean isValid(String password, ConstraintValidatorContext constraintValidatorContext) {

        PasswordValidator passwordValidator = new PasswordValidator(generateMessageResolver(),
                Arrays.asList(
                        //Length rule. Min 10 max 128 characters
                        new LengthRule(10, 128),

                        //At least one upper case letter
                        new CharacterRule(EnglishCharacterData.UpperCase, 1),

                        //At least one lower case letter
                        new CharacterRule(EnglishCharacterData.LowerCase, 1),

                        //At least one number
                        new CharacterRule(EnglishCharacterData.Digit, 1),

                        //At least one special characters
                        new CharacterRule(EnglishCharacterData.Special, 1),

                        new WhitespaceRule(MatchBehavior.Contains)
                )
        );

        RuleResult result = passwordValidator.validate(new PasswordData(password));

        if (result.isValid()) {
            return true;
        }

        //Sending one message each time failed validation.
        constraintValidatorContext.buildConstraintViolationWithTemplate(passwordValidator.getMessages(result).stream().findFirst()
                        .orElse(constraintValidatorContext.getDefaultConstraintMessageTemplate()))
                .addConstraintViolation()
                .disableDefaultConstraintViolation();
        return false;
    }

    private MessageResolver generateMessageResolver() {

        String lang = Optional.of(httpServletRequest.getParameter("lang"))
                .orElseThrow(() -> new RuntimeException("Lang parameter cannot be null!"));

        Properties props = new Properties();

        //Default passay messages in english. So we do not need to search english messages file.
        if (!lang.contains("en")) {

            try (FileInputStream fileInputStream = new FileInputStream(String.format(PASSAY_MESSAGE_FILE_PATH, lang))) {

                props.load(new InputStreamReader(fileInputStream, StandardCharsets.UTF_8));
                return new PropertiesMessageResolver(props);

            } catch (IOException exception) {

                log.error("Invalid language parameter!");
                throw new InvalidRequestParameterException("Invalid language parameter!");

            }

        }

        return new PropertiesMessageResolver();
    }
}

This is the class where we validate the annotated string field. Since we are internationalizing error messages, we have to get the current locale from the client. I have chosen to use a request parameter for the sake of simplicity. Many browsers today supply every request with a language header.

The logic of internationalization is simply getting the correct properties file that contains error messages for a language. The generateMessageResolver method is responsible for acquiring the correct properties file. The isValid method simply validates the given field. I wrote a blog post about password validation with passay in detail. You can find it here.

passay_tr.properties

ILLEGAL_WHITESPACE=Şifreniz boşluk içermemelidir.
INSUFFICIENT_UPPERCASE=Şifreniz en az %1$s büyük harf içermelidir.
INSUFFICIENT_LOWERCASE=Şifreniz en az %1$s küçük harf içermelidir.
INSUFFICIENT_DIGIT=Şifreniz en az %1$s rakam içermelidir.
INSUFFICIENT_SPECIAL=Şifreniz en az %1$s özel karakter içermelidir (+#$*).
TOO_LONG=Şifreniz en fazla %2$s karakter uzunluğunda olabilir.
TOO_SHORT=Şifreniz en az %1$s karakter uzunluğunda olmalıdır.

This is the file where we put messages for a language. Passay will show these if validation fails. The pattern passay_%s.properties allows us to put multiple langue files. You only need to replace “%s” with the language parameter passed by the client. For example, if you need error messages in french. Then you’ll need to create a properties file passay_fr.properties and fill it with the french version of the default file. There are so many validation techniques supplied by passay and each of them has an error message. I only used the most used ones. You can find more information on https://www.passay.org/reference/.

There is the default message file supplied by the passay library:

HISTORY_VIOLATION=Password matches one of %1$s previous passwords. 
ILLEGAL_WORD=Password contains the dictionary word '%1$s'. 
ILLEGAL_WORD_REVERSED=Password contains the reversed dictionary word '%1$s'. 
ILLEGAL_DIGEST_WORD=Password contains a dictionary word. 
ILLEGAL_DIGEST_WORD_REVERSED=Password contains a reversed dictionary word. 
ILLEGAL_MATCH=Password matches the illegal pattern '%1$s'. 
ALLOWED_MATCH=Password must match pattern '%1$s'. 
ILLEGAL_CHAR=Password %2$s the illegal character '%1$s'. 
ALLOWED_CHAR=Password %2$s the illegal character '%1$s'. 
ILLEGAL_QWERTY_SEQUENCE=Password contains the illegal QWERTY sequence '%1$s'. 
ILLEGAL_ALPHABETICAL_SEQUENCE=Password contains the illegal alphabetical sequence '%1$s'. 
ILLEGAL_NUMERICAL_SEQUENCE=Password contains the illegal numerical sequence '%1$s'. 
ILLEGAL_USERNAME=Password %2$s the user id '%1$s'. 
ILLEGAL_USERNAME_REVERSED=Password %2$s the user id '%1$s' in reverse. 
ILLEGAL_WHITESPACE=Password %2$s a whitespace character. 
ILLEGAL_NUMBER_RANGE=Password %2$s the number '%1$s'. 
ILLEGAL_REPEATED_CHARS=Password contains %3$s sequences of %1$s or more repeated characters, but only %2$s allowed: %4$s. 
INSUFFICIENT_UPPERCASE=Password must contain %1$s or more uppercase characters. 
INSUFFICIENT_LOWERCASE=Password must contain %1$s or more lowercase characters. 
INSUFFICIENT_ALPHABETICAL=Password must contain %1$s or more alphabetical characters. 
INSUFFICIENT_DIGIT=Password must contain %1$s or more digit characters. 
INSUFFICIENT_SPECIAL=Password must contain %1$s or more special characters. 
INSUFFICIENT_CHARACTERISTICS=Password matches %1$s of %3$s character rules, but %2$s are required. 
INSUFFICIENT_COMPLEXITY=Password meets %2$s complexity rules, but %3$s are required. 
INSUFFICIENT_COMPLEXITY_RULES=No rules have been configured for a password of length %1$s. 
SOURCE_VIOLATION=Password cannot be the same as your %1$s password. 
TOO_LONG=Password must be no more than %2$s characters in length. 
TOO_SHORT=Password must be %1$s or more characters in length. 
TOO_MANY_OCCURRENCES=Password contains %2$s occurrences of the character '%1$s', but at most %3$s are allowed.

You can find more details on https://www.passay.org/reference/

SignupRequest

@Data
@AllArgsConstructor
@NoArgsConstructor
public class SignupRequest {

    @Email
    private String email;

    @Password
    private String password;

}

Spring validation supplies us with a default email validator and I created a @Password annotation to check if the given string field matches with the password policies I had decided.

SignupController

@RestController
@RequestMapping("/authentication")
public class SignupController {

    private final UserRepository userRepository;

    public SignupController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }


    @PostMapping("/signup")
    public ResponseEntity<SignupResponse> signup(@RequestBody @Valid SignupRequest request,
                                                 @RequestParam(defaultValue = "en-us") String lang) {

        SignupResponse response = SignupResponse.builder()
                .user(userRepository.save(new User(request.getEmail(), request.getPassword())))
                .message("Sign Up Complete!")
                .timestamp(Instant.now())
                .build();

        return new ResponseEntity<>(response, HttpStatus.CREATED);

    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public SignupResponse handlePasswordValidationException(MethodArgumentNotValidException e) {


        //Returning password error message as a response.
        return SignupResponse.builder()
                .message(Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage())
                .timestamp(Instant.now())
                .build();

    }

    @ExceptionHandler(InvalidRequestParameterException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public SignupResponse handleInvalidRequestParameterResponse(InvalidRequestParameterException e) {

        return SignupResponse.builder()
                .message(e.getMessage())
                .timestamp(Instant.now())
                .build();

    }

}```

The client will send a post request to /signup endpoint with a SignupRequest as a request body and a string as a language parameter. There are two exception handlers. One is for if a client sends a request with an insufficient password according to the policies, and the other one is for a case that client supplied language parameter does not have a passay properties file.

This should be helpful,

I appreciate your interest in my post. I wish to assist you with any questions and issues. Emails can be sent to me at aykutbkaya@gmail.com.

Thanks.