Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
<modelVersion>4.0.0</modelVersion>


<groupId>org.devhamzat</groupId>
<groupId>org.constraynt</groupId>
<artifactId>constraynt</artifactId>
<version>0.0.1-SNAPSHOT</version>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<name>Constraynt</name>
Expand All @@ -24,8 +24,8 @@

<developers>
<developer>
<id>yourFriendlyNeighbourhoodDev</id>
<name>Dev</name>
<id>yourFriendlyNeighbourhoodBatman</id>
<name>BATMAN</name>
<roles>
<role>developer</role>
<role>package manager</role>
Expand Down Expand Up @@ -73,17 +73,17 @@
<artifactId>passay</artifactId>
<version>1.6.4</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>6.1.11</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.0.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
Expand Down
158 changes: 135 additions & 23 deletions src/main/java/org/devhamzat/email/Email.java
Original file line number Diff line number Diff line change
@@ -1,53 +1,165 @@
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;

import static java.util.regex.Pattern.CASE_INSENSITIVE;

public class Email implements ConstraintValidator<ValidateEmail, CharSequence> {
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])?)$");
}
10 changes: 9 additions & 1 deletion src/main/java/org/devhamzat/email/ValidateEmail.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

}
Original file line number Diff line number Diff line change
@@ -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<String> getErrorMessages();

PasswordValidationResult validate(String rawPassword, PasswordPolicy policy);
}
Loading