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.