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[] 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[] 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()); + } +}