diff --git a/docs/customization.md b/docs/customization.md index d8d6767f..48910b67 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -1,5 +1,50 @@ # Customize your instance +This section contains instruction to customize your RDP registry. + +## Allowed email providers (new in 1.5.8) + +You may restrict which email providers can be used for creating new accounts by specifying a list or a file +containing allowed domains. Matches are performed in a case-insensitive manner. Only printable ASCII characters are +allowed. + +```ini +rdp.settings.allowed-email-domains=example.com +rdp.settings.allowed-email-domains-file=file:swot.txt +rdp.settings.allowed-email-domains-file-refresh-delay=3600 +``` + +The default refresh delay is set to one hour. Disable it by setting it to an empty value. A value of `0` will cause a +refresh on every validation. + +This feature is disabled by default. + +### Internationalized domain names + +To use [internationalized domain names](https://en.wikipedia.org/wiki/Internationalized_domain_name) in email addresses, +add their Punycode to the list or file and set the following setting: + +```ini +rdp.settings.allow-internationalized-email-domains=true +``` + +For example, to allow users from `universität.example.com` to register, add `xn--universitt-y5a.example.com` to the +file. + +## reCAPTCHA (new in 1.5.8) + +RDP supports [reCAPTCHA v2](https://www.google.com/recaptcha/about/) to mitigate the registration of accounts by bots. +To enable it, add the reCAPTCHA token and secret to your configuration. + +```ini +rdp.site.recaptcha-token=mytoken +rdp.site.recaptcha-secret=mysecret +``` + +This feature is disabled by default. + +## Cached data + Most of the data used by the application is retrieved remotely at startup and subsequently updated on a monthly basis. To prevent data from being loaded on startup and/or recurrently, set the following parameter in @@ -12,6 +57,8 @@ rdp.settings.cache.enabled=false You should deploy your RDP instance at least once to have initial data before setting this property and whenever you update the software. +The following sections will cover in details individual data sources that can be imported in your registry. + ## Gene information and GO terms By default, RDP will retrieve the latest gene information from NCBI, and GO terms @@ -271,19 +318,20 @@ The page lists some basic stats at the very top and provides few action buttons: ![Actions available for simple categories.](images/simple-category-actions.png) -- "Deactivate" (or "Deactivate All Terms" in the case of an ontology category): this will remove the category from the Profile and Search pages. This action is reversible, as the category can be easily re-activated. This action is recommended in cases where a category cannot be deleted because it has already been used by some users. +- "Deactivate" (or "Deactivate All Terms" in the case of an ontology category): this will remove the category from the + Profile and Search pages. This action is reversible, as the category can be easily re-activated. This action is + recommended in cases where a category cannot be deleted because it has already been used by some users. - Update from "source": Update the ontology category using the original URL (if available) - Download as OBO: Download the category as an OBO file - - The number of used terms indicate how many terms in the ontology have been associated with associated with users. In the Edit window on the Manage Profile Category page, you can add a definition/description of the category, which is used in a tooltip on the Profile Page. You can also specify if this category will be used as a filter on the Gene -Search page. While all active categories will be available on the Researcher Search page, only categories that have "Available for gene search?" checked will be displayed on the Gene Search page. +Search page. While all active categories will be available on the Researcher Search page, only categories that have " +Available for gene search?" checked will be displayed on the Gene Search page. ![Interface for editing the properties of an ontology.](images/edit-an-ontology.png) @@ -348,8 +396,6 @@ values. A warning will be displayed in the admin section if this is the case. Read more about configuring messages in [Customizing the application messages](#customizing-the-applications-messages) section of this page. - - ### Resolving external URLs By default, ontologies and terms are resolved from [OLS](https://www.ebi.ac.uk/ols/index). Reactome pathways get a @@ -402,7 +448,6 @@ settings will retrieve all the necessary files relative to the working directory #this setting relates only to gene info files. Files for all taxons will be stord under gene/ rdp.settings.cache.load-from-disk=true rdp.settings.cache.gene-files-location=file:genes/ - #file for GO ontology rdp.settings.cache.term-file=file:go.obo #file for gene GO annotation @@ -537,7 +582,8 @@ rdp.faq.questions.=A relevant question. rdp.faq.answers.=A plausible answer. ``` -The provided default file can be found in [faq.properties](https://github.com/PavlidisLab/rdp/tree/{{ config.extra.git_ref }}/src/main/resources/faq.properties). +The provided default file can be found in [faq.properties](https://github.com/PavlidisLab/rdp/tree/{{ +config.extra.git_ref }}/src/main/resources/faq.properties). ### Ordering FAQ entries diff --git a/src/main/java/ubc/pavlab/rdp/ValidationConfig.java b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java new file mode 100644 index 00000000..034cfdd1 --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java @@ -0,0 +1,75 @@ +package ubc.pavlab.rdp; + +import lombok.extern.apachecommons.CommonsLog; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.convert.DurationUnit; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.web.client.RestTemplate; +import ubc.pavlab.rdp.validation.*; + +import java.io.IOException; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * This configuration provides a few {@link org.springframework.validation.Validator} beans. + */ +@CommonsLog +@Configuration +public class ValidationConfig { + + @Bean + public EmailValidator emailValidator( + @Value("${rdp.settings.allowed-email-domains}") List allowedEmailDomains, + @Value("${rdp.settings.allowed-email-domains-file}") Resource allowedEmailDomainsFile, + @Value("${rdp.settings.allowed-email-domains-file-refresh-delay}") @DurationUnit(ChronoUnit.SECONDS) Duration refreshDelay, + @Value("${rdp.settings.allow-internationalized-email-domains}") boolean allowIdn ) throws IOException { + List strategies = new ArrayList<>(); + if ( allowedEmailDomains != null && !allowedEmailDomains.isEmpty() ) { + SetBasedAllowedDomainStrategy strategy = new SetBasedAllowedDomainStrategy( allowedEmailDomains ); + strategies.add( strategy ); + log.info( String.format( "Email validation is configured to accept addresses from: %s.", String.join( ", ", + strategy.getAllowedDomains() ) ) ); + } + if ( allowedEmailDomainsFile != null ) { + log.info( "Reading allowed email domains from " + allowedEmailDomainsFile + "..." ); + if ( refreshDelay.isZero() ) { + log.warn( "The refresh delay for reading " + allowedEmailDomainsFile + " is set to zero: the file will be re-read for every email domain validation." ); + } + ResourceBasedAllowedDomainStrategy strategy = new ResourceBasedAllowedDomainStrategy( allowedEmailDomainsFile, refreshDelay ); + strategy.refresh(); + Set allowedDomains = strategy.getAllowedDomains(); + strategies.add( strategy ); + if ( strategy.getAllowedDomains().size() <= 5 ) { + log.info( String.format( "Email validation is configured to accept addresses from: %s.", String.join( ", ", allowedDomains ) ) ); + } else { + log.info( String.format( "Email validation is configured to accept addresses from a list of %d domains.", allowedDomains.size() ) ); + } + } + AllowedDomainStrategy strategy; + if ( strategies.isEmpty() ) { + strategy = ( domain ) -> true; + log.warn( "No allowed email domains file specified, all domains will be allowed for newly registered users." ); + } else if ( strategies.size() == 1 ) { + strategy = strategies.iterator().next(); + } else { + strategy = domain -> strategies.stream().anyMatch( s -> s.allows( domain ) ); + } + return new EmailValidator( strategy, allowIdn ); + } + + @Bean + @ConditionalOnProperty("rdp.site.recaptcha-secret") + public RecaptchaValidator recaptchaValidator( @Value("${rdp.site.recaptcha-secret}") String secret ) { + RestTemplate rt = new RestTemplate(); + rt.getMessageConverters().add( new FormHttpMessageConverter() ); + return new RecaptchaValidator( rt, secret ); + } +} diff --git a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java index 0e498dd8..7cb7cc22 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java @@ -2,18 +2,16 @@ import lombok.extern.apachecommons.CommonsLog; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.MessageSource; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; -import org.springframework.validation.BindingResult; +import org.springframework.validation.*; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.WebDataBinder; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.InitBinder; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import ubc.pavlab.rdp.exception.TokenException; @@ -24,9 +22,14 @@ import ubc.pavlab.rdp.services.PrivacyService; import ubc.pavlab.rdp.services.UserService; import ubc.pavlab.rdp.settings.ApplicationSettings; +import ubc.pavlab.rdp.validation.EmailValidator; +import ubc.pavlab.rdp.validation.Recaptcha; +import ubc.pavlab.rdp.validation.RecaptchaValidator; import javax.servlet.http.HttpServletRequest; +import java.util.List; import java.util.Locale; +import java.util.stream.Collectors; /** * Created by mjacobson on 16/01/18. @@ -44,9 +47,43 @@ public class LoginController { @Autowired private ApplicationSettings applicationSettings; + @Autowired + private EmailValidator emailValidator; + + @Autowired(required = false) + private RecaptchaValidator recaptchaValidator; + + @Autowired + private MessageSource messageSource; + + /** + * Wraps a {@link EmailValidator} so that it can be applied to the {@code user.email} nested path. + */ + private class UserEmailValidator implements Validator { + + @Override + public boolean supports( Class clazz ) { + return User.class.isAssignableFrom( clazz ); + } + + @Override + public void validate( Object target, Errors errors ) { + User user = (User) target; + if ( user.getEmail() != null ) { + try { + errors.pushNestedPath( "email" ); + ValidationUtils.invokeValidator( emailValidator, user.getEmail(), errors ); + } finally { + errors.popNestedPath(); + } + } + } + } + @InitBinder("user") public void configureUserDataBinder( WebDataBinder dataBinder ) { dataBinder.setAllowedFields( "email", "password", "profile.name", "profile.lastName" ); + dataBinder.addValidators( new UserEmailValidator() ); } @GetMapping("/login") @@ -73,9 +110,24 @@ public ModelAndView registration() { @PostMapping("/registration") public ModelAndView createNewUser( @Validated(User.ValidationUserAccount.class) User user, BindingResult bindingResult, + @RequestParam(name = "g-recaptcha-response", required = false) String recaptchaResponse, + @RequestHeader(name = "X-Forwarded-For", required = false) List clientIp, RedirectAttributes redirectAttributes, Locale locale ) { ModelAndView modelAndView = new ModelAndView( "registration" ); + + if ( recaptchaValidator != null ) { + Recaptcha recaptcha = new Recaptcha( recaptchaResponse, clientIp != null ? clientIp.iterator().next() : null ); + BindingResult recaptchaBindingResult = new BeanPropertyBindingResult( recaptcha, "recaptcha" ); + recaptchaValidator.validate( recaptcha, recaptchaBindingResult ); + if ( recaptchaBindingResult.hasErrors() ) { + modelAndView.setStatus( HttpStatus.BAD_REQUEST ); + modelAndView.addObject( "message", recaptchaBindingResult.getAllErrors().stream().map( oe -> messageSource.getMessage( oe, locale ) ).collect( Collectors.joining( "
" ) ) ); + modelAndView.addObject( "error", Boolean.TRUE ); + return modelAndView; + } + } + User existingUser = userService.findUserByEmailNoAuth( user.getEmail() ); // profile can be missing of no profile.* fields have been set @@ -87,6 +139,9 @@ public ModelAndView createNewUser( @Validated(User.ValidationUserAccount.class) // initialize a basic user profile Profile userProfile = user.getProfile(); + if ( userProfile == null ) { + userProfile = new Profile(); + } userProfile.setPrivacyLevel( privacyService.getDefaultPrivacyLevel() ); userProfile.setShared( applicationSettings.getPrivacy().isDefaultSharing() ); userProfile.setHideGenelist( false ); @@ -105,6 +160,22 @@ public ModelAndView createNewUser( @Validated(User.ValidationUserAccount.class) if ( bindingResult.hasErrors() ) { modelAndView.setStatus( HttpStatus.BAD_REQUEST ); + // indicate to the mode + boolean isDomainNotAllowed = bindingResult.getFieldErrors( "email" ).stream() + .map( FieldError::getCode ) + .anyMatch( "EmailValidator.domainNotAllowed"::equals ); + modelAndView.addObject( "domainNotAllowed", isDomainNotAllowed ); + if ( isDomainNotAllowed ) { + // this code is not set if the email is not minimally valid, so we can safely parse it + String domain = user.getEmail().split( "@", 2 )[1]; + modelAndView.addObject( "domainNotAllowedFrom", user.getEmail() ); + modelAndView.addObject( "domainNotAllowedSubject", + messageSource.getMessage( "LoginController.domainNotAllowedSubject", + new String[]{ domain }, locale ) ); + modelAndView.addObject( "domainNotAllowedBody", + messageSource.getMessage( "LoginController.domainNotAllowedBody", + new String[]{ user.getEmail(), domain, user.getProfile().getFullName() }, locale ) ); + } } else { user = userService.create( user ); userService.createVerificationTokenForUser( user, locale ); diff --git a/src/main/java/ubc/pavlab/rdp/model/User.java b/src/main/java/ubc/pavlab/rdp/model/User.java index eb1fa50b..ea6ec23c 100644 --- a/src/main/java/ubc/pavlab/rdp/model/User.java +++ b/src/main/java/ubc/pavlab/rdp/model/User.java @@ -4,17 +4,13 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonUnwrapped; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.media.SchemaProperty; import lombok.*; import lombok.extern.apachecommons.CommonsLog; import org.hibernate.annotations.CacheConcurrencyStrategy; import org.hibernate.annotations.NaturalId; -import org.hibernate.validator.constraints.Email; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import org.springframework.util.StringUtils; import ubc.pavlab.rdp.model.enums.PrivacyLevelType; import ubc.pavlab.rdp.model.enums.TierType; import ubc.pavlab.rdp.model.ontology.Ontology; @@ -89,7 +85,6 @@ public static Comparator getComparator() { @NaturalId @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) @Column(name = "email", unique = true, nullable = false) - @Email(message = "Your email address is not valid.", groups = { ValidationUserAccount.class }) @NotNull(message = "Please provide an email address.", groups = { ValidationUserAccount.class, ValidationServiceAccount.class }) @Size(min = 1, message = "Please provide an email address.", groups = { ValidationUserAccount.class, ValidationServiceAccount.class }) private String email; @@ -144,6 +139,7 @@ public static Comparator getComparator() { private final Set passwordResetTokens = new HashSet<>(); @Valid + @NotNull @Embedded @JsonUnwrapped private Profile profile; diff --git a/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java b/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java index 630379b0..4b2293cb 100644 --- a/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java +++ b/src/main/java/ubc/pavlab/rdp/settings/ApplicationSettings.java @@ -6,18 +6,17 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.validation.annotation.Validated; -import ubc.pavlab.rdp.ontology.resolvers.OntologyResolver; import ubc.pavlab.rdp.model.GeneInfo; import ubc.pavlab.rdp.model.enums.PrivacyLevelType; import ubc.pavlab.rdp.model.enums.ResearcherCategory; import ubc.pavlab.rdp.model.enums.ResearcherPosition; import ubc.pavlab.rdp.model.enums.TierType; import ubc.pavlab.rdp.model.ontology.Ontology; +import ubc.pavlab.rdp.ontology.resolvers.OntologyResolver; import ubc.pavlab.rdp.services.GeneInfoService; import javax.validation.constraints.Max; import javax.validation.constraints.Min; -import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Size; import java.net.URI; import java.time.Duration; @@ -273,4 +272,26 @@ public static class OntologySettings { * Enabled tier types. */ public EnumSet enabledTiers; + /** + * List of allowed email domains for registering users. + *

