check-domain-setup-permission (#97)
Co-authored-by: Michael Hoennig <michael@hoennig.de> Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/97 Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
@@ -1,10 +1,59 @@
|
||||
package net.hostsharing.hsadminng.hs.booking.item.validators;
|
||||
|
||||
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
|
||||
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.REGISTRAR_LEVEL_DOMAINS;
|
||||
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
|
||||
|
||||
class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
|
||||
|
||||
public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}";
|
||||
public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
|
||||
public static final String VERIFICATION_CODE_PROPERTY_NAME = "verificationCode";
|
||||
|
||||
HsDomainSetupBookingItemValidator() {
|
||||
super(
|
||||
// no properties yet. maybe later, the setup code goes here?
|
||||
stringProperty(DOMAIN_NAME_PROPERTY_NAME).writeOnce()
|
||||
.maxLength(253)
|
||||
.matchesRegEx(FQDN_REGEX).describedAs("is not a (non-top-level) fully qualified domain name")
|
||||
.notMatchesRegEx(REGISTRAR_LEVEL_DOMAINS).describedAs("is a forbidden registrar-level domain name")
|
||||
.required(),
|
||||
stringProperty(VERIFICATION_CODE_PROPERTY_NAME)
|
||||
.readOnly().initializedBy(HsDomainSetupBookingItemValidator::generateVerificationCode)
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> validateEntity(final HsBookingItem bookingItem) {
|
||||
final var violations = new ArrayList<String>();
|
||||
final var domainName = bookingItem.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
|
||||
if (!bookingItem.isLoaded() &&
|
||||
domainName.matches("hostsharing.(com|net|org|coop|de)")) {
|
||||
violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName
|
||||
+ "' is a forbidden Hostsharing domain name");
|
||||
}
|
||||
violations.addAll(super.validateEntity(bookingItem));
|
||||
return violations;
|
||||
}
|
||||
|
||||
private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) {
|
||||
final var alphaNumeric = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
final var secureRandom = new SecureRandom();
|
||||
final var sb = new StringBuilder();
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
if ( i > 0 && i % 4 == 0 ) {
|
||||
sb.append("-");
|
||||
}
|
||||
sb.append(alphaNumeric.charAt(secureRandom.nextInt(alphaNumeric.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -0,0 +1,134 @@
|
||||
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||
|
||||
import net.hostsharing.hsadminng.mapper.Array;
|
||||
import org.apache.commons.collections4.EnumerationUtils;
|
||||
|
||||
import javax.naming.InvalidNameException;
|
||||
import javax.naming.NameNotFoundException;
|
||||
import javax.naming.NamingEnumeration;
|
||||
import javax.naming.NamingException;
|
||||
import javax.naming.ServiceUnavailableException;
|
||||
import javax.naming.directory.Attribute;
|
||||
import javax.naming.directory.InitialDirContext;
|
||||
import java.util.HashMap;
|
||||
import java.util.Hashtable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static java.util.Arrays.stream;
|
||||
import static java.util.Collections.emptyList;
|
||||
|
||||
public class Dns {
|
||||
|
||||
public static final String[] REGISTRAR_LEVEL_DOMAINS = Array.of(
|
||||
"[^.]+", // top-level-domains
|
||||
"(co|org|gov|ac|sch)\\.uk",
|
||||
"(com|net|org|edu|gov|asn|id)\\.au",
|
||||
"(co|ne|or|ac|go)\\.jp",
|
||||
"(com|net|org|gov|edu|ac)\\.cn",
|
||||
"(com|net|org|gov|edu|mil|art)\\.br",
|
||||
"(co|net|org|gen|firm|ind)\\.in",
|
||||
"(com|net|org|gob|edu)\\.mx",
|
||||
"(gov|edu)\\.it",
|
||||
"(co|net|org|govt|ac|school|geek|kiwi)\\.nz",
|
||||
"(co|ne|or|go|re|pe)\\.kr"
|
||||
);
|
||||
public static final Pattern[] REGISTRAR_LEVEL_DOMAIN_PATTERN = stream(REGISTRAR_LEVEL_DOMAINS)
|
||||
.map(Pattern::compile)
|
||||
.toArray(Pattern[]::new);
|
||||
|
||||
private final static Map<String, Result> fakeResults = new HashMap<>();
|
||||
|
||||
public static Optional<String> superDomain(final String domainName) {
|
||||
final var parts = domainName.split("\\.", 2);
|
||||
if (parts.length == 2) {
|
||||
return Optional.of(parts[1]);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static boolean isRegistrarLevelDomain(final String domainName) {
|
||||
return stream(REGISTRAR_LEVEL_DOMAIN_PATTERN)
|
||||
.anyMatch(p -> p.matcher(domainName).matches());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param domainName a fully qualified domain name
|
||||
* @return true if `domainName` can be registered at a registrar, false if it's a subdomain of such or a registrar-level domain itself
|
||||
*/
|
||||
public static boolean isRegistrableDomain(final String domainName) {
|
||||
return !isRegistrarLevelDomain(domainName) &&
|
||||
superDomain(domainName).map(Dns::isRegistrarLevelDomain).orElse(false);
|
||||
}
|
||||
|
||||
public static void fakeResultForDomain(final String domainName, final Result fakeResult) {
|
||||
fakeResults.put(domainName, fakeResult);
|
||||
}
|
||||
|
||||
public static void resetFakeResults() {
|
||||
fakeResults.clear();
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
SUCCESS,
|
||||
NAME_NOT_FOUND,
|
||||
INVALID_NAME,
|
||||
SERVICE_UNAVAILABLE,
|
||||
UNKNOWN_FAILURE
|
||||
}
|
||||
|
||||
public record Result(Status status, List<String> records, NamingException exception) {
|
||||
|
||||
|
||||
public static Result fromRecords(final NamingEnumeration<?> recordEnumeration) {
|
||||
final List<String> records = recordEnumeration == null
|
||||
? emptyList()
|
||||
: EnumerationUtils.toList(recordEnumeration).stream().map(Object::toString).toList();
|
||||
return new Result(Status.SUCCESS, records, null);
|
||||
}
|
||||
|
||||
public static Result fromRecords(final String... records) {
|
||||
return new Result(Status.SUCCESS, stream(records).toList(), null);
|
||||
}
|
||||
|
||||
public static Result fromException(final NamingException exception) {
|
||||
return switch (exception) {
|
||||
case ServiceUnavailableException exc -> new Result(Status.SERVICE_UNAVAILABLE, emptyList(), exc);
|
||||
case NameNotFoundException exc -> new Result(Status.NAME_NOT_FOUND, emptyList(), exc);
|
||||
case InvalidNameException exc -> new Result(Status.INVALID_NAME, emptyList(), exc);
|
||||
case NamingException exc -> new Result(Status.UNKNOWN_FAILURE, emptyList(), exc);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private final String domainName;
|
||||
|
||||
public Dns(final String domainName) {
|
||||
this.domainName = domainName;
|
||||
}
|
||||
|
||||
public Result fetchRecordsOfType(final String recordType) {
|
||||
if (fakeResults.containsKey(domainName)) {
|
||||
return fakeResults.get(domainName);
|
||||
}
|
||||
|
||||
try {
|
||||
final var env = new Hashtable<>();
|
||||
env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory");
|
||||
final Attribute records = new InitialDirContext(env)
|
||||
.getAttributes(domainName, new String[] { recordType })
|
||||
.get(recordType);
|
||||
return Result.fromRecords(records != null ? records.getAll() : null);
|
||||
} catch (final NamingException exception) {
|
||||
return Result.fromException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
final var result = new Dns("example.org").fetchRecordsOfType("TXT");
|
||||
System.out.println(result);
|
||||
}
|
||||
|
||||
}
|
@@ -3,55 +3,104 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP;
|
||||
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.superDomain;
|
||||
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainHttpSetupHostingAssetValidator.SUBDOMAIN_NAME_REGEX;
|
||||
|
||||
class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
|
||||
|
||||
public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}";
|
||||
|
||||
private final Pattern identifierPattern;
|
||||
public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
|
||||
|
||||
HsDomainSetupHostingAssetValidator() {
|
||||
super( DOMAIN_SETUP,
|
||||
super(
|
||||
DOMAIN_SETUP,
|
||||
AlarmContact.isOptional(),
|
||||
|
||||
NO_EXTRA_PROPERTIES);
|
||||
this.identifierPattern = Pattern.compile(FQDN_REGEX);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> validateEntity(final HsHostingAsset assetEntity) {
|
||||
// TODO.impl: for newly created entities, check the permission of setting up a domain
|
||||
//
|
||||
// reject, if the domain is any of these:
|
||||
// hostsharing.com|net|org|coop, // just to be on the safe side
|
||||
// [^.}+, // top-level-domain
|
||||
// co.uk, org.uk, gov.uk, ac.uk, sch.uk,
|
||||
// com.au, net.au, org.au, edu.au, gov.au, asn.au, id.au,
|
||||
// co.jp, ne.jp, or.jp, ac.jp, go.jp,
|
||||
// com.cn, net.cn, org.cn, gov.cn, edu.cn, ac.cn,
|
||||
// com.br, net.br, org.br, gov.br, edu.br, mil.br, art.br,
|
||||
// co.in, net.in, org.in, gen.in, firm.in, ind.in,
|
||||
// com.mx, net.mx, org.mx, gob.mx, edu.mx,
|
||||
// gov.it, edu.it,
|
||||
// co.nz, net.nz, org.nz, govt.nz, ac.nz, school.nz, geek.nz, kiwi.nz,
|
||||
// co.kr, ne.kr, or.kr, go.kr, re.kr, pe.kr
|
||||
//
|
||||
// allow if
|
||||
// - user has Admin/Agent-role for all its sub-domains and the direct parent-Domain which are set up at at Hostsharing
|
||||
// - domain has DNS zone with TXT record approval
|
||||
// - parent-domain has DNS zone with TXT record approval
|
||||
//
|
||||
// TXT-Record check:
|
||||
// new InitialDirContext().getAttributes("dns:_netblocks.google.com", new String[] { "TXT"}).get("TXT").getAll();
|
||||
final var violations = // new ArrayList<String>();
|
||||
super.validateEntity(assetEntity);
|
||||
if (!violations.isEmpty()) {
|
||||
return violations;
|
||||
}
|
||||
|
||||
return super.validateEntity(assetEntity);
|
||||
final var domainName = assetEntity.getIdentifier();
|
||||
final var dnsResult = new Dns(domainName).fetchRecordsOfType("TXT");
|
||||
final Supplier<String> getCode = () -> assetEntity.getBookingItem().getDirectValue("verificationCode", String.class);
|
||||
switch (dnsResult.status()) {
|
||||
case Dns.Status.SUCCESS: {
|
||||
final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + getCode.get();
|
||||
final var verificationFound = findTxtRecord(dnsResult, expectedTxtRecordValue)
|
||||
.or(() -> superDomain(domainName)
|
||||
.flatMap(superDomainName -> findTxtRecord(
|
||||
new Dns(superDomainName).fetchRecordsOfType("TXT"),
|
||||
expectedTxtRecordValue))
|
||||
);
|
||||
if (verificationFound.isEmpty()) {
|
||||
violations.add(
|
||||
"[DNS] no TXT record '" + expectedTxtRecordValue +
|
||||
"' found for domain name '" + domainName + "' (nor in its super-domain)");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Dns.Status.NAME_NOT_FOUND: {
|
||||
if (isDnsVerificationRequiredForUnregisteredDomain(assetEntity)) {
|
||||
final var superDomain = superDomain(domainName);
|
||||
final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + getCode.get();
|
||||
final var verificationFoundInSuperDomain = superDomain.flatMap(superDomainName -> findTxtRecord(
|
||||
new Dns(superDomainName).fetchRecordsOfType("TXT"),
|
||||
expectedTxtRecordValue));
|
||||
if (verificationFoundInSuperDomain.isEmpty()) {
|
||||
violations.add(
|
||||
"[DNS] no TXT record '" + expectedTxtRecordValue +
|
||||
"' found for domain name '" + superDomain.orElseThrow() + "'");
|
||||
}
|
||||
}
|
||||
// otherwise no DNS verification to be able to setup DNS for domains to register
|
||||
break;
|
||||
}
|
||||
|
||||
case Dns.Status.INVALID_NAME:
|
||||
violations.add("[DNS] invalid domain name '" + assetEntity.getIdentifier() + "'");
|
||||
break;
|
||||
|
||||
case Dns.Status.SERVICE_UNAVAILABLE:
|
||||
case Dns.Status.UNKNOWN_FAILURE:
|
||||
violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception());
|
||||
break;
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
|
||||
return identifierPattern;
|
||||
if (assetEntity.getBookingItem() != null) {
|
||||
final var bookingItemDomainName = assetEntity.getBookingItem()
|
||||
.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
|
||||
return Pattern.compile(bookingItemDomainName, Pattern.CASE_INSENSITIVE | Pattern.LITERAL);
|
||||
}
|
||||
final var parentDomainName = assetEntity.getParentAsset().getIdentifier();
|
||||
return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
private static boolean isDnsVerificationRequiredForUnregisteredDomain(final HsHostingAsset assetEntity) {
|
||||
return !Dns.isRegistrableDomain(assetEntity.getIdentifier())
|
||||
&& assetEntity.getParentAsset() == null;
|
||||
}
|
||||
|
||||
|
||||
private static Optional<String> findTxtRecord(final Dns.Result result, final String expectedTxtRecordValue) {
|
||||
return result.records().stream()
|
||||
.filter(r -> r.contains(expectedTxtRecordValue))
|
||||
.findAny();
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,12 @@
|
||||
package net.hostsharing.hsadminng.hs.validation;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Setter;
|
||||
import net.hostsharing.hsadminng.mapper.Array;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -15,11 +17,19 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
|
||||
|
||||
protected static final String[] KEY_ORDER = Array.join(
|
||||
ValidatableProperty.KEY_ORDER_HEAD,
|
||||
Array.of("matchesRegEx", "minLength", "maxLength", "provided"),
|
||||
Array.of("matchesRegEx", "matchesRegExDescription",
|
||||
"notMatchesRegEx", "notMatchesRegExDescription",
|
||||
"minLength", "maxLength",
|
||||
"provided"),
|
||||
ValidatableProperty.KEY_ORDER_TAIL,
|
||||
Array.of("undisclosed"));
|
||||
private String[] provided;
|
||||
private Pattern[] matchesRegEx;
|
||||
private String matchesRegExDescription;
|
||||
private Pattern[] notMatchesRegEx;
|
||||
private String notMatchesRegExDescription;
|
||||
@Setter(AccessLevel.PRIVATE)
|
||||
private Consumer<String> describedAsConsumer;
|
||||
private Integer minLength;
|
||||
private Integer maxLength;
|
||||
private boolean undisclosed;
|
||||
@@ -56,10 +66,23 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
|
||||
|
||||
public P matchesRegEx(final String... regExPattern) {
|
||||
this.matchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new);
|
||||
this.describedAsConsumer = violationMessage -> matchesRegExDescription = violationMessage;
|
||||
return self();
|
||||
}
|
||||
|
||||
/// predifined values, similar to fixed values in a combobox
|
||||
public P notMatchesRegEx(final String... regExPattern) {
|
||||
this.notMatchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new);
|
||||
this.describedAsConsumer = violationMessage -> notMatchesRegExDescription = violationMessage;
|
||||
return self();
|
||||
}
|
||||
|
||||
public P describedAs(final String violationMessage) {
|
||||
describedAsConsumer.accept(violationMessage);
|
||||
describedAsConsumer = null;
|
||||
return self();
|
||||
}
|
||||
|
||||
/// predefined values, similar to fixed values in a combobox
|
||||
public P provided(final String... provided) {
|
||||
this.provided = provided;
|
||||
return self();
|
||||
@@ -78,16 +101,10 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
|
||||
@Override
|
||||
protected void validate(final List<String> result, final String propValue, final PropertiesProvider propProvider) {
|
||||
super.validate(result, propValue, propProvider);
|
||||
if (minLength != null && propValue.length()<minLength) {
|
||||
result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length());
|
||||
}
|
||||
if (maxLength != null && propValue.length()>maxLength) {
|
||||
result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length());
|
||||
}
|
||||
if (matchesRegEx != null &&
|
||||
stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) {
|
||||
result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) + " but " + display(propValue) + " does not match" + (matchesRegEx.length>1?" any":""));
|
||||
}
|
||||
validateMinLength(result, propValue);
|
||||
validateMaxLength(result, propValue);
|
||||
validateMatchesRegEx(result, propValue);
|
||||
validateNotMatchesRegEx(result, propValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -99,4 +116,47 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
|
||||
protected String simpleTypeName() {
|
||||
return "string";
|
||||
}
|
||||
|
||||
private void validateMinLength(final List<String> result, final String propValue) {
|
||||
if (minLength != null && propValue.length()<minLength) {
|
||||
result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMaxLength(final List<String> result, final String propValue) {
|
||||
if (maxLength != null && propValue.length()>maxLength) {
|
||||
result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMatchesRegEx(final List<String> result, final String propValue) {
|
||||
if (matchesRegEx != null &&
|
||||
stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) {
|
||||
if (matchesRegExDescription != null) {
|
||||
result.add(propertyName + "' = " + display(propValue) + " " + matchesRegExDescription);
|
||||
} else if (matchesRegEx.length>1) {
|
||||
result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) +
|
||||
" but " + display(propValue) + " does not match any");
|
||||
} else {
|
||||
result.add(propertyName + "' is expected to match " + Arrays.toString(matchesRegEx) + " but " + display(
|
||||
propValue) +
|
||||
" does not match");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateNotMatchesRegEx(final List<String> result, final String propValue) {
|
||||
if (notMatchesRegEx != null &&
|
||||
stream(notMatchesRegEx).map(p -> p.matcher(propValue)).anyMatch(Matcher::matches)) {
|
||||
if (notMatchesRegExDescription != null) {
|
||||
result.add(propertyName + "' = " + display(propValue) + " " + notMatchesRegExDescription);
|
||||
} else if (notMatchesRegEx.length>1) {
|
||||
result.add(propertyName + "' is expected not to match any of " + Arrays.toString(notMatchesRegEx) +
|
||||
" but " + display(propValue) + " does match at least one");
|
||||
} else {
|
||||
result.add(propertyName + "' is expected not to match " + Arrays.toString(notMatchesRegEx) +
|
||||
" but " + display(propValue) + " does match");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user