diff --git a/pom.xml b/pom.xml
index 5377c58..5d39611 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,9 +4,9 @@
4.0.0
-org.devhamzat
+ org.constraynt
constraynt
- 0.0.1-SNAPSHOT
+ 0.1.0-SNAPSHOT
jar
Constraynt
@@ -24,8 +24,8 @@
- yourFriendlyNeighbourhoodDev
- Dev
+ yourFriendlyNeighbourhoodBatman
+ BATMAN
developer
package manager
@@ -73,17 +73,17 @@
passay
1.6.4
+
+ org.springframework
+ spring-web
+ 6.1.11
+
org.projectlombok
lombok
1.18.30
true
-
- org.springframework.boot
- spring-boot-starter-web
- 3.0.5
-
org.springframework.boot
spring-boot-starter-test
diff --git a/src/main/java/org/devhamzat/email/Email.java b/src/main/java/org/devhamzat/email/Email.java
index 1a72494..9d44881 100644
--- a/src/main/java/org/devhamzat/email/Email.java
+++ b/src/main/java/org/devhamzat/email/Email.java
@@ -1,9 +1,10 @@
-package org.devhamzat.email;
+package org.constraynt.email;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.hibernate.validator.internal.util.DomainNameUtil;
+import java.net.IDN;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -11,43 +12,154 @@
public class Email implements ConstraintValidator {
private static final int MAX_LOCAL_PART_LENGTH = 64;
- private static final String LOCAL_PART_ATOM = "[a-z0-9!#$%&'*+/=?^_`{|}~\u0080-\uFFFF-]";
- private static final String LOCAL_PART_INSIDE_QUOTES_ATOM = "[a-z0-9!#$%&'*.(),<>\\[\\]:; @+/=?^_`{|}~\u0080-\uFFFF-]|\\\\\\\\|\\\\\"";
+ private static final int MAX_DOMAIN_TOTAL_LENGTH = 255;
+ private static final int MAX_LABEL_LENGTH = 63;
- private static final Pattern LOCAL_PART_PATTERN = Pattern.compile(
- "(?:" + LOCAL_PART_ATOM + "+|\"" + LOCAL_PART_INSIDE_QUOTES_ATOM + "+\")" +
- "(?:\\." + "(?:" + LOCAL_PART_ATOM + "+|\"" + LOCAL_PART_INSIDE_QUOTES_ATOM + "+\")" + ")*", CASE_INSENSITIVE
+ // Fast path ASCII email regex (common case)
+ private static final Pattern FAST_PATH_ASCII = Pattern.compile(
+ "^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@" +
+ "(?:(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)\\.)+" +
+ "[A-Za-z]{2,63}$"
);
+ private static final String LOCAL_PART_ATOM_ALL = "[a-z0-9!#$%&'*+/=?^_`{|}~\\u0080-\\uFFFF-]";
+ private static final String LOCAL_PART_ATOM_NO_PLUS = "[a-z0-9!#$%&'*//=?^_`{|}~\\u0080-\\uFFFF-]"; // no '+'
+ private static final String LOCAL_PART_INSIDE_QUOTES_ATOM = "(?:[a-z0-9!#$%&'*.(),<>\\[\\]:; @+/=?^_`{|}~\\u0080-\\uFFFF-]|\\\\\\\\|\\\\\\\")";
+
+ private Pattern localPartPattern;
+ private boolean allowTld;
+ private boolean allowIpDomain;
+ private boolean allowPlusSign;
+
@Override
public void initialize(ValidateEmail constraintAnnotation) {
- ConstraintValidator.super.initialize(constraintAnnotation);
+ this.allowTld = constraintAnnotation.allowTld();
+ this.allowIpDomain = constraintAnnotation.allowIpDomain();
+ this.allowPlusSign = constraintAnnotation.allowPlusSign();
+ String atom = allowPlusSign ? LOCAL_PART_ATOM_ALL : LOCAL_PART_ATOM_NO_PLUS;
+ this.localPartPattern = Pattern.compile(
+ "(?:" + atom + "+|\"" + LOCAL_PART_INSIDE_QUOTES_ATOM + "+\")" +
+ "(?:\\." + "(?:" + atom + "+|\"" + LOCAL_PART_INSIDE_QUOTES_ATOM + "+\")" + ")*",
+ CASE_INSENSITIVE
+ );
}
@Override
- public boolean isValid(CharSequence email, ConstraintValidatorContext context) {
- if (email == null || email.isEmpty()) {
- return false;
+ public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
+ if (value == null || value.length() == 0) {
+ return true; // null/empty considered valid; use @NotBlank to require
}
- String emailString = email.toString();
- int split = emailString.lastIndexOf("@");
- if (split <= 0) {
- return false;
+ final String email = value.toString();
+
+ // Fast path for common ASCII emails (only when TLDs are allowed)
+ if (allowTld && FAST_PATH_ASCII.matcher(email).matches()) {
+ return true;
}
- String localPart = emailString.substring(0, split);
- String domainPart = emailString.substring(split + 1);
- if (!isValidEmailLocalPart(localPart)) {
- return false;
+ int at = email.lastIndexOf('@');
+ if (at <= 0 || at == email.length() - 1) {
+ return violation(context, "{constraynt.email.missingAt}");
+ }
+
+ String local = email.substring(0, at);
+ String domain = email.substring(at + 1);
+
+ if (!isValidLocal(local)) {
+ return violation(context, "{constraynt.email.local.invalid}");
+ }
+
+ if (!isValidDomain(domain, context)) {
+ return false; // violation already added
}
- return DomainNameUtil.isValidEmailDomainAddress( domainPart );
+ return true;
}
- private boolean isValidEmailLocalPart(String localPart) {
- if (localPart.length() > MAX_LOCAL_PART_LENGTH) {
+ private boolean isValidLocal(String local) {
+ if (local.length() > MAX_LOCAL_PART_LENGTH) {
return false;
}
- Matcher matcher = LOCAL_PART_PATTERN.matcher(localPart);
- return matcher.matches();
+ // Disallow leading/trailing dot and consecutive dots when not quoted
+ if (!(local.startsWith("\"") && local.endsWith("\""))) {
+ if (local.startsWith(".") || local.endsWith(".")) return false;
+ if (local.contains("..")) return false;
+ }
+ Matcher m = localPartPattern.matcher(local);
+ return m.matches();
+ }
+
+ private boolean isValidDomain(String domain, ConstraintValidatorContext context) {
+ // IP literal: [x.x.x.x] or [IPv6:...]
+ if (domain.startsWith("[") && domain.endsWith("]")) {
+ if (!allowIpDomain) {
+ return violation(context, "{constraynt.email.domain.ipNotAllowed}");
+ }
+ String inner = domain.substring(1, domain.length() - 1);
+ if (inner.regionMatches(true, 0, "IPv6:", 0, 5)) {
+ String ipv6 = inner.substring(5);
+ if (!IPV6_PATTERN.matcher(ipv6).matches()) {
+ return violation(context, "{constraynt.email.domain.invalid}");
+ }
+ return true;
+ } else {
+ if (!IPV4_PATTERN.matcher(inner).matches()) {
+ return violation(context, "{constraynt.email.domain.invalid}");
+ }
+ return true;
+ }
+ }
+
+ String[] labels = domain.split("\\.");
+ if (labels.length == 0) {
+ return violation(context, "{constraynt.email.domain.invalid}");
+ }
+ if (!allowTld && labels.length < 2) {
+ return violation(context, "{constraynt.email.domain.tldNotAllowed}");
+ }
+
+ StringBuilder asciiBuilder = new StringBuilder();
+ try {
+ for (int i = 0; i < labels.length; i++) {
+ String label = labels[i];
+ if (label.isEmpty()) {
+ return violation(context, "{constraynt.email.domain.invalid}");
+ }
+ String ascii = IDN.toASCII(label, IDN.USE_STD3_ASCII_RULES);
+ if (ascii.isEmpty() || ascii.length() > MAX_LABEL_LENGTH) {
+ return violation(context, "{constraynt.email.domain.labelTooLong}");
+ }
+ if (!DOMAIN_LABEL_ASCII.matcher(ascii).matches()) {
+ return violation(context, "{constraynt.email.domain.invalid}");
+ }
+ if (i > 0) asciiBuilder.append('.');
+ asciiBuilder.append(ascii);
+ }
+ } catch (IllegalArgumentException ex) {
+ return violation(context, "{constraynt.email.domain.invalid}");
+ }
+
+ String asciiDomain = asciiBuilder.toString();
+ if (asciiDomain.length() > MAX_DOMAIN_TOTAL_LENGTH) {
+ return violation(context, "{constraynt.email.domain.tooLong}");
+ }
+ if (!DomainNameUtil.isValidEmailDomainAddress(asciiDomain)) {
+ return violation(context, "{constraynt.email.domain.invalid}");
+ }
+ return true;
+ }
+
+ private boolean violation(ConstraintValidatorContext context, String messageTemplate) {
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate(messageTemplate).addConstraintViolation();
+ return false;
}
+
+ // Conservative IPv4 and IPv6 patterns
+ private static final Pattern IPV4_PATTERN = Pattern.compile(
+ "^(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}$");
+
+ private static final Pattern IPV6_PATTERN = Pattern.compile(
+ "^([0-9A-Fa-f]{1,4})(:([0-9A-Fa-f]{1,4})){7}$|^(([0-9A-Fa-f]{1,4}:){1,7}:)$|^(:(:[0-9A-Fa-f]{1,4}){1,7})$|^((([0-9A-Fa-f]{1,4}:){1,6}|:):([0-9A-Fa-f]{1,4})(:([0-9A-Fa-f]{1,4})){0,5})$");
+
+ private static final Pattern DOMAIN_LABEL_ASCII = Pattern.compile(
+ "^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)$");
}
diff --git a/src/main/java/org/devhamzat/email/ValidateEmail.java b/src/main/java/org/devhamzat/email/ValidateEmail.java
index 974fbba..ea1e576 100644
--- a/src/main/java/org/devhamzat/email/ValidateEmail.java
+++ b/src/main/java/org/devhamzat/email/ValidateEmail.java
@@ -3,6 +3,7 @@
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
+import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
@@ -12,11 +13,18 @@
@Target({FIELD})
@Retention(value = RUNTIME)
@Constraint(validatedBy = Email.class)
+@Documented
public @interface ValidateEmail {
- String message() default "Invalid email format";
+ String message() default "{org.constraynt.email.ValidateEmail}";
Class>[] groups() default {};
Class extends Payload>[] payload() default {};
+ boolean allowTld() default true;
+
+ boolean allowIpDomain() default false;
+
+ boolean allowPlusSign() default true;
+
}
diff --git a/src/main/java/org/devhamzat/password/ConstrayntPasswordValidator.java b/src/main/java/org/devhamzat/password/ConstrayntPasswordValidator.java
index 72377eb..ff4bb02 100644
--- a/src/main/java/org/devhamzat/password/ConstrayntPasswordValidator.java
+++ b/src/main/java/org/devhamzat/password/ConstrayntPasswordValidator.java
@@ -1,24 +1,15 @@
package org.devhamzat.password;
-import java.util.List;
-
/**
* Interface for password validation in the Constraynt library.
* Implementations of this interface provide custom password validation logic.
*/
public interface ConstrayntPasswordValidator {
/**
- * Validates the given password against the implemented rules.
- *
- * @param password The password to validate.
- * @return true if the password is valid, false otherwise.
- */
- boolean validate(String password);
- /**
- * Retrieves the error messages generated during the last validation.
- *
- * @return A list of error messages, or an empty list if no errors occurred.
+ * Validates the given password against the provided policy.
+ * @param rawPassword the password text to validate.
+ * @param policy the policy describing rule configuration.
+ * @return validation result including message codes when invalid.
*/
- List getErrorMessages();
-
+ PasswordValidationResult validate(String rawPassword, PasswordPolicy policy);
}
diff --git a/src/main/java/org/devhamzat/password/DefaultConstrayntPasswordValidator.java b/src/main/java/org/devhamzat/password/DefaultConstrayntPasswordValidator.java
index 6a2df55..5e662ac 100644
--- a/src/main/java/org/devhamzat/password/DefaultConstrayntPasswordValidator.java
+++ b/src/main/java/org/devhamzat/password/DefaultConstrayntPasswordValidator.java
@@ -1,48 +1,105 @@
package org.devhamzat.password;
import org.passay.*;
-import org.passay.dictionary.ArrayWordList;
+import org.passay.dictionary.Dictionary;
import org.passay.dictionary.WordListDictionary;
+import org.passay.dictionary.WordLists;
+
+import java.util.*;
+import java.util.stream.Collectors;
-import java.util.Arrays;
-import java.util.List;
-// DefaultConstrayntPasswordValidator.java
/**
- * Default implementation of the ConstrayntPasswordValidator interface.
- * This class uses the Passay library to perform password validation.
+ * Default implementation of the ConstrayntPasswordValidator interface using Passay.
*/
public class DefaultConstrayntPasswordValidator implements ConstrayntPasswordValidator {
- private final PasswordValidator validator;
- private List errorMessages;
- /**
- * Constructs a new DefaultConstrayntPasswordValidator with default rules.
- * The default rules include length requirements and character type requirements.
- */
- public DefaultConstrayntPasswordValidator() {
- WordListDictionary wordListDictionary = new WordListDictionary(
- new ArrayWordList(new String[]{"password", "username"}));
- validator = new PasswordValidator(Arrays.asList(
- new LengthRule(8, 64),
- new CharacterRule(EnglishCharacterData.UpperCase, 1),
- new CharacterRule(EnglishCharacterData.LowerCase, 1),
- new CharacterRule(EnglishCharacterData.Digit, 1),
- new CharacterRule(EnglishCharacterData.Special, 1),
- new DictionarySubstringRule(wordListDictionary),
- new DictionaryRule(wordListDictionary),
- new WhitespaceRule()
- ));
- }
+ private static final String[] DEFAULT_DICT = {
+ "password", "passw0rd", "letmein", "welcome", "admin", "user",
+ "qwerty", "abc123", "iloveyou", "monkey"
+ };
@Override
- public boolean validate(String password) {
- RuleResult result = validator.validate(new PasswordData(password));
- errorMessages = validator.getMessages(result);
- return result.isValid();
+ public PasswordValidationResult validate(String rawPassword, PasswordPolicy policy) {
+ List rules = new ArrayList<>();
+ rules.add(new LengthRule(policy.minLength, policy.maxLength));
+ if (policy.requireUppercase) rules.add(new CharacterRule(EnglishCharacterData.UpperCase, 1));
+ if (policy.requireLowercase) rules.add(new CharacterRule(EnglishCharacterData.LowerCase, 1));
+ if (policy.requireDigit) rules.add(new CharacterRule(EnglishCharacterData.Digit, 1));
+ if (policy.requireSpecial) rules.add(new CharacterRule(EnglishCharacterData.Special, 1));
+ if (policy.disallowWhitespace) {
+ rules.add(new WhitespaceRule());
+ }
+ if (policy.isBlockedStringsEnabled) {
+ if (policy.blockedSubstrings != null && !policy.blockedSubstrings.isEmpty()) {
+ rules.add(new IllegalRegexRule(buildSubstringUnionRegex(policy.blockedSubstrings)));
+ }
+ }
+ if (policy.isDictionaryEnabled) {
+ Dictionary dictionary = buildCombinedDictionary(policy.enableDefaultDictionary);
+ if (dictionary != null) {
+ rules.add(new DictionaryRule(dictionary));
+ }
+ }
+
+ PasswordValidator validator = new PasswordValidator(rules);
+ RuleResult result = validator.validate(new PasswordData(rawPassword));
+ if (result.isValid()) {
+ return PasswordValidationResult.ok();
+ }
+
+ List codes = result.getDetails().stream()
+ .map(this::mapDetailToMessageKey)
+ .distinct()
+ .collect(Collectors.toList());
+
+ if (codes.isEmpty()) {
+ codes = List.of("constraynt.password.invalid");
+ }
+ return PasswordValidationResult.fail(codes);
}
- @Override
- public List getErrorMessages() {
- return errorMessages;
+ private String buildSubstringUnionRegex(List blocked) {
+ String inner = blocked.stream()
+ .filter(s -> s != null && !s.isBlank())
+ .map(java.util.regex.Pattern::quote)
+ .collect(Collectors.joining("|"));
+ if (inner.isEmpty()) inner = "(?!)";
+ return "(?i).*(" + inner + ").*";
+ }
+
+ private Dictionary buildCombinedDictionary(boolean includeDefault) {
+ List all = new ArrayList<>();
+ if (includeDefault) all.addAll(Arrays.asList(DEFAULT_DICT));
+
+ ServiceLoader loader = ServiceLoader.load(org.constraynt.password.spi.DictionaryProvider.class);
+ for (org.constraynt.password.spi.DictionaryProvider provider : loader) {
+ Collection words = provider.words();
+ if (words != null) all.addAll(words);
+ }
+ if (all.isEmpty()) return null;
+
+ try {
+ return new WordListDictionary(WordLists.createFromReader(
+ all.stream().map(s -> new java.io.StringReader(s + "\n")).toArray(java.io.Reader[]::new),
+ false
+ ));
+ } catch (java.io.IOException e) {
+ // Fallback: no dictionary if we cannot build
+ return null;
+ }
+ }
+
+ private String mapDetailToMessageKey(RuleResultDetail d) {
+ String code = d.getErrorCode();
+ if ("TOO_SHORT".equals(code)) return "constraynt.password.minLength";
+ if ("TOO_LONG".equals(code)) return "constraynt.password.maxLength";
+ if ("INSUFFICIENT_UPPERCASE".equals(code)) return "constraynt.password.uppercase";
+ if ("INSUFFICIENT_LOWERCASE".equals(code)) return "constraynt.password.lowercase";
+ if ("INSUFFICIENT_DIGIT".equals(code)) return "constraynt.password.digit";
+ if ("INSUFFICIENT_SPECIAL".equals(code)) return "constraynt.password.special";
+ if ("ILLEGAL_WHITESPACE".equals(code)) return "constraynt.password.whitespace";
+ if ("ILLEGAL_WORD".equals(code)) return "constraynt.password.dictionary";
+ if ("ILLEGAL_MATCH".equals(code)) return "constraynt.password.blockedSubstring";
+ return "constraynt.password.invalid";
}
}
diff --git a/src/main/java/org/devhamzat/password/Password.java b/src/main/java/org/devhamzat/password/Password.java
index f62504c..75778bb 100644
--- a/src/main/java/org/devhamzat/password/Password.java
+++ b/src/main/java/org/devhamzat/password/Password.java
@@ -3,50 +3,95 @@
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.beans.factory.annotation.Autowired;
+import org.hibernate.validator.constraintvalidation.HibernateConstraintValidatorContext;
+
+import java.util.Arrays;
/**
- * Implementation of ConstraintValidator for password validation.
- * This class validates passwords annotated with {@link ValidatePassword} annotation.
- * It uses {@link DefaultConstrayntPasswordValidator} for the actual password validation logic.
+ * ConstraintValidator for @ValidatePassword using a pluggable ConstrayntPasswordValidator.
*/
public class Password implements ConstraintValidator {
- /**
- * The default password validator used for validating passwords.
- */
@Autowired
- private DefaultConstrayntPasswordValidator defaultConstrayntPasswordValidator;
-
- /**
- * Validates the given password.
- *
- * @param password The password to validate. Can be null.
- * @param context The constraint validator context.
- * @return true if the password is valid, false otherwise.
- */
+ private ConstrayntPasswordValidator validator;
+
+ private ValidatePassword annotation;
+
@Override
- public boolean isValid(String password, ConstraintValidatorContext context) {
- if (password == null) {
- addConstraintViolation(context, "Password cannot be null");
- return false;
+ public void initialize(ValidatePassword constraintAnnotation) {
+ this.annotation = constraintAnnotation;
+ }
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ // Presence handling according to `required` flag
+ if (value == null || value.isEmpty()) {
+ if (annotation.required()) {
+ // Emit presence-specific message and fail
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate("{constraynt.password.null}")
+ .addConstraintViolation();
+ return false;
+ } else {
+ // Not required: empty is allowed; skip further checks
+ return true;
+ }
}
- boolean isValid = defaultConstrayntPasswordValidator.validate(password);
- if (!isValid) {
- addConstraintViolation(context, String.join(", ", defaultConstrayntPasswordValidator.getErrorMessages()));
+ PasswordPolicy policy = new PasswordPolicy(
+ annotation.minLength(),
+ annotation.maxLength(),
+ annotation.requireUppercase(),
+ annotation.requireLowercase(),
+ annotation.requireDigit(),
+ annotation.requireSpecial(),
+ annotation.disallowWhitespace(),
+ Arrays.asList(annotation.blockedSubstrings()),
+ annotation.enableDefaultDictionary(),
+ annotation.isBlockedStringsEnabled(),
+ annotation.isDictionaryEnabled()
+ );
+
+ if (validator == null) {
+ throw new IllegalStateException("ConstrayntPasswordValidator bean is not initialized. Ensure Spring is configuring ConstraintValidator instances or provide a validator.");
+ }
+
+ PasswordValidationResult result = validator.validate(value, policy);
+ if (result.valid()) {
+ return true;
}
- return isValid;
- }
- /**
- * Adds a constraint violation to the context.
- *
- * @param context The constraint validator context.
- * @param message The error message to add.
- */
- private void addConstraintViolation(ConstraintValidatorContext context, String message) {
+ // Prepare to add message parameters for interpolation (min/max)
context.disableDefaultConstraintViolation();
- context.buildConstraintViolationWithTemplate(message)
- .addConstraintViolation();
+ HibernateConstraintValidatorContext hctx = null;
+ try {
+ hctx = context.unwrap(HibernateConstraintValidatorContext.class);
+ hctx = hctx.addMessageParameter("minLength", policy.minLength)
+ .addMessageParameter("maxLength", policy.maxLength);
+ } catch (Exception ignored) {
+ // Not running with Hibernate Validator; fall back without parameters
+ }
+
+ if (annotation.exposeAllViolations()) {
+ for (String code : result.messageCodes()) {
+ if (hctx != null) {
+ hctx.buildConstraintViolationWithTemplate("{" + code + "}")
+ .addConstraintViolation();
+ } else {
+ context.buildConstraintViolationWithTemplate("{" + code + "}")
+ .addConstraintViolation();
+ }
+ }
+ } else {
+ String first = result.messageCodes().isEmpty() ? "constraynt.password.invalid" : result.messageCodes().get(0);
+ if (hctx != null) {
+ hctx.buildConstraintViolationWithTemplate("{" + first + "}")
+ .addConstraintViolation();
+ } else {
+ context.buildConstraintViolationWithTemplate("{" + first + "}")
+ .addConstraintViolation();
+ }
+ }
+ return false;
}
}
\ No newline at end of file
diff --git a/src/main/java/org/devhamzat/password/PasswordPolicy.java b/src/main/java/org/devhamzat/password/PasswordPolicy.java
new file mode 100644
index 0000000..9bdd97a
--- /dev/null
+++ b/src/main/java/org/devhamzat/password/PasswordPolicy.java
@@ -0,0 +1,40 @@
+package org.constraynt.password;
+
+import java.util.List;
+
+public class PasswordPolicy {
+ public final int minLength;
+ public final int maxLength;
+ public final boolean requireUppercase;
+ public final boolean requireLowercase;
+ public final boolean requireDigit;
+ public final boolean requireSpecial;
+ public final boolean disallowWhitespace;
+ public final List blockedSubstrings;
+ public final boolean enableDefaultDictionary;
+ public final boolean isDictionaryEnabled;
+ public final boolean isBlockedStringsEnabled;
+
+ public PasswordPolicy(int minLength, int maxLength,
+ boolean requireUppercase, boolean requireLowercase,
+ boolean requireDigit, boolean requireSpecial,
+ boolean disallowWhitespace,
+ List blockedSubstrings,
+ boolean enableDefaultDictionary,
+ boolean isDictionaryEnabled,
+ boolean isBlockedStringsEnabled
+
+ ) {
+ this.minLength = minLength;
+ this.maxLength = maxLength;
+ this.requireUppercase = requireUppercase;
+ this.requireLowercase = requireLowercase;
+ this.requireDigit = requireDigit;
+ this.requireSpecial = requireSpecial;
+ this.disallowWhitespace = disallowWhitespace;
+ this.blockedSubstrings = blockedSubstrings;
+ this.enableDefaultDictionary = enableDefaultDictionary;
+ this.isDictionaryEnabled = isDictionaryEnabled;
+ this.isBlockedStringsEnabled = isBlockedStringsEnabled;
+ }
+}
diff --git a/src/main/java/org/devhamzat/password/PasswordValidationResult.java b/src/main/java/org/devhamzat/password/PasswordValidationResult.java
new file mode 100644
index 0000000..f9fda6d
--- /dev/null
+++ b/src/main/java/org/devhamzat/password/PasswordValidationResult.java
@@ -0,0 +1,24 @@
+package org.constraynt.password;
+
+import java.util.Collections;
+import java.util.List;
+
+public record PasswordValidationResult(boolean valid, List messageCodes) {
+ public PasswordValidationResult(boolean valid, List messageCodes) {
+ this.valid = valid;
+ this.messageCodes = messageCodes == null ? List.of() : List.copyOf(messageCodes);
+ }
+
+ @Override
+ public List messageCodes() {
+ return Collections.unmodifiableList(messageCodes);
+ }
+
+ public static PasswordValidationResult ok() {
+ return new PasswordValidationResult(true, List.of());
+ }
+
+ public static PasswordValidationResult fail(List messageCodes) {
+ return new PasswordValidationResult(false, messageCodes);
+ }
+}
diff --git a/src/main/java/org/devhamzat/password/PasswordValidatorConfig.java b/src/main/java/org/devhamzat/password/PasswordValidatorConfig.java
index 4ff9189..4326d9c 100644
--- a/src/main/java/org/devhamzat/password/PasswordValidatorConfig.java
+++ b/src/main/java/org/devhamzat/password/PasswordValidatorConfig.java
@@ -10,12 +10,10 @@
public class PasswordValidatorConfig {
/**
* Creates and configures the default ConstrayntPasswordValidator bean.
- *
- * @return A new instance of DefaultConstrayntPasswordValidator.
*/
@Bean
- @ConditionalOnMissingBean(DefaultConstrayntPasswordValidator.class)
- public DefaultConstrayntPasswordValidator defaultConstrayntPasswordValidator() {
+ @ConditionalOnMissingBean(ConstrayntPasswordValidator.class)
+ public ConstrayntPasswordValidator constrayntPasswordValidator() {
return new DefaultConstrayntPasswordValidator();
}
}
diff --git a/src/main/java/org/devhamzat/password/ValidatePassword.java b/src/main/java/org/devhamzat/password/ValidatePassword.java
index cd66c31..fce24fe 100644
--- a/src/main/java/org/devhamzat/password/ValidatePassword.java
+++ b/src/main/java/org/devhamzat/password/ValidatePassword.java
@@ -20,25 +20,36 @@
@Target({FIELD, ANNOTATION_TYPE})
@Retention(value = RUNTIME)
public @interface ValidatePassword {
- /**
- * Defines the error message to be used when the password validation fails.
- *
- * @return The error message.
- */
- String message() default "Invalid password";
-
- /**
- * Defines the validation groups to which this constraint belongs.
- *
- * @return The validation groups.
- */
+ // Use message key so consumers can localize
+ String message() default "{org.constraynt.password.ValidatePassword}";
Class>[] groups() default {};
- /**
- * Defines the payload associated with the constraint.
- *
- * @return The payload.
- */
Class extends Payload>[] payload() default {};
+ boolean required() default true;
+
+ int minLength() default 8;
+
+ int maxLength() default 64;
+
+ boolean requireUppercase() default true;
+
+ boolean requireLowercase() default true;
+
+ boolean requireDigit() default true;
+
+ boolean requireSpecial() default true;
+
+ boolean disallowWhitespace() default true;
+
+ boolean isBlockedStringsEnabled() default false;
+
+ String[] blockedSubstrings() default {"password", "qwerty", "123456", "letmein"};
+
+ boolean isDictionaryEnabled() default false;
+
+ boolean enableDefaultDictionary() default true;
+
+
+ boolean exposeAllViolations() default false;
}
diff --git a/src/main/java/org/devhamzat/password/spi/DictionaryProvider.java b/src/main/java/org/devhamzat/password/spi/DictionaryProvider.java
new file mode 100644
index 0000000..040d0f8
--- /dev/null
+++ b/src/main/java/org/devhamzat/password/spi/DictionaryProvider.java
@@ -0,0 +1,10 @@
+package org.constraynt.password.spi;
+
+import java.util.Collection;
+
+/**
+ * SPI for providing additional dictionary words used by the password validator.
+ */
+public interface DictionaryProvider {
+ Collection words();
+}
diff --git a/src/main/resources/ValidationMessages.properties b/src/main/resources/ValidationMessages.properties
index 8d69484..b69a6e6 100644
--- a/src/main/resources/ValidationMessages.properties
+++ b/src/main/resources/ValidationMessages.properties
@@ -1 +1,69 @@
-org.devhamzat.file.text.ValidateTXTFiles =Invalid TEXT file
\ No newline at end of file
+# Default validation messages for Constraynt constraints (English)
+# You can provide locale-specific overrides by adding files like ValidationMessages_en.properties, ValidationMessages_fr.properties, etc.
+
+# ================= Email =================
+# Default key for @ValidateEmail
+org.constraynt.email.ValidateEmail=Invalid email format
+# Detailed email violations
+constraynt.email.missingAt=Email must contain a single @ separating local and domain parts
+constraynt.email.local.invalid=Email local part is invalid
+constraynt.email.domain.invalid=Email domain is invalid
+constraynt.email.domain.tldNotAllowed=Top-level domains without a dot are not allowed
+constraynt.email.domain.ipNotAllowed=IP address domains are not allowed
+constraynt.email.domain.labelTooLong=Domain label exceeds 63 characters
+constraynt.email.domain.tooLong=Domain exceeds 255 characters
+
+# ================= Password =================
+# Default key for @ValidatePassword
+org.constraynt.password.ValidatePassword=Invalid password
+# Specific messages used by Password validator
+# Presence (note: required=true enforces non-empty before rule checks)
+constraynt.password.null=Password cannot be null
+
+# Length
+constraynt.password.minLength=Password is too short. Minimum length is {minLength} characters
+constraynt.password.maxLength=Password is too long. Maximum length is {maxLength} characters
+
+# Character classes
+constraynt.password.uppercase=Add at least one uppercase letter (A-Z)
+constraynt.password.lowercase=Add at least one lowercase letter (a-z)
+constraynt.password.digit=Add at least one digit (0-9)
+constraynt.password.special=Add at least one special character (e.g., !@#$)
+
+# Whitespace
+constraynt.password.whitespace=Password cannot contain whitespace
+
+# Blocked substrings / regex matches
+constraynt.password.blockedSubstring=Password contains a disallowed word or pattern
+
+# Dictionary (common or easily guessed)
+constraynt.password.dictionary=Password is too common or easily guessed
+
+# Fallback
+constraynt.password.invalid=Invalid password
+
+# ================= Generic File =================
+# Default key for @ValidateFile
+org.constraynt.file.fileType.ValidateFile=Invalid file type
+# Additional file-related messages with placeholders
+constraynt.file.disallowedType=File type not allowed. Allowed: {allowedTypes}
+constraynt.file.empty=File is empty
+
+# ================= Image File =================
+# Default key for @ValidateImage
+org.constraynt.file.image.ValidateImage=Invalid image file
+# Detailed image violations
+constraynt.image.required=Image file is required
+constraynt.image.tooLarge=Image exceeds maximum size of {maxSize} bytes
+constraynt.image.typeNotAllowed=Unsupported image content type. Allowed: {allowedContentTypes}
+constraynt.image.notReadable=Provided file is not a readable image
+constraynt.image.tooWide=Image width {width}px exceeds maximum {maxWidth}px
+constraynt.image.tooTall=Image height {height}px exceeds maximum {maxHeight}px
+
+# ================= Text File =================
+# Default key for @ValidateTXTFiles
+org.constraynt.file.text.ValidateTXTFiles=Invalid text file
+# Detailed text file violations
+constraynt.text.typeNotAllowed=Unsupported text file type. Allowed: {allowedTypes}
+constraynt.text.extensionNotAllowed=Unsupported text file extension. Allowed: {allowedExtensions}
+constraynt.text.tooLarge=Text file exceeds maximum size of {maxSize} bytes
\ No newline at end of file
diff --git a/src/main/resources/ValidationMessages_en.properties b/src/main/resources/ValidationMessages_en.properties
new file mode 100644
index 0000000..144e5a5
--- /dev/null
+++ b/src/main/resources/ValidationMessages_en.properties
@@ -0,0 +1,55 @@
+# English overrides for Constraynt validation messages
+# Typically identical to default. Provided to establish i18n structure.
+
+org.constraynt.email.ValidateEmail=Invalid email format
+# Detailed email violations
+constraynt.email.missingAt=Email must contain a single @ separating local and domain parts
+constraynt.email.local.invalid=Email local part is invalid
+constraynt.email.domain.invalid=Email domain is invalid
+constraynt.email.domain.tldNotAllowed=Top-level domains without a dot are not allowed
+constraynt.email.domain.ipNotAllowed=IP address domains are not allowed
+constraynt.email.domain.labelTooLong=Domain label exceeds 63 characters
+constraynt.email.domain.tooLong=Domain exceeds 255 characters
+
+org.constraynt.password.ValidatePassword=Invalid password
+# Presence
+constraynt.password.null=Password cannot be null
+
+# Length
+constraynt.password.minLength=Password is too short. Minimum length is {minLength} characters
+constraynt.password.maxLength=Password is too long. Maximum length is {maxLength} characters
+
+# Character classes
+constraynt.password.uppercase=Add at least one uppercase letter (A-Z)
+constraynt.password.lowercase=Add at least one lowercase letter (a-z)
+constraynt.password.digit=Add at least one digit (0-9)
+constraynt.password.special=Add at least one special character (e.g., !@#$)
+
+# Whitespace
+constraynt.password.whitespace=Password cannot contain whitespace
+
+# Blocked substrings / regex matches
+constraynt.password.blockedSubstring=Password contains a disallowed word or pattern
+
+# Dictionary
+constraynt.password.dictionary=Password is too common or easily guessed
+
+# Fallback
+constraynt.password.invalid=Invalid password
+
+org.constraynt.file.fileType.ValidateFile=Invalid file type
+constraynt.file.disallowedType=File type not allowed. Allowed: {allowedTypes}
+constraynt.file.empty=File is empty
+
+org.constraynt.file.image.ValidateImage=Invalid image file
+constraynt.image.required=Image file is required
+constraynt.image.tooLarge=Image exceeds maximum size of {maxSize} bytes
+constraynt.image.typeNotAllowed=Unsupported image content type. Allowed: {allowedContentTypes}
+constraynt.image.notReadable=Provided file is not a readable image
+constraynt.image.tooWide=Image width {width}px exceeds maximum {maxWidth}px
+constraynt.image.tooTall=Image height {height}px exceeds maximum {maxHeight}px
+
+org.constraynt.file.text.ValidateTXTFiles=Invalid text file
+constraynt.text.typeNotAllowed=Unsupported text file type. Allowed: {allowedTypes}
+constraynt.text.extensionNotAllowed=Unsupported text file extension. Allowed: {allowedExtensions}
+constraynt.text.tooLarge=Text file exceeds maximum size of {maxSize} bytes
diff --git a/src/test/java/org/constraynt/email/EmailValidatorTest.java b/src/test/java/org/constraynt/email/EmailValidatorTest.java
new file mode 100644
index 0000000..ee28a4f
--- /dev/null
+++ b/src/test/java/org/constraynt/email/EmailValidatorTest.java
@@ -0,0 +1,75 @@
+package org.constraynt.email;
+
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import jakarta.validation.ValidatorFactory;
+import jakarta.validation.ConstraintViolation;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class EmailValidatorTest {
+
+ private static ValidatorFactory factory;
+ private static Validator validator;
+
+ public static class Dto {
+ @ValidateEmail(allowTld = true, allowIpDomain = false, allowPlusSign = true)
+ public String email;
+ }
+
+ @BeforeAll
+ static void setup() {
+ factory = Validation.buildDefaultValidatorFactory();
+ validator = factory.getValidator();
+ }
+
+ @AfterAll
+ static void tearDown() {
+ if (factory != null) factory.close();
+ }
+
+ @Test
+ void nullAndEmpty_areValid() {
+ Dto dto = new Dto();
+ dto.email = null;
+ assertTrue(validator.validate(dto).isEmpty());
+ dto.email = "";
+ assertTrue(validator.validate(dto).isEmpty());
+ }
+
+ @Test
+ void validAscii_shouldPass() {
+ Dto dto = new Dto();
+ dto.email = "user.name+tag@example.com";
+ assertTrue(validator.validate(dto).isEmpty());
+ }
+
+ @Test
+ void missingAt_shouldFail() {
+ Dto dto = new Dto();
+ dto.email = "username.example.com";
+ Set> v = validator.validate(dto);
+ assertFalse(v.isEmpty());
+ assertTrue(v.iterator().next().getMessage().toLowerCase().contains("@"));
+ }
+
+ @Test
+ void tldRequired_whenDisabled_shouldFail() {
+ class DtoNoTld { @ValidateEmail(allowTld = false) public String email; }
+ DtoNoTld dto = new DtoNoTld();
+ dto.email = "user@localhost";
+ assertFalse(validator.validate(dto).isEmpty());
+ }
+
+ @Test
+ void ipDomain_notAllowed_shouldFail() {
+ Dto dto = new Dto();
+ dto.email = "user@[127.0.0.1]";
+ assertFalse(validator.validate(dto).isEmpty());
+ }
+}
diff --git a/src/test/java/org/constraynt/file/FileValidatorTest.java b/src/test/java/org/constraynt/file/FileValidatorTest.java
new file mode 100644
index 0000000..be907e9
--- /dev/null
+++ b/src/test/java/org/constraynt/file/FileValidatorTest.java
@@ -0,0 +1,58 @@
+package org.constraynt.file;
+
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import jakarta.validation.ValidatorFactory;
+import org.constraynt.file.fileType.ValidateFile;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockMultipartFile;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class FileValidatorTest {
+
+ private static ValidatorFactory factory;
+ private static Validator validator;
+
+ public static class Dto {
+ @ValidateFile(allowedTypes = {"txt", "pdf"})
+ public org.springframework.web.multipart.MultipartFile file;
+ }
+
+ @BeforeAll
+ static void setup() {
+ factory = Validation.buildDefaultValidatorFactory();
+ validator = factory.getValidator();
+ }
+
+ @AfterAll
+ static void tearDown() {
+ if (factory != null) factory.close();
+ }
+
+ @Test
+ void nullOrEmpty_isValid() {
+ Dto dto = new Dto();
+ dto.file = null;
+ assertTrue(validator.validate(dto).isEmpty());
+ dto.file = new MockMultipartFile("file", new byte[0]);
+ assertTrue(validator.validate(dto).isEmpty());
+ }
+
+ @Test
+ void allowedExtension_passes() {
+ Dto dto = new Dto();
+ dto.file = new MockMultipartFile("file", "readme.txt", "text/plain", "hi".getBytes());
+ assertTrue(validator.validate(dto).isEmpty());
+ }
+
+ @Test
+ void disallowedExtension_fails() {
+ Dto dto = new Dto();
+ dto.file = new MockMultipartFile("file", "image.png", "image/png", new byte[]{1,2});
+ assertFalse(validator.validate(dto).isEmpty());
+ }
+}