diff --git a/docs/customization.md b/docs/customization.md index ccd4388e..0dcabf0c 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -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. diff --git a/src/main/java/ubc/pavlab/rdp/ValidationConfig.java b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java index 4a91b7a8..f5e46ea6 100644 --- a/src/main/java/ubc/pavlab/rdp/ValidationConfig.java +++ b/src/main/java/ubc/pavlab/rdp/ValidationConfig.java @@ -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; @@ -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. @@ -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 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 ); } } diff --git a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java index 23eb5f57..7cb7cc22 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java @@ -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; @@ -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. @@ -50,7 +50,7 @@ public class LoginController { @Autowired private EmailValidator emailValidator; - @Autowired + @Autowired(required = false) private RecaptchaValidator recaptchaValidator; @Autowired @@ -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" ); @@ -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 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 diff --git a/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java b/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java index 475ad385..da5db924 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java +++ b/src/main/java/ubc/pavlab/rdp/validation/EmailValidator.java @@ -69,7 +69,7 @@ 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 ) ) { @@ -77,7 +77,9 @@ public void validate( Object target, Errors errors ) { 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 ); } } } diff --git a/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java b/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java index ad7e5a4d..32695818 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java +++ b/src/main/java/ubc/pavlab/rdp/validation/Recaptcha.java @@ -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; } diff --git a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java index 0152c753..6e0feb37 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java +++ b/src/main/java/ubc/pavlab/rdp/validation/RecaptchaValidator.java @@ -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; @@ -17,6 +26,9 @@ 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; } @@ -24,17 +36,28 @@ public RecaptchaValidator( RestTemplate restTemplate, String 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", - 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 ); + } } } @@ -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; } } diff --git a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java index fecace8a..49ce2816 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java +++ b/src/main/java/ubc/pavlab/rdp/validation/ResourceBasedAllowedDomainStrategy.java @@ -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; @@ -85,6 +86,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 getAllowedDomains() { + if ( strategy == null ) { + return Collections.emptySet(); + } else { + return strategy.getAllowedDomains(); + } + } + /** * Verify if the resource should be reloaded. */ @@ -94,6 +106,7 @@ private boolean shouldRefresh() { } // check if the file is stale + if ( System.currentTimeMillis() - lastRefresh >= refreshDelay.toMillis() ) { try { // avoid refreshing if the file hasn't changed diff --git a/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java index 47a3e1ea..21b73271 100644 --- a/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java +++ b/src/main/java/ubc/pavlab/rdp/validation/SetBasedAllowedDomainStrategy.java @@ -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; @@ -29,4 +30,8 @@ public SetBasedAllowedDomainStrategy( Collection allowedDomains ) { 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 dc607e59..5c8c63bb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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 # ============================================================== diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 8c5abc66..778c2b43 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -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 @@ -239,8 +251,8 @@ rdp.cache.ortholog-source-description=The ortholog mapping is based on
-
- Yikes! It looks like your email domain is not in our list of allowed domains. If you think this is a - mistake, - + -
+
@@ -58,8 +57,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 efb4f637..29d4ec2f 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; @@ -30,6 +31,7 @@ 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; @@ -120,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" ) ); @@ -137,6 +141,12 @@ 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 @@ -146,7 +156,7 @@ public void register_whenEmailDomainIsNotAccepted_thenProduceHelpfulMessage() th return null; } ).when( emailValidator ).validate( eq( "bob@example.com" ), any() ); when( emailValidator.supports( String.class ) ).thenReturn( true ); - String expectedMailto = "from=foo@example.com&subject=&body="; + String expectedMailto = "mailto:admin@...from=foo@example.com&subject=&body="; mvc.perform( post( "/registration" ) .param( "profile.name", "Bob" ) .param( "profile.lastName", "Smith" ) @@ -157,8 +167,8 @@ public void register_whenEmailDomainIsNotAccepted_thenProduceHelpfulMessage() th .andExpect( model().attribute( "domainNotAllowed", true ) ) .andExpect( model().attribute( "domainNotAllowedFrom", "bob@example.com" ) ) .andExpect( model().attribute( "domainNotAllowedSubject", "Attempting to register with example.com as an email domain is not allowed" ) ) - .andExpect( model().attribute( "domainNotAllowedBody", "" ) ) - .andExpect( xpath( "a[href ~= 'mailto:'].href" ).string( expectedMailto ) ); + .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=Attempting" ) ) ); } @Test diff --git a/src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java similarity index 96% rename from src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java rename to src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java index eae8e109..3b37ed0e 100644 --- a/src/test/java/ubc/pavlab/rdp/security/EmailValidatorFactoryTest.java +++ b/src/test/java/ubc/pavlab/rdp/security/EmailValidatorWithContextTest.java @@ -30,7 +30,7 @@ "rdp.settings.allowed-email-domains-refresh-delay=PT1S", "rdp.settings.allow-internationalized-domain-names=true" }) -public class EmailValidatorFactoryTest { +public class EmailValidatorWithContextTest { @TestConfiguration @Import(ValidationConfig.class) @@ -73,7 +73,7 @@ public void testReloadAfterDelay() throws Exception { v.validate( "foo@ubc.ca", errors ); verifyNoInteractions( errors ); - try ( BufferedWriter writer = Files.newBufferedWriter( tmpFile ) ) { + try ( BufferedWriter ignored = Files.newBufferedWriter( tmpFile ) ) { // clearing the file } diff --git a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java index 3c22b974..800d9687 100644 --- a/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java +++ b/src/test/java/ubc/pavlab/rdp/validation/EmailValidatorTest.java @@ -38,7 +38,7 @@ public void validate_whenDomainIsAllowed_thenAccept() { 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" ); + verify( e ).rejectValue( null, "EmailValidator.domainNotAllowed", new String[]{ "test2.com" }, null ); } @Test diff --git a/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java b/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java index b7b4e144..d8f76ea8 100644 --- a/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java +++ b/src/test/java/ubc/pavlab/rdp/validation/RecaptchaValidatorTest.java @@ -1,7 +1,90 @@ package ubc.pavlab.rdp.validation; -import static org.junit.Assert.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Value; +import org.junit.Test; +import org.junit.runner.RunWith; +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.context.junit4.SpringRunner; +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 +@RunWith(SpringRunner.class) 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 + private static class Reply { + boolean success; + String challengeTs; + String hostname; + @Nullable + String[] errorCodes; + } } \ No newline at end of file