Skip to content

Commit

Permalink
Finish reCAPTCHA and email validation
Browse files Browse the repository at this point in the history
  • Loading branch information
arteymix committed Dec 12, 2023
1 parent 738fecf commit 86eb7da
Show file tree
Hide file tree
Showing 16 changed files with 341 additions and 164 deletions.
3 changes: 2 additions & 1 deletion docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ RDP supports [reCAPTCHA v2](https://www.google.com/recaptcha/about/) to mitigate
bots. To enable it, add the reCAPTCHA secret to your configuration.

```properties
rdp.settings.recaptcha-secret=mysecret
rdp.site.recaptcha-token=mytoken
rdp.site.recaptcha-secret=mysecret
```

This feature is disabled by default.
Expand Down
24 changes: 18 additions & 6 deletions src/main/java/ubc/pavlab/rdp/ValidationConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

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.AllowedDomainStrategy;
import ubc.pavlab.rdp.validation.EmailValidator;
Expand All @@ -15,6 +17,7 @@
import java.io.IOException;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Set;

/**
* This configuration provides a few {@link org.springframework.validation.Validator} beans.
Expand All @@ -29,19 +32,28 @@ public EmailValidator emailValidator(
@Value("${rdp.settings.allowed-email-domains-refresh-delay}") @DurationUnit(ChronoUnit.SECONDS) Duration refreshDelay,
@Value("${rdp.settings.allow-internationalized-domain-names}") boolean allowIdn ) throws IOException {
AllowedDomainStrategy strategy;
if ( allowedEmailDomainsFile == null ) {
strategy = ( domain ) -> true;
log.info( "No allowed email domains file specified, all domains will be allowed for newly registered users." );
} else {
if ( allowedEmailDomainsFile != null ) {
log.info( "Reading allowed email domains from " + allowedEmailDomainsFile + "..." );
strategy = new ResourceBasedAllowedDomainStrategy( allowedEmailDomainsFile, refreshDelay );
( (ResourceBasedAllowedDomainStrategy) strategy ).refresh();
Set<String> allowedDomains = ( (ResourceBasedAllowedDomainStrategy) strategy ).getAllowedDomains();
if ( allowedDomains.size() <= 5 ) {
log.info( String.format( "Email validation is configured to accept only addresses from: %s.", String.join( ", ", allowedDomains ) ) );
} else {
log.info( String.format( "Email validation is configured to accept only addresses from a list of %d domains.", allowedDomains.size() ) );
}
} else {
strategy = ( domain ) -> true;
log.warn( "No allowed email domains file specified, all domains will be allowed for newly registered users." );
}
return new EmailValidator( strategy, allowIdn );
}

@Bean
public RecaptchaValidator recaptchaValidator( @Value("${rdp.settings.recaptcha.secret}") String secret ) {
return new RecaptchaValidator( new RestTemplate(), secret );
@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 );
}
}
31 changes: 20 additions & 11 deletions src/main/java/ubc/pavlab/rdp/controllers/LoginController.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@
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;
Expand All @@ -26,10 +23,13 @@
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.
Expand All @@ -50,7 +50,7 @@ public class LoginController {
@Autowired
private EmailValidator emailValidator;

@Autowired
@Autowired(required = false)
private RecaptchaValidator recaptchaValidator;

@Autowired
Expand Down Expand Up @@ -86,12 +86,6 @@ public void configureUserDataBinder( WebDataBinder dataBinder ) {
dataBinder.addValidators( new UserEmailValidator() );
}

@InitBinder("recaptcha")
public void configureRecaptchaDataBinder( WebDataBinder dataBinder ) {
dataBinder.setAllowedFields( "secret" );
dataBinder.addValidators( recaptchaValidator );
}

@GetMapping("/login")
public ModelAndView login() {
ModelAndView modelAndView = new ModelAndView( "login" );
Expand All @@ -116,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<String> 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( "<br>" ) ) );
modelAndView.addObject( "error", Boolean.TRUE );
return modelAndView;
}
}

User existingUser = userService.findUserByEmailNoAuth( user.getEmail() );

// profile can be missing of no profile.* fields have been set
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,17 @@ public void validate( Object target, Errors errors ) {
try {
domain = IDN.toASCII( domain );
} catch ( IllegalArgumentException e ) {
errors.rejectValue( null, "EmailValidator.domainNotConformToRfc3490", new String[]{ e.getMessage() }, "" );
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 ) ) {
errors.rejectValue( null, "EmailValidator.domainNotAllowed" );
// 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 );
}
}
}
5 changes: 4 additions & 1 deletion src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package ubc.pavlab.rdp.validation;

import lombok.Data;
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;
}
44 changes: 31 additions & 13 deletions src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
package ubc.pavlab.rdp.validation;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Data;
import lombok.Value;
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;
Expand All @@ -17,24 +26,38 @@ public class RecaptchaValidator implements Validator {
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<String, String> payload = new LinkedMultiValueMap<>();
payload.add( "secret", secret );
payload.add( "response", recaptcha.getResponse() );
if ( recaptcha.getRemoteIp() != null ) {
payload.add( "remoteip", recaptcha.getRemoteIp() );
}
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>( payload, headers );
Reply reply = restTemplate.postForObject( "https://www.google.com/recaptcha/api/siteverify",
new Payload( secret, recaptcha.getResponse(), recaptcha.getRemoteIp() ), Reply.class );
requestEntity, Reply.class );
if ( reply == null ) {
errors.reject( "" );
errors.reject( "RecaptchaValidator.empty-reply" );
return;
}
if ( !reply.success ) {
errors.reject( "" );
errors.reject( "RecaptchaValidator.unsuccessful-response" );
}
for ( String errorCode : reply.errorCodes ) {
errors.reject( errorCode );
if ( reply.errorCodes != null ) {
for ( String errorCode : reply.errorCodes ) {
errors.reject( "RecaptchaValidator." + errorCode );
}
}
}

Expand All @@ -43,18 +66,13 @@ public boolean supports( Class<?> clazz ) {
return Recaptcha.class.isAssignableFrom( clazz );
}

@Value
private static class Payload {
String secret;
String response;
String remoteIp;
}

@Data
@JsonNaming(PropertyNamingStrategies.KebabCaseStrategy.class)
private static class Reply {
private boolean success;
private String challengeTs;
private String hostname;
@Nullable
private String[] errorCodes;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import java.io.IOException;
import java.io.InputStreamReader;
import java.time.Duration;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;

Expand All @@ -31,6 +32,11 @@
@CommonsLog
public class ResourceBasedAllowedDomainStrategy implements AllowedDomainStrategy {

/**
* Resolution to use when comparing the last modified of a file against some recorded timestamp.
*/
private final static int LAST_MODIFIED_RESOLUTION_MS = 2;

/**
* A resource where email domains are found.
*/
Expand Down Expand Up @@ -85,6 +91,17 @@ public synchronized void refresh() throws IOException {
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<String> getAllowedDomains() {
if ( strategy == null ) {
return Collections.emptySet();
} else {
return strategy.getAllowedDomains();
}
}

/**
* Verify if the resource should be reloaded.
*/
Expand All @@ -94,10 +111,15 @@ private boolean shouldRefresh() {
}

// check if the file is stale

if ( System.currentTimeMillis() - lastRefresh >= refreshDelay.toMillis() ) {
try {
// avoid refreshing if the file hasn't changed
return allowedEmailDomainsFile.getFile().lastModified() > lastRefresh;
long lastModified = allowedEmailDomainsFile.getFile().lastModified();
if ( lastModified == 0L ) {
// error reading the last modified, assume it's stale
return true;
}
return lastModified + LAST_MODIFIED_RESOLUTION_MS > lastRefresh;
} catch ( FileNotFoundException ignored ) {
// resource is not backed by a file, most likely
} catch ( IOException e ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.apache.commons.lang3.StringUtils;

import java.util.Collection;
import java.util.Collections;
import java.util.Set;
import java.util.TreeSet;

Expand All @@ -29,4 +30,8 @@ public SetBasedAllowedDomainStrategy( Collection<String> allowedDomains ) {
public boolean allows( String domain ) {
return allowedDomains.contains( domain );
}

public Set<String> getAllowedDomains() {
return Collections.unmodifiableSet( allowedDomains );
}
}
4 changes: 4 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ rdp.site.theme-color=#285187
### Google Analytics ###
rdp.site.ga-tracker=

### reCAPTCHA v2 ###
#rdp.site.recaptcha-token=
#rdp.site.recaptcha-secret=

# ==============================================================
# = FAQ
# ==============================================================
Expand Down
26 changes: 19 additions & 7 deletions src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,26 @@ AbstractUserDetailsAuthenticationProvider.credentialsExpired=User credentials ha
# {0} contains the domain part
LoginController.domainNotAllowedSubject=Attempting to register with {0} as an email domain is not allowed
# {0} contains the email address, {1} contains the domain part and {2} contains the user's full name
LoginController.domainNotAllowedBody=Hello!\
\
LoginController.domainNotAllowedBody=Hello!\n\
\n\
I am trying to register {0} and it appears that {1} is not in your allowed list of email domains. Could you please \
include it? \
\
Best,\
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
Expand Down Expand Up @@ -239,8 +251,8 @@ rdp.cache.ortholog-source-description=The ortholog mapping is based on <a href="
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.emptyAddress=The user cannot be empty.
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 is not included in the allowed set of domains.
EmailValidator.domainNotAllowed=The domain {0} is not allowed.
Loading

0 comments on commit 86eb7da

Please sign in to comment.