+ * May be null or empty, in which case any email address will be allowed. + */ + private List allowedEmailDomains; + /** + * File containing allowed email domains for registering users. + *

+ * May be null, in which case any email address will be allowed. + */ + private Resource allowedEmailDomainsFile; + /** + * Refresh delay to reload the allowed email domains file, in seconds. + */ + @DurationUnit(value = ChronoUnit.SECONDS) + private Duration allowedEmailDomainsFileRefreshDelay; + /** + * Allow internationalized domain names. + * If set to true, Punycode can be added to {@link #allowedEmailDomains} or {@link #allowedEmailDomainsFile}. + */ + private boolean allowInternationalizedEmailDomains; } diff --git a/src/main/java/ubc/pavlab/rdp/settings/SiteSettings.java b/src/main/java/ubc/pavlab/rdp/settings/SiteSettings.java index f451b832..403fafcc 100644 --- a/src/main/java/ubc/pavlab/rdp/settings/SiteSettings.java +++ b/src/main/java/ubc/pavlab/rdp/settings/SiteSettings.java @@ -47,5 +47,16 @@ public URI getHostUrl() { @NotEmpty(message = "The admin email must be specified.") private String adminEmail; + /** + * GA4 tracker. + */ private String gaTracker; + /** + * Public reCAPTCHA key. + */ + private String recaptchaToken; + /** + * Secret reCAPTCHA key. + */ + private String recaptchaSecret; } diff --git a/src/main/java/ubc/pavlab/rdp/validation/AllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/AllowedDomainStrategy.java new file mode 100644 index 00000000..492794cd --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/AllowedDomainStrategy.java @@ -0,0 +1,12 @@ +package ubc.pavlab.rdp.validation; + +/** + * Defines a strategy to determine if a domain is allowed. + * + * @author poirigui + */ +@FunctionalInterface +public interface AllowedDomainStrategy { + + boolean allows( String domain ); +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java b/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java new file mode 100644 index 00000000..da5db924 --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java @@ -0,0 +1,85 @@ +package ubc.pavlab.rdp.validation; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +import java.net.IDN; +import java.util.Set; + +/** + * Validate an email address against a list of allowed domains. + *

+ * If no list of allowed domains is provided, any domain is allowed and only basic validation is performed. + *

+ * If enabled, this validator can accept international domain names (IDN) and verify them against the list of allowed + * domains by first converting them to Punycode using {@link IDN#toASCII(String)}. + * + * @author poirigui + */ +public class EmailValidator implements Validator { + + /** + * List of allowed domains. + */ + private final AllowedDomainStrategy allowedDomainStrategy; + + /** + * Allow international domain names. + */ + private final boolean allowIdn; + + public EmailValidator() { + this.allowedDomainStrategy = null; + this.allowIdn = false; + } + + public EmailValidator( AllowedDomainStrategy allowedDomainStrategy, boolean allowIdn ) { + this.allowedDomainStrategy = allowedDomainStrategy; + this.allowIdn = allowIdn; + } + + public EmailValidator( Set allowedDomains, boolean allowIdn ) { + this( new SetBasedAllowedDomainStrategy( allowedDomains ), allowIdn ); + } + + @Override + public boolean supports( Class clazz ) { + return String.class.isAssignableFrom( clazz ); + } + + @Override + public void validate( Object target, Errors errors ) { + String email = (String) target; + String[] parts = email.split( "@", 2 ); + if ( parts.length != 2 ) { + errors.rejectValue( null, "EmailValidator.invalidAddress" ); + return; + } + String address = parts[0]; + if ( address.isEmpty() ) { + errors.rejectValue( null, "EmailValidator.emptyUser" ); + } + String domain = parts[1]; + if ( domain.isEmpty() ) { + errors.rejectValue( null, "EmailValidator.emptyDomain" ); + return; + } + if ( allowIdn ) { + try { + domain = IDN.toASCII( domain ); + } catch ( IllegalArgumentException e ) { + errors.rejectValue( null, "EmailValidator.domainNotConformToRfc3490", new String[]{ e.getMessage() }, null ); + return; + } + } else if ( !StringUtils.isAsciiPrintable( domain ) ) { + errors.rejectValue( null, "EmailValidator.domainContainsUnsupportedCharacters" ); + return; + } + if ( allowedDomainStrategy != null && !allowedDomainStrategy.allows( domain ) ) { + // at this point, the domain only contains ascii-printable, so it can safely be passed back to the user in + // an error message + errors.rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ domain }, null ); + } + } +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java b/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java new file mode 100644 index 00000000..32695818 --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java @@ -0,0 +1,13 @@ +package ubc.pavlab.rdp.validation; + +import lombok.Value; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestParam; + +@Value +public class Recaptcha { + String response; + @Nullable + String remoteIp; +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java new file mode 100644 index 00000000..6e0feb37 --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java @@ -0,0 +1,78 @@ +package ubc.pavlab.rdp.validation; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import org.springframework.web.client.RestTemplate; + +/** + * reCAPTCHA v2 implementation as a Spring validator. + * + * @author poirigui + */ +public class RecaptchaValidator implements Validator { + + private final RestTemplate restTemplate; + private final String secret; + + public RecaptchaValidator( RestTemplate restTemplate, String secret ) { + Assert.isTrue( restTemplate.getMessageConverters().stream().anyMatch( converter -> converter.canWrite( MultiValueMap.class, MediaType.APPLICATION_FORM_URLENCODED ) ), + "The supplied RestTemplate must support writing " + MediaType.APPLICATION_FORM_URLENCODED_VALUE + " messages." ); + Assert.isTrue( StringUtils.isNotBlank( secret ), "The secret must not be empty." ); + this.restTemplate = restTemplate; + this.secret = secret; + } + + @Override + public void validate( Object target, Errors errors ) { + Recaptcha recaptcha = (Recaptcha) target; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType( MediaType.APPLICATION_FORM_URLENCODED ); + MultiValueMap payload = new LinkedMultiValueMap<>(); + payload.add( "secret", secret ); + payload.add( "response", recaptcha.getResponse() ); + if ( recaptcha.getRemoteIp() != null ) { + payload.add( "remoteip", recaptcha.getRemoteIp() ); + } + HttpEntity> requestEntity = new HttpEntity<>( payload, headers ); + Reply reply = restTemplate.postForObject( "https://www.google.com/recaptcha/api/siteverify", + requestEntity, Reply.class ); + if ( reply == null ) { + errors.reject( "RecaptchaValidator.empty-reply" ); + return; + } + if ( !reply.success ) { + errors.reject( "RecaptchaValidator.unsuccessful-response" ); + } + if ( reply.errorCodes != null ) { + for ( String errorCode : reply.errorCodes ) { + errors.reject( "RecaptchaValidator." + errorCode ); + } + } + } + + @Override + public boolean supports( Class clazz ) { + return Recaptcha.class.isAssignableFrom( clazz ); + } + + @Data + @JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) + private static class Reply { + private boolean success; + private String challengeTs; + private String hostname; + @Nullable + private String[] errorCodes; + } +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java new file mode 100644 index 00000000..2d8eac86 --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java @@ -0,0 +1,139 @@ +package ubc.pavlab.rdp.validation; + +import lombok.extern.apachecommons.CommonsLog; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.time.StopWatch; +import org.springframework.core.io.Resource; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.time.Duration; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * A resource-based strategy for allowing domains. + *

+ * The resource is read line-by-line, each line being a domain that will be allowed. The domains validated using a + * {@link SetBasedAllowedDomainStrategy}, so all of its rules regarding ASCII characters and case-insensitivity applies. + *

+ * The strategy accepts an optional refresh delay which it will use to determine if the resource should be reloaded. If + * the resource is backed by a {@link java.io.File}, the last modified date will also be used to prevent unnecessary + * reload. + *

+ * If the refresh fails for any reason, an error is logged and the previous list of allowed domains is used until + * another refresh is attempted. If no previous list of allowed domains exist, the exception will be raised. For this + * reason, you might want to invoke {@link #refresh()} right after creating the strategy to catch any error early otn. + * + * @author poirigui + */ +@CommonsLog +public class ResourceBasedAllowedDomainStrategy implements AllowedDomainStrategy { + + /** + * Resolution to use when comparing the last modified of a file against a recorded timestamp with + * {@link System#currentTimeMillis()}. + */ + private final static int LAST_MODIFIED_RESOLUTION_MS = 10; + + /** + * A resource where email domains are found. + */ + private final Resource allowedEmailDomainsFile; + + /** + * A refresh delay, in ms. + */ + + private final Duration refreshDelay; + + /* internal state */ + private volatile SetBasedAllowedDomainStrategy strategy; + private long lastRefresh; + + public ResourceBasedAllowedDomainStrategy( Resource allowedEmailDomainsFile, Duration refreshDelay ) { + this.allowedEmailDomainsFile = allowedEmailDomainsFile; + this.refreshDelay = refreshDelay; + } + + @Override + public boolean allows( String domain ) { + if ( strategy == null || shouldRefresh() ) { + try { + refresh(); + } catch ( Exception e ) { + if ( strategy == null ) { + throw new RuntimeException( e ); + } else { + // pretend the resource has been refreshed, otherwise it will be reattempted on every request + this.lastRefresh = System.currentTimeMillis(); + log.error( String.format( "An error occurred while refreshing the list of allowed domains from %s. The previous list will be used until the next refresh.", allowedEmailDomainsFile ), e ); + } + } + } + return strategy.allows( domain ); + } + + /** + * Refresh the list of allowed domains. + * + * @throws IOException if an error occurred while reading the resource. + */ + public synchronized void refresh() throws IOException { + StopWatch timer = StopWatch.createStarted(); + Set allowedDomains; + try ( BufferedReader ir = new BufferedReader( new InputStreamReader( allowedEmailDomainsFile.getInputStream() ) ) ) { + allowedDomains = new HashSet<>(); + String line; + int lineno = 0; + while ( ( line = ir.readLine() ) != null ) { + lineno++; + if ( StringUtils.isAsciiPrintable( line ) ) { + allowedDomains.add( line.trim() ); + } else { + log.warn( String.format( "Invalid characters in line %d from %s, it will be ignored.", lineno, allowedEmailDomainsFile ) ); + } + } + } + strategy = new SetBasedAllowedDomainStrategy( allowedDomains ); + lastRefresh = System.currentTimeMillis(); + log.info( String.format( "Loaded %d domains from %s in %d ms.", allowedDomains.size(), allowedEmailDomainsFile, timer.getTime() ) ); + } + + /** + * Obtain a set of allowed email domains. + */ + public Set getAllowedDomains() { + if ( strategy == null ) { + return Collections.emptySet(); + } else { + return strategy.getAllowedDomains(); + } + } + + /** + * Verify if the resource should be reloaded. + */ + private boolean shouldRefresh() { + if ( refreshDelay == null ) { + return false; + } + if ( System.currentTimeMillis() - lastRefresh >= refreshDelay.toMillis() ) { + // check if the file is stale + try { + long lastModified = FileUtils.lastModified( allowedEmailDomainsFile.getFile() ); + return lastModified + LAST_MODIFIED_RESOLUTION_MS > lastRefresh; + } catch ( FileNotFoundException ignored ) { + // resource is not backed by a file, most likely + } catch ( IOException e ) { + log.error( String.format( "An error occurred while checking the last modified date of %s.", allowedEmailDomainsFile ), e ); + } + return true; + } + return false; + } +} diff --git a/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java new file mode 100644 index 00000000..2985bd66 --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java @@ -0,0 +1,37 @@ +package ubc.pavlab.rdp.validation; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.util.Assert; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; + +/** + * Simple strategy for allowing domain based on a case-insensitive set. + *

+ * The supplied set can only contain domain with ASCII-printable characters. If you want to allow IDN, store + * Punycode in the set and enable IDN in {@link EmailValidator#EmailValidator(Set, boolean)}. + */ +public class SetBasedAllowedDomainStrategy implements AllowedDomainStrategy { + + private final Set allowedDomains; + + public SetBasedAllowedDomainStrategy( Collection allowedDomains ) { + // ascii-only domains, case-insensitive + Assert.isTrue( allowedDomains.stream().allMatch( StringUtils::isAsciiPrintable ), + "Allowed domains must only contain ASCII-printable characters." ); + this.allowedDomains = new TreeSet<>( String.CASE_INSENSITIVE_ORDER ); + this.allowedDomains.addAll( allowedDomains ); + } + + @Override + public boolean allows( String domain ) { + return allowedDomains.contains( domain ); + } + + public Set getAllowedDomains() { + return Collections.unmodifiableSet( allowedDomains ); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4a99c2bf..c87467b7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -73,6 +73,15 @@ server.compression.enabled=true # = Application Specific Defaults # ============================================================== +# A comma-delimited list of allowed email domains (ignored if empty) +rdp.settings.allowed-email-domains= +# File containing a list of allowed email domains (ignored if empty) +rdp.settings.allowed-email-domains-file= +# Refresh delay in seconds (ignored if empty, always refresh if set to zero) +rdp.settings.allowed-email-domains-file-refresh-delay=3600 +# Allow internationalized domain names +rdp.settings.allow-internationalized-email-domains=false + # Cached gene, orthologs, annotations, etc. rdp.settings.cache.enabled=true rdp.settings.cache.load-from-disk=false @@ -118,6 +127,10 @@ rdp.site.theme-color=#285187 ### Google Analytics ### rdp.site.ga-tracker= +### reCAPTCHA v2 ### +#rdp.site.recaptcha-token= +#rdp.site.recaptcha-secret= + # ============================================================== # = FAQ # ============================================================== diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 53bd53b8..fea1492e 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -45,6 +45,30 @@ AbstractUserDetailsAuthenticationProvider.expired=User account has expired. AbstractUserDetailsAuthenticationProvider.locked=User account is locked. AbstractUserDetailsAuthenticationProvider.credentialsExpired=User credentials have expired. +# when a domain is not allowed, those are used to prefill the contact email +# {0} contains the domain part +LoginController.domainNotAllowedSubject=Register with an email address from {0} +# {0} contains the email address, {1} contains the domain part and {2} contains the user's full name +LoginController.domainNotAllowedBody=Hello!\n\ + \n\ + I am trying to register an account with {0} and it appears that {1} is not an allowed email provider. Could you \ + please include it?\n\ + \n\ + Best,\n\ + {2} + +RecaptchaValidator.emtpy-reply=The reply from the reCAPTCHA service was empty. +RecaptchaValidator.unsuccessful-response=The reCAPTCHA was not successful. + +# those codes are defined in https://developers.google.com/recaptcha/docs/verify +RecaptchaValidator.missing-input-secret=The secret parameter is missing. +RecaptchaValidator.invalid-input-secret=The secret parameter is invalid or malformed. +RecaptchaValidator.missing-input-response=The response parameter is missing. +RecaptchaValidator.invalid-input-response=The response parameter is invalid or malformed. +RecaptchaValidator.bad-request=The request is invalid or malformed. +RecaptchaValidator.timeout-or-duplicate=The response is no longer valid: either is too old or has been used previously. + + AbstractSearchController.UserSearchParams.emptyQueryNotAllowed=At least one search criteria must be provided. # {0} contains the taxon id @@ -224,4 +248,11 @@ rdp.ontologies.reactome.definition=Reactome is an open-source, open access, manu # Edit this if you use a different source for orthologs rdp.cache.ortholog-source-description=The ortholog mapping is based on DIOPT version 9 \ -results, filtered for score >5, either best forward or reverse match and Rank = "high" or Rank = "moderate". \ No newline at end of file +results, filtered for score >5, either best forward or reverse match and Rank = "high" or Rank = "moderate". + +EmailValidator.invalidAddress=The email address lacks a '@' character. +EmailValidator.emptyUser=The user cannot be empty. +EmailValidator.emptyDomain=The domain cannot be empty. +EmailValidator.domainNotConformToRfc3490=The domain is not conform to RFC3490: {0}. +EmailValidator.domainContainsUnsupportedCharacters=The domain contains characters that are not ASCII printable. +EmailValidator.domainNotAllowed=The domain {0} is not allowed. \ No newline at end of file diff --git a/src/main/resources/templates/registration.html b/src/main/resources/templates/registration.html index 27c51927..7c355e69 100644 --- a/src/main/resources/templates/registration.html +++ b/src/main/resources/templates/registration.html @@ -13,7 +13,15 @@

-
+
+ It looks like your email address does not match one of the approved email domains. If you think this is a + mistake, + + contact us + + so that we can complete your registration. +
+
@@ -50,6 +58,8 @@
+ + + + \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java index b3093c2a..9ae02ff4 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/LoginControllerTest.java @@ -1,5 +1,6 @@ package ubc.pavlab.rdp.controllers; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -18,6 +19,7 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.validation.Errors; import ubc.pavlab.rdp.exception.TokenDoesNotMatchEmailException; import ubc.pavlab.rdp.exception.TokenNotFoundException; import ubc.pavlab.rdp.model.Profile; @@ -28,6 +30,9 @@ import ubc.pavlab.rdp.services.UserService; import ubc.pavlab.rdp.settings.ApplicationSettings; import ubc.pavlab.rdp.settings.SiteSettings; +import ubc.pavlab.rdp.validation.EmailValidator; +import ubc.pavlab.rdp.validation.Recaptcha; +import ubc.pavlab.rdp.validation.RecaptchaValidator; import java.util.Locale; @@ -35,8 +40,7 @@ import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -73,9 +77,16 @@ public class LoginControllerTest { @MockBean(name = "ontologyMessageSource") private MessageSource ontologyMessageSource; + @MockBean + private RecaptchaValidator recaptchaValidator; + + @MockBean + private EmailValidator emailValidator; + @BeforeEach public void setUp() { when( privacyService.getDefaultPrivacyLevel() ).thenReturn( PrivacyLevelType.PRIVATE ); + when( emailValidator.supports( String.class ) ).thenReturn( true ); } @Test @@ -111,11 +122,13 @@ public void register_thenReturnSuccess() throws Exception { .andExpect( model().attribute( "user", new User() ) ); when( userService.create( any() ) ).thenAnswer( answer -> answer.getArgument( 0, User.class ) ); mvc.perform( post( "/registration" ) + .header( "X-Forwarded-For", "127.0.0.1", "10.0.0.2" ) .param( "profile.name", "Bob" ) .param( "profile.lastName", "Smith" ) .param( "email", "bob@example.com" ) .param( "password", "123456" ) .param( "passwordConfirm", "123456" ) + .param( "g-recaptcha-response", "1234" ) .param( "id", "27" ) ) // this field is ignored .andExpect( status().is3xxRedirection() ) .andExpect( redirectedUrl( "/login" ) ); @@ -128,6 +141,33 @@ public void register_thenReturnSuccess() throws Exception { assertThat( user.isEnabled() ).isFalse(); assertThat( user.getAnonymousId() ).isNull(); } ); + ArgumentCaptor recaptchaCaptor = ArgumentCaptor.forClass( Recaptcha.class ); + verify( recaptchaValidator ).validate( recaptchaCaptor.capture(), any() ); + assertThat( recaptchaCaptor.getValue() ).satisfies( r -> { + assertThat( r.getResponse() ).isEqualTo( "1234" ); + assertThat( r.getRemoteIp() ).isEqualTo( "127.0.0.1" ); + } ); + } + + @Test + public void register_whenEmailDomainIsNotAccepted_thenProduceHelpfulMessage() throws Exception { + doAnswer( a -> { + a.getArgument( 1, Errors.class ).rejectValue( null, "EmailValidator.domainNotAllowed" ); + return null; + } ).when( emailValidator ).validate( eq( "bob@example.com" ), any() ); + when( emailValidator.supports( String.class ) ).thenReturn( true ); + mvc.perform( post( "/registration" ) + .param( "profile.name", "Bob" ) + .param( "profile.lastName", "Smith" ) + .param( "email", "bob@example.com" ) + .param( "password", "123456" ) + .param( "passwordConfirm", "123456" ) ) + .andExpect( status().isBadRequest() ) + .andExpect( model().attribute( "domainNotAllowed", true ) ) + .andExpect( model().attribute( "domainNotAllowedFrom", "bob@example.com" ) ) + .andExpect( model().attribute( "domainNotAllowedSubject", "Register with an email address from example.com" ) ) + .andExpect( model().attribute( "domainNotAllowedBody", containsString( "bob@example.com" ) ) ) + .andExpect( xpath( "//a[starts-with(@href, 'mailto:')]/@href" ).string( Matchers.startsWith( "mailto:support@example.com?from=bob@example.com&subject=Register" ) ) ); } @Test diff --git a/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java new file mode 100644 index 00000000..30fbca4c --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java @@ -0,0 +1,61 @@ +package ubc.pavlab.rdp.security; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.validation.Errors; +import ubc.pavlab.rdp.ValidationConfig; +import ubc.pavlab.rdp.validation.EmailValidator; + +import static org.mockito.Mockito.*; + +@ExtendWith(SpringExtension.class) +@TestPropertySource(properties = { + "rdp.settings.allowed-email-domains=ubc3.ca", + "rdp.settings.allowed-email-domains-file=classpath:allowed-email-domains-test.txt", + "rdp.settings.allowed-email-domains-file-refresh-delay=PT0.1S", + "rdp.settings.allow-internationalized-email-domains=true" +}) +public class EmailValidatorWithContextTest { + + @TestConfiguration + @Import(ValidationConfig.class) + static class EmailValidatorFactoryTestContextConfiguration { + + @Bean + public ConversionService conversionService() { + return new DefaultFormattingConversionService(); + } + } + + @Autowired + private EmailValidator emailValidator; + + @Test + public void test() { + Errors errors = mock( Errors.class ); + emailValidator.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + } + + @Test + public void testDomainFromList() { + Errors errors = mock( Errors.class ); + emailValidator.validate( "foo@ubc3.ca", errors ); + verifyNoInteractions( errors ); + } + + @Test + public void testUnrecognizedDomain() { + Errors errors = mock( Errors.class ); + emailValidator.validate( "foo@ubc2.ca", errors ); + verify( errors ).rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ "ubc2.ca" }, null ); + } +} \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java new file mode 100644 index 00000000..1e98291c --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java @@ -0,0 +1,141 @@ +package ubc.pavlab.rdp.validation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.PathResource; +import org.springframework.validation.Errors; + +import java.io.BufferedWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Collections; + +import static org.mockito.Mockito.*; + +public class EmailValidatorTest { + + private EmailValidator v; + private Errors e; + + @BeforeEach + public void setUp() { + v = new EmailValidator(); + e = mock( Errors.class ); + } + + @Test + public void validate_whenDomainIsAllowed_thenAccept() { + v.validate( "test@test.com", e ); + verifyNoInteractions( e ); + } + + @Test + public void validate_whenDomainIsNotInAllowedDomains_thenReject() { + v = new EmailValidator( Collections.singleton( "test.com" ), false ); + v.validate( "test@test2.com", e ); + verify( e ).rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ "test2.com" }, null ); + } + + @Test + public void validate_whenIdnIsEnabledAndDomainHasUnicodeSymbols_thenAccept() { + v = new EmailValidator( (AllowedDomainStrategy) null, true ); + v.validate( "foo@Bücher.example", e ); + verifyNoInteractions( e ); + } + + @Test + public void validate_whenIdnIsEnabledAndDomainHasInvalidUnicodeSymbols_thenReject() { + v = new EmailValidator( (AllowedDomainStrategy) null, true ); + // that's the code for a chequered flag 🏁 + v.validate( "foo@B\uD83C\uDFC1cher.example", e ); + verify( e ).rejectValue( isNull(), eq( "EmailValidator.domainNotConformToRfc3490" ), any(), isNull() ); + } + + @Test + public void validate_whenDomainContainsUnsupportedCharacters_thenReject() { + v.validate( "foo@Bücher.example", e ); + verify( e ).rejectValue( null, "EmailValidator.domainContainsUnsupportedCharacters" ); + } + + @Test + public void validate_whenDomainIsMissing_thenReject() { + v.validate( "test", e ); + verify( e ).rejectValue( null, "EmailValidator.invalidAddress" ); + } + + @Test + public void validate_whenDomainIsEmpty_thenReject() { + v.validate( "test@", e ); + verify( e ).rejectValue( null, "EmailValidator.emptyDomain" ); + } + + @Test + public void validate_whenAddressIsEmpty_thenReject() { + v.validate( "@test.com", e ); + verify( e ).rejectValue( null, "EmailValidator.emptyUser" ); + } + + @RepeatedTest(10) + public void validate_whenDelayForRefreshingExpiresAndDomainIsRemoved_thenReject() throws Exception { + Path tmpFile = Files.createTempFile( "test", null ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + writer.write( "ubc.ca" ); + } + + EmailValidator v = new EmailValidator( new ResourceBasedAllowedDomainStrategy( new PathResource( tmpFile ), Duration.ofMillis( 50 ) ), false ); + + Errors errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + writer.write( "ubc2.ca" ); + } + + // no immediate change + errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + + // until the refresh delay expires... + Thread.sleep( 50 ); + + errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verify( errors ).rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ "ubc.ca" }, null ); + + errors = mock( Errors.class ); + v.validate( "foo@ubc2.ca", errors ); + verifyNoInteractions( errors ); + } + + @Test + public void validate_whenDelayForRefreshingIsZero() throws Exception { + Path tmpFile = Files.createTempFile( "test", null ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + writer.write( "ubc.ca" ); + } + + EmailValidator v = new EmailValidator( new ResourceBasedAllowedDomainStrategy( new PathResource( tmpFile ), Duration.ofMillis( 0 ) ), false ); + + Errors errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verifyNoInteractions( errors ); + + try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + writer.write( "ubc2.ca" ); + } + + errors = mock( Errors.class ); + v.validate( "foo@ubc.ca", errors ); + verify( errors ).rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ "ubc.ca" }, null ); + + errors = mock( Errors.class ); + v.validate( "foo@ubc2.ca", errors ); + verifyNoInteractions( errors ); + } +} \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java new file mode 100644 index 00000000..d08cda17 --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java @@ -0,0 +1,90 @@ +package ubc.pavlab.rdp.validation; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Value; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +@JsonTest +public class RecaptchaValidatorTest { + + private RestTemplate restTemplate = new RestTemplate(); + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void test() throws JsonProcessingException { + MockRestServiceServer mockServer = MockRestServiceServer.createServer( restTemplate ); + MultiValueMap expectedFormData = new LinkedMultiValueMap<>(); + expectedFormData.add( "secret", "1234" ); + expectedFormData.add( "response", "I'm human." ); + expectedFormData.add( "remoteip", "127.0.0.1" ); + mockServer.expect( requestTo( "https://www.google.com/recaptcha/api/siteverify" ) ) + .andExpect( content().formData( expectedFormData ) ) + .andRespond( withStatus( HttpStatus.OK ).contentType( MediaType.APPLICATION_JSON ) + .body( objectMapper.writeValueAsString( new Reply( true, "", "localhost", null ) ) ) ); + Validator validator = new RecaptchaValidator( restTemplate, "1234" ); + Recaptcha recaptcha = new Recaptcha( "I'm human.", "127.0.0.1" ); + Errors errors = new BeanPropertyBindingResult( recaptcha, "recaptcha" ); + validator.validate( recaptcha, errors ); + assertThat( errors.hasErrors() ).withFailMessage( errors.toString() ).isFalse(); + mockServer.verify(); + } + + @Test + public void testInvalidRecaptchaResponse() throws JsonProcessingException { + MockRestServiceServer mockServer = MockRestServiceServer.createServer( restTemplate ); + MultiValueMap expectedFormData = new LinkedMultiValueMap<>(); + expectedFormData.add( "secret", "1234" ); + expectedFormData.add( "response", "I'm a robot." ); + expectedFormData.add( "remoteip", "127.0.0.1" ); + mockServer.expect( requestTo( "https://www.google.com/recaptcha/api/siteverify" ) ) + .andExpect( content().formData( expectedFormData ) ) + .andRespond( withStatus( HttpStatus.OK ).contentType( MediaType.APPLICATION_JSON ) + .body( objectMapper.writeValueAsString( new Reply( false, "", "localhost", new String[]{ + "invalid-input-secret" + } ) ) ) ); + Validator validator = new RecaptchaValidator( restTemplate, "1234" ); + Recaptcha recaptcha = new Recaptcha( "I'm a robot.", "127.0.0.1" ); + Errors errors = new BeanPropertyBindingResult( recaptcha, "recaptcha" ); + validator.validate( recaptcha, errors ); + assertThat( errors.hasErrors() ).isTrue(); + assertThat( errors.getGlobalErrors() ) + .satisfiesExactlyInAnyOrder( ( f ) -> { + assertThat( f.getCode() ).isEqualTo( "RecaptchaValidator.unsuccessful-response" ); + }, ( f ) -> { + assertThat( f.getCode() ).isEqualTo( "RecaptchaValidator.invalid-input-secret" ); + } ); + mockServer.verify(); + } + + @Value + @JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class) + private static class Reply { + boolean success; + String challengeTs; + String hostname; + @Nullable + String[] errorCodes; + } +} \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java b/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java new file mode 100644 index 00000000..42ba171a --- /dev/null +++ b/src/test/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategyIntegrationTest.java @@ -0,0 +1,19 @@ +package ubc.pavlab.rdp.validation; + +import org.junit.Test; +import org.springframework.core.io.UrlResource; + +import java.io.IOException; + +import static org.assertj.core.api.Assumptions.assumeThat; + +public class ResourceBasedAllowedDomainStrategyIntegrationTest { + + @Test + public void testWithJetBrainsSwot() throws IOException { + UrlResource resource = new UrlResource( "https://github.com/JetBrains/swot/releases/download/latest/swot.txt" ); + assumeThat( resource.exists() ).isTrue(); + ResourceBasedAllowedDomainStrategy strategy = new ResourceBasedAllowedDomainStrategy( resource, null ); + strategy.refresh(); + } +} \ No newline at end of file diff --git a/src/test/resources/allowed-email-domains-test.txt b/src/test/resources/allowed-email-domains-test.txt new file mode 100644 index 00000000..f5f79fb3 --- /dev/null +++ b/src/test/resources/allowed-email-domains-test.txt @@ -0,0 +1,2 @@ +example.com +ubc.ca \ No newline at end of file