Repository: gravitee-io/graviteeio-access-management
Pull request #794 (2020-05-07 09:39:19)

feat(captcha): add captcha support on AM login page
Closes gravitee-io/issues#3675


Patch:
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-common/src/main/java/io/gravitee/am/gateway/handler/common/spring/CommonConfiguration.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-common/src/main/java/io/gravitee/am/gateway/handler/common/spring/CommonConfiguration.java
index d310a23aee..2106ba7658 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-common/src/main/java/io/gravitee/am/gateway/handler/common/spring/CommonConfiguration.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-common/src/main/java/io/gravitee/am/gateway/handler/common/spring/CommonConfiguration.java
@@ -53,6 +53,7 @@ import io.vertx.ext.web.client.WebClientOptions;
 import io.vertx.reactivex.core.Vertx;
 import io.vertx.reactivex.ext.web.client.WebClient;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
@@ -73,6 +74,7 @@ public class CommonConfiguration {
     private Vertx vertx;
 
     @Bean
+    @Qualifier("oidcWebClient")
     public WebClient webClient() {
         WebClientOptions options = new WebClientOptions()
                 .setConnectTimeout(Integer.valueOf(environment.getProperty("oidc.http.connectionTimeout", "10")) * 1000)
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-oidc/src/main/java/io/gravitee/am/gateway/handler/oidc/service/clientregistration/impl/DynamicClientRegistrationServiceImpl.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-oidc/src/main/java/io/gravitee/am/gateway/handler/oidc/service/clientregistration/impl/DynamicClientRegistrationServiceImpl.java
index 9fb9f42877..a5d1296ba1 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-oidc/src/main/java/io/gravitee/am/gateway/handler/oidc/service/clientregistration/impl/DynamicClientRegistrationServiceImpl.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-oidc/src/main/java/io/gravitee/am/gateway/handler/oidc/service/clientregistration/impl/DynamicClientRegistrationServiceImpl.java
@@ -47,6 +47,7 @@ import io.vertx.reactivex.ext.web.client.WebClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 
 import java.net.URI;
 import java.net.URISyntaxException;
@@ -81,6 +82,7 @@ public class DynamicClientRegistrationServiceImpl implements DynamicClientRegist
     private JWTService jwtService;
 
     @Autowired
+    @Qualifier("oidcWebClient")
     public WebClient client;
 
     @Autowired
diff --git a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-oidc/src/main/java/io/gravitee/am/gateway/handler/oidc/service/jwk/impl/JWKServiceImpl.java b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-oidc/src/main/java/io/gravitee/am/gateway/handler/oidc/service/jwk/impl/JWKServiceImpl.java
index ce050cb38a..cf615485d8 100644
--- a/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-oidc/src/main/java/io/gravitee/am/gateway/handler/oidc/service/jwk/impl/JWKServiceImpl.java
+++ b/gravitee-am-gateway/gravitee-am-gateway-handler/gravitee-am-gateway-handler-oidc/src/main/java/io/gravitee/am/gateway/handler/oidc/service/jwk/impl/JWKServiceImpl.java
@@ -29,6 +29,7 @@ import io.reactivex.Single;
 import io.vertx.reactivex.ext.web.client.HttpResponse;
 import io.vertx.reactivex.ext.web.client.WebClient;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 
 import java.net.URISyntaxException;
 import java.util.Optional;
@@ -45,6 +46,7 @@ public class JWKServiceImpl implements JWKService {
     private CertificateManager certificateManager;
 
     @Autowired
+    @Qualifier("oidcWebClient")
     public WebClient client;
 
     @Override
diff --git a/gravitee-am-identityprovider/gravitee-am-identityprovider-github/src/main/java/io/gravitee/am/identityprovider/github/authentication/GithubAuthenticationProvider.java b/gravitee-am-identityprovider/gravitee-am-identityprovider-github/src/main/java/io/gravitee/am/identityprovider/github/authentication/GithubAuthenticationProvider.java
index 8001592c01..4ce84702cc 100644
--- a/gravitee-am-identityprovider/gravitee-am-identityprovider-github/src/main/java/io/gravitee/am/identityprovider/github/authentication/GithubAuthenticationProvider.java
+++ b/gravitee-am-identityprovider/gravitee-am-identityprovider-github/src/main/java/io/gravitee/am/identityprovider/github/authentication/GithubAuthenticationProvider.java
@@ -41,6 +41,7 @@ import io.vertx.reactivex.ext.web.client.WebClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.context.annotation.Import;
 
 import java.util.*;
@@ -61,6 +62,7 @@ public class GithubAuthenticationProvider implements SocialAuthenticationProvide
     private static final String CODE = "code";
 
     @Autowired
+    @Qualifier("gitHubWebClient")
     private WebClient client;
 
     @Autowired
diff --git a/gravitee-am-identityprovider/gravitee-am-identityprovider-github/src/main/java/io/gravitee/am/identityprovider/github/authentication/spring/GithubAuthenticationProviderConfiguration.java b/gravitee-am-identityprovider/gravitee-am-identityprovider-github/src/main/java/io/gravitee/am/identityprovider/github/authentication/spring/GithubAuthenticationProviderConfiguration.java
index af80b0728b..c3419dcef7 100644
--- a/gravitee-am-identityprovider/gravitee-am-identityprovider-github/src/main/java/io/gravitee/am/identityprovider/github/authentication/spring/GithubAuthenticationProviderConfiguration.java
+++ b/gravitee-am-identityprovider/gravitee-am-identityprovider-github/src/main/java/io/gravitee/am/identityprovider/github/authentication/spring/GithubAuthenticationProviderConfiguration.java
@@ -20,6 +20,7 @@ import io.vertx.ext.web.client.WebClientOptions;
 import io.vertx.reactivex.core.Vertx;
 import io.vertx.reactivex.ext.web.client.WebClient;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 
@@ -39,6 +40,7 @@ public class GithubAuthenticationProviderConfiguration {
     private GithubIdentityProviderConfiguration configuration;
 
     @Bean
+    @Qualifier("gitHubWebClient")
     public WebClient httpClient() {
         WebClientOptions httpClientOptions = new WebClientOptions();
         httpClientOptions
diff --git a/gravitee-am-identityprovider/gravitee-am-identityprovider-oauth2-generic/src/main/java/io/gravitee/am/identityprovider/oauth2/authentication/OAuth2GenericAuthenticationProvider.java b/gravitee-am-identityprovider/gravitee-am-identityprovider-oauth2-generic/src/main/java/io/gravitee/am/identityprovider/oauth2/authentication/OAuth2GenericAuthenticationProvider.java
index 5dac9848a9..b80ab0b51d 100644
--- a/gravitee-am-identityprovider/gravitee-am-identityprovider-oauth2-generic/src/main/java/io/gravitee/am/identityprovider/oauth2/authentication/OAuth2GenericAuthenticationProvider.java
+++ b/gravitee-am-identityprovider/gravitee-am-identityprovider-oauth2-generic/src/main/java/io/gravitee/am/identityprovider/oauth2/authentication/OAuth2GenericAuthenticationProvider.java
@@ -57,6 +57,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.InitializingBean;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.context.annotation.Import;
 import org.springframework.util.Assert;
 
@@ -82,6 +83,7 @@ public class OAuth2GenericAuthenticationProvider implements OpenIDConnectAuthent
     private static final String USERINFO_ENDPOINT = "userinfo_endpoint";
 
     @Autowired
+    @Qualifier("oauthWebClient")
     private WebClient client;
 
     @Autowired
diff --git a/gravitee-am-identityprovider/gravitee-am-identityprovider-oauth2-generic/src/main/java/io/gravitee/am/identityprovider/oauth2/authentication/spring/OAuth2GenericAuthenticationProviderConfiguration.java b/gravitee-am-identityprovider/gravitee-am-identityprovider-oauth2-generic/src/main/java/io/gravitee/am/identityprovider/oauth2/authentication/spring/OAuth2GenericAuthenticationProviderConfiguration.java
index de62bc5534..b14a0c5516 100644
--- a/gravitee-am-identityprovider/gravitee-am-identityprovider-oauth2-generic/src/main/java/io/gravitee/am/identityprovider/oauth2/authentication/spring/OAuth2GenericAuthenticationProviderConfiguration.java
+++ b/gravitee-am-identityprovider/gravitee-am-identityprovider-oauth2-generic/src/main/java/io/gravitee/am/identityprovider/oauth2/authentication/spring/OAuth2GenericAuthenticationProviderConfiguration.java
@@ -20,6 +20,7 @@ import io.vertx.ext.web.client.WebClientOptions;
 import io.vertx.reactivex.core.Vertx;
 import io.vertx.reactivex.ext.web.client.WebClient;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 
@@ -39,6 +40,7 @@ public class OAuth2GenericAuthenticationProviderConfiguration {
     private OAuth2GenericIdentityProviderConfiguration configuration;
 
     @Bean
+    @Qualifier("oauthWebClient")
     public WebClient httpClient() {
         WebClientOptions httpClientOptions = new WebClientOptions();
         httpClientOptions
diff --git a/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/authentication/controller/LoginController.java b/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/authentication/controller/LoginController.java
index c59b794447..58a0306545 100644
--- a/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/authentication/controller/LoginController.java
+++ b/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/authentication/controller/LoginController.java
@@ -20,6 +20,7 @@ import io.gravitee.am.management.handlers.management.api.authentication.manager.
 import io.gravitee.am.model.IdentityProvider;
 import io.gravitee.am.model.Organization;
 import io.gravitee.am.service.OrganizationService;
+import io.gravitee.am.service.ReCaptchaService;
 import io.gravitee.common.http.HttpHeaders;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.web.savedrequest.SavedRequest;
@@ -53,6 +54,9 @@ public class LoginController {
     @Autowired
     private IdentityProviderManager identityProviderManager;
 
+    @Autowired
+    private ReCaptchaService reCaptchaService;
+
     @RequestMapping(value = "/login")
     public ModelAndView login(HttpServletRequest request) {
         String organizationId = Organization.DEFAULT;
@@ -90,6 +94,9 @@ public class LoginController {
             params.put("authorizeUrls", authorizeUrls);
         }
 
+        params.put("reCaptchaEnabled", reCaptchaService.isEnabled());
+        params.put("reCaptchaSiteKey", reCaptchaService.getSiteKey());
+
         return new ModelAndView(LOGIN_VIEW, params);
     }
 
diff --git a/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/authentication/filter/RecaptchaFilter.java b/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/authentication/filter/RecaptchaFilter.java
new file mode 100644
index 0000000000..9e3897a0d5
--- /dev/null
+++ b/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/authentication/filter/RecaptchaFilter.java
@@ -0,0 +1,90 @@
+/**
+ * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.gravitee.am.management.handlers.management.api.authentication.filter;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.gravitee.am.service.ReCaptchaService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.MediaType;
+import org.springframework.web.filter.GenericFilterBean;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
+
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+
+/**
+ * @author Jeoffrey HAEYAERT (jeoffrey.haeyaert at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+public class RecaptchaFilter extends GenericFilterBean {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(RecaptchaFilter.class);
+    public static final String DEFAULT_RECAPTCHA_HEADER_NAME = "X-Recaptcha-Token";
+    private static final Set<String> RESTRICTED_PATHS = new HashSet<>(Arrays.asList("POST /login"));
+
+    private ReCaptchaService reCaptchaService;
+    private ObjectMapper objectMapper;
+
+    public RecaptchaFilter(ReCaptchaService reCaptchaService, ObjectMapper objectMapper) {
+        this.reCaptchaService = reCaptchaService;
+        this.objectMapper = objectMapper;
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+
+        HttpServletRequest httpRequest = (HttpServletRequest) request;
+        HttpServletResponse httpResponse = (HttpServletResponse) response;
+
+        if(reCaptchaService.isEnabled() && RESTRICTED_PATHS.contains(httpRequest.getMethod() + " " + httpRequest.getPathInfo())) {
+
+            LOGGER.debug("Checking captcha");
+            String reCaptchaToken = httpRequest.getHeader(DEFAULT_RECAPTCHA_HEADER_NAME);
+
+            if (reCaptchaToken == null) {
+                reCaptchaToken = httpRequest.getParameter(DEFAULT_RECAPTCHA_HEADER_NAME);
+            }
+
+            if(!reCaptchaService.isValid(reCaptchaToken).blockingGet()) {
+
+                HashMap<String, Object> error = new HashMap<>();
+
+                error.put("message", "Something goes wrong. Please try again.");
+                error.put("http_status", SC_BAD_REQUEST);
+
+                httpResponse.setStatus(SC_BAD_REQUEST);
+                httpResponse.setContentType(MediaType.APPLICATION_JSON.toString());
+                httpResponse.getWriter().write(objectMapper.writeValueAsString(error));
+                httpResponse.getWriter().close();
+            }else {
+                chain.doFilter(request, response);
+            }
+        } else {
+            chain.doFilter(request, response);
+        }
+    }
+}
\ No newline at end of file
diff --git a/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/spring/security/SecurityConfiguration.java b/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/spring/security/SecurityConfiguration.java
index a105ea9151..f16bdbd062 100644
--- a/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/spring/security/SecurityConfiguration.java
+++ b/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/java/io/gravitee/am/management/handlers/management/api/spring/security/SecurityConfiguration.java
@@ -15,8 +15,10 @@
  */
 package io.gravitee.am.management.handlers.management.api.spring.security;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import io.gravitee.am.management.handlers.management.api.authentication.filter.CheckAuthenticationCookieFilter;
 import io.gravitee.am.management.handlers.management.api.authentication.filter.JWTAuthenticationFilter;
+import io.gravitee.am.management.handlers.management.api.authentication.filter.RecaptchaFilter;
 import io.gravitee.am.management.handlers.management.api.authentication.filter.SocialAuthenticationFilter;
 import io.gravitee.am.management.handlers.management.api.authentication.handler.CookieClearingLogoutHandler;
 import io.gravitee.am.management.handlers.management.api.authentication.handler.CustomAuthenticationFailureHandler;
@@ -29,6 +31,7 @@ import io.gravitee.am.management.handlers.management.api.authentication.web.*;
 import io.gravitee.am.management.handlers.management.api.authentication.web.LoginUrlAuthenticationEntryPoint;
 import io.gravitee.am.management.handlers.management.api.authentication.web.WebAuthenticationDetails;
 import io.gravitee.am.management.handlers.management.api.authentication.web.WebAuthenticationDetailsSource;
+import io.gravitee.am.service.ReCaptchaService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.context.annotation.Bean;
@@ -79,6 +82,12 @@ public class SecurityConfiguration {
     @Autowired
     private Http401UnauthorizedEntryPoint http401UnauthorizedEntryPoint;
 
+    @Autowired
+    private ReCaptchaService reCaptchaService;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
     @Autowired
     public void configureGlobal(AuthenticationManagerBuilder auth) {
         auth.authenticationProvider(userAuthenticationProvider());
@@ -117,6 +126,7 @@ public class SecurityConfiguration {
             .cors()
                 .configurationSource(corsConfigurationSource())
                 .and()
+            .addFilterBefore(new RecaptchaFilter(reCaptchaService, objectMapper), AbstractPreAuthenticatedProcessingFilter.class)
             .addFilterBefore(socialAuthFilter(), AbstractPreAuthenticatedProcessingFilter.class)
             .addFilterBefore(checkAuthCookieFilter(), AbstractPreAuthenticatedProcessingFilter.class);
         }
diff --git a/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/resources/views/login.html b/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/resources/views/login.html
index bfa2af6be5..920037555e 100644
--- a/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/resources/views/login.html
+++ b/gravitee-am-management-api/gravitee-am-management-api-rest/src/main/resources/views/login.html
@@ -15,6 +15,7 @@
 
     <!-- Favicon and touch icons -->
     <link rel="shortcut icon" th:href="@{assets/ico/favicon.ico}">
+    <script th:if="${reCaptchaEnabled}" th:src="${'https://www.google.com/recaptcha/api.js?render=' + reCaptchaSiteKey}"></script>
 </head>
 
 <body>
@@ -28,7 +29,7 @@
                 <label>Sign in</label>
                 <span>to continue to the Administration Portal</span></span>
             </div>
-            <form role="form" th:action="@{login}" method="post">
+            <form id="form" role="form" th:action="@{login}" method="post">
                 <div class="login-form-content">
                     <div class="input-textfield">
                         <label class="w3-text-blue">Username</label>
@@ -47,7 +48,7 @@
                 </div>
 
                 <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
-                <input type="hidden" th:name="client_id" th:value="${param.client_id}"/>
+                <input type="hidden" id="X-Recaptcha-Token" name="X-Recaptcha-Token" />
                 <div class="login-form-actions">
                     <button class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-button--accent">Sign in</button>
                 </div>
@@ -75,16 +76,13 @@
 <![endif]-->
 <script th:src="@{assets/material/material.min.js}"></script>
 <script th:src="@{assets/js/jquery-1.11.1.min.js}"></script>
-<script>
-    $(".mdl-textfield__input").focus(function (){
-        if( !this.value ){
-            $(this).prop('required', true);
-            $(this).parent().addClass('is-invalid');
-        }
-    });
-    $(".mdl-button[type='submit']").click(function (event){
-        $(this).siblings(".mdl-textfield").addClass('is-invalid');
-        $(this).siblings(".mdl-textfield").children(".mdl-textfield__input").prop('required', true);
+<script th:if="${reCaptchaEnabled}" th:inline="javascript">
+    $("#form").submit(function(event) {
+        event.preventDefault();
+        grecaptcha.execute('[(${reCaptchaSiteKey})]', {action: 'login'}).then(function (token) {
+            $("#X-Recaptcha-Token").val(token);
+            $("#form").unbind('submit').submit();
+        });
     });
 </script>
 </body>
diff --git a/gravitee-am-management-api/gravitee-am-management-api-standalone/gravitee-am-management-api-standalone-distribution/src/main/resources/config/gravitee.yml b/gravitee-am-management-api/gravitee-am-management-api-standalone/gravitee-am-management-api-standalone-distribution/src/main/resources/config/gravitee.yml
index 15e0cb2941..911812eab3 100644
--- a/gravitee-am-management-api/gravitee-am-management-api-standalone/gravitee-am-management-api-standalone-distribution/src/main/resources/config/gravitee.yml
+++ b/gravitee-am-management-api/gravitee-am-management-api-standalone/gravitee-am-management-api-standalone-distribution/src/main/resources/config/gravitee.yml
@@ -209,6 +209,30 @@ user:
 gateway:
   url: http://localhost:8092
 
+# global configuration of the http client
+#httpClient:
+#  timeout: 10000 # in milliseconds
+#  proxy:
+#    type: HTML #HTTP, SOCK4, SOCK5
+#    http:
+#      host: localhost
+#      port: 3128
+#      username: user
+#      password: secret
+#    https:
+#      host: localhost
+#      port: 3128
+#      username: user
+#      password: secret
+
+# Allows to enable or disable recaptcha (see https://developers.google.com/recaptcha/docs/v3). This only affect the AM login page.
+#reCaptcha:
+#  enabled: false
+#  siteKey:
+#  secretKey:
+#  minScore: 0.5
+#  serviceUrl: https://www.google.com/recaptcha/api/siteverify
+
 # Referenced properties
 ds:
   mongodb:
diff --git a/gravitee-am-service/pom.xml b/gravitee-am-service/pom.xml
index 12ca0348d7..933fcab3fb 100644
--- a/gravitee-am-service/pom.xml
+++ b/gravitee-am-service/pom.xml
@@ -79,6 +79,10 @@
             <groupId>io.vertx</groupId>
             <artifactId>vertx-rx-java2</artifactId>
         </dependency>
+        <dependency>
+            <groupId>io.vertx</groupId>
+            <artifactId>vertx-web-client</artifactId>
+        </dependency>
 
         <!-- Bouncy Castle -->
         <dependency>
@@ -109,5 +113,11 @@
             <groupId>com.github.fge</groupId>
             <artifactId>json-patch</artifactId>
         </dependency>
+
+        <!-- Test -->
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-test</artifactId>
+        </dependency>
     </dependencies>
 </project>
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/ReCaptchaService.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/ReCaptchaService.java
new file mode 100644
index 0000000000..7a5ce74e94
--- /dev/null
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/ReCaptchaService.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.gravitee.am.service;
+
+import io.reactivex.Single;
+
+/**
+ * @author Jeoffrey HAEYAERT (jeoffrey.haeyaert at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+public interface ReCaptchaService {
+
+    Single<Boolean> isValid(String token);
+
+    boolean isEnabled();
+
+    String getSiteKey();
+}
\ No newline at end of file
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/ReCaptchaServiceImpl.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/ReCaptchaServiceImpl.java
new file mode 100644
index 0000000000..a22f6dd488
--- /dev/null
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/impl/ReCaptchaServiceImpl.java
@@ -0,0 +1,106 @@
+/**
+ * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.gravitee.am.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.gravitee.am.service.ReCaptchaService;
+import io.reactivex.Single;
+import io.vertx.reactivex.core.MultiMap;
+import io.vertx.reactivex.ext.web.client.WebClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Component;
+
+import java.net.URI;
+import java.util.Map;
+
+/**
+ * @author Jeoffrey HAEYAERT (jeoffrey.haeyaert at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+@Component
+public class ReCaptchaServiceImpl implements ReCaptchaService {
+
+    private static final Logger logger = LoggerFactory.getLogger(ReCaptchaServiceImpl.class);
+
+    @Value("${reCaptcha.enabled:false}")
+    private boolean enabled;
+
+    @Value("${reCaptcha.siteKey}")
+    private String siteKey;
+
+    @Value("${reCaptcha.secretKey}")
+    private String secretKey;
+
+    @Value("${reCaptcha.minScore:0.5}")
+    private Double minScore;
+
+    @Value("${reCaptcha.serviceUrl:https://www.google.com/recaptcha/api/siteverify}")
+    private String serviceUrl;
+
+    @Autowired
+    @Qualifier("recaptchaWebClient")
+    private WebClient client;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @Override
+    public Single<Boolean> isValid(String token) {
+
+        if (!this.isEnabled()) {
+            logger.debug("ReCaptchaService is disabled");
+            return Single.just(true);
+        }
+
+        logger.debug("ReCaptchaService is enabled");
+
+        if (token == null || "".equals(token.trim())) {
+            logger.debug("Recaptcha token is empty");
+            return Single.just(false);
+        }
+
+        return client.post(URI.create(serviceUrl).toString())
+                .rxSendForm(MultiMap.caseInsensitiveMultiMap().set("secret", secretKey).set("response", token))
+                .map(buffer -> {
+                    Map res = objectMapper.readValue(buffer.bodyAsString(), Map.class);
+
+                    Boolean success = (Boolean) res.getOrDefault("success", false);
+                    Double score = (Double) res.getOrDefault("score", 0.0d);
+
+                    logger.debug("ReCaptchaService success: {} score: {}", success, score);
+
+                    // Result should be successful and score above 0.5.
+                    return (success && score >= minScore);
+                })
+                .onErrorResumeNext(throwable -> {
+                    logger.error("An error occurred when trying to validate ReCaptcha token.", throwable);
+                    return Single.just(false);
+                });
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    public String getSiteKey() {
+        return siteKey;
+    }
+}
\ No newline at end of file
diff --git a/gravitee-am-service/src/main/java/io/gravitee/am/service/spring/WebClientsConfiguration.java b/gravitee-am-service/src/main/java/io/gravitee/am/service/spring/WebClientsConfiguration.java
new file mode 100644
index 0000000000..df4794014d
--- /dev/null
+++ b/gravitee-am-service/src/main/java/io/gravitee/am/service/spring/WebClientsConfiguration.java
@@ -0,0 +1,115 @@
+/**
+ * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.gravitee.am.service.spring;
+
+import io.vertx.core.net.ProxyOptions;
+import io.vertx.core.net.ProxyType;
+import io.vertx.ext.web.client.WebClientOptions;
+import io.vertx.reactivex.core.Vertx;
+import io.vertx.reactivex.ext.web.client.WebClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Lazy;
+
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URL;
+
+/**
+ * @author Jeoffrey HAEYAERT (jeoffrey.haeyaert at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+@Configuration
+public class WebClientsConfiguration {
+
+    private static final String HTTPS_SCHEME = "https";
+
+    @Value("${httpClient.timeout:10000}")
+    private int httpClientTimeout;
+
+    @Value("${httpClient.proxy.type:HTTP}")
+    private String httpClientProxyType;
+
+    @Value("${httpClient.proxy.http.host:#{systemProperties['http.proxyHost'] ?: 'localhost'}}")
+    private String httpClientProxyHttpHost;
+
+    @Value("${httpClient.proxy.http.port:#{systemProperties['http.proxyPort'] ?: 3128}}")
+    private int httpClientProxyHttpPort;
+
+    @Value("${httpClient.proxy.http.username:#{null}}")
+    private String httpClientProxyHttpUsername;
+
+    @Value("${httpClient.proxy.http.password:#{null}}")
+    private String httpClientProxyHttpPassword;
+
+    @Value("${httpClient.proxy.https.host:#{systemProperties['https.proxyHost'] ?: 'localhost'}}")
+    private String httpClientProxyHttpsHost;
+
+    @Value("${httpClient.proxy.https.port:#{systemProperties['https.proxyPort'] ?: 3128}}")
+    private int httpClientProxyHttpsPort;
+
+    @Value("${httpClient.proxy.https.username:#{null}}")
+    private String httpClientProxyHttpsUsername;
+
+    @Value("${httpClient.proxy.https.password:#{null}}")
+    private String httpClientProxyHttpsPassword;
+
+    @Value("#{systemProperties['httpClient.proxy'] == null ? false : true }")
+    private boolean isProxyConfigured;
+
+    @Value("${reCaptcha.serviceUrl:https://www.google.com/recaptcha/api/siteverify}")
+    private String recaptchaServiceUrl;
+
+    @Bean("recaptchaWebClient")
+    protected WebClient recaptchaWebClient(Vertx vertx) throws MalformedURLException {
+
+        return createWebClient(vertx, URI.create(recaptchaServiceUrl).toURL());
+    }
+
+    private WebClient createWebClient(Vertx vertx, URL url) {
+
+        final int port = url.getPort() != -1 ? url.getPort() : (HTTPS_SCHEME.equals(url.getProtocol()) ? 443 : 80);
+
+        WebClientOptions options = new WebClientOptions()
+                .setDefaultPort(port)
+                .setDefaultHost(url.getHost())
+                .setKeepAlive(true)
+                .setMaxPoolSize(10)
+                .setTcpKeepAlive(true)
+                .setConnectTimeout(httpClientTimeout)
+                .setSsl(url.getProtocol().equals(HTTPS_SCHEME));
+
+        if (this.isProxyConfigured) {
+            ProxyOptions proxyOptions = new ProxyOptions();
+            proxyOptions.setType(ProxyType.valueOf(httpClientProxyType));
+            if (HTTPS_SCHEME.equals(url.getProtocol())) {
+                proxyOptions.setHost(httpClientProxyHttpsHost);
+                proxyOptions.setPort(httpClientProxyHttpsPort);
+                proxyOptions.setUsername(httpClientProxyHttpsUsername);
+                proxyOptions.setPassword(httpClientProxyHttpsPassword);
+            } else {
+                proxyOptions.setHost(httpClientProxyHttpHost);
+                proxyOptions.setPort(httpClientProxyHttpPort);
+                proxyOptions.setUsername(httpClientProxyHttpUsername);
+                proxyOptions.setPassword(httpClientProxyHttpPassword);
+            }
+            options.setProxyOptions(proxyOptions);
+        }
+
+        return WebClient.create(vertx, options);
+    }
+}
diff --git a/postman/collections/graviteeio-am-user-management-collection-app-version.json b/postman/collections/graviteeio-am-user-management-collection-app-version.json
index fdece22ee1..0f5cce48be 100644
--- a/postman/collections/graviteeio-am-user-management-collection-app-version.json
+++ b/postman/collections/graviteeio-am-user-management-collection-app-version.json
@@ -294,7 +294,7 @@
 													"",
 													"pm.test(\"UM - create role - name field required\", function () {",
 													"    var jsonData = pm.response.json();",
-													"    pm.expect(jsonData.message).to.eql(\"[name: ne peut pas être nul]\");",
+													"    pm.expect(jsonData.message).to.eql(\"[name: must not be null]\");",
 													"});"
 												],
 												"type": "text/javascript"
@@ -902,7 +902,7 @@
 													"",
 													"pm.test(\"UM - create user - username field required\", function () {",
 													"    var jsonData = pm.response.json();",
-													"    pm.expect(jsonData.message).to.eql(\"[username: ne peut pas être nul]\");",
+													"    pm.expect(jsonData.message).to.eql(\"[username: must not be null]\");",
 													"});"
 												],
 												"type": "text/javascript"
@@ -961,7 +961,7 @@
 													"",
 													"pm.test(\"UM - create user - email field required\", function () {",
 													"    var jsonData = pm.response.json();",
-													"    pm.expect(jsonData.message).to.eql(\"[email: ne peut pas être nul]\");",
+													"    pm.expect(jsonData.message).to.eql(\"[email: must not be null]\");",
 													"});"
 												],
 												"type": "text/javascript"
@@ -1984,7 +1984,7 @@
 													"",
 													"pm.test(\"UM - create group - name field required\", function () {",
 													"    var jsonData = pm.response.json();",
-													"    pm.expect(jsonData.message).to.eql(\"[name: ne peut pas être nul]\");",
+													"    pm.expect(jsonData.message).to.eql(\"[name: must not be null]\");",
 													"});"
 												],
 												"type": "text/javascript"
diff --git a/postman/collections/graviteeio-am-user-management-collection.json b/postman/collections/graviteeio-am-user-management-collection.json
index e41d1531b1..0a29ec711b 100644
--- a/postman/collections/graviteeio-am-user-management-collection.json
+++ b/postman/collections/graviteeio-am-user-management-collection.json
@@ -294,7 +294,7 @@
 													"",
 													"pm.test(\"UM - create role - name field required\", function () {",
 													"    var jsonData = pm.response.json();",
-													"    pm.expect(jsonData.message).to.eql(\"[name: ne peut pas être nul]\");",
+													"    pm.expect(jsonData.message).to.eql(\"[name: must not be null]\");",
 													"});"
 												],
 												"type": "text/javascript"
@@ -902,7 +902,7 @@
 													"",
 													"pm.test(\"UM - create user - username field required\", function () {",
 													"    var jsonData = pm.response.json();",
-													"    pm.expect(jsonData.message).to.eql(\"[username: ne peut pas être nul]\");",
+													"    pm.expect(jsonData.message).to.eql(\"[username: must not be null]\");",
 													"});"
 												],
 												"type": "text/javascript"
@@ -961,7 +961,7 @@
 													"",
 													"pm.test(\"UM - create user - email field required\", function () {",
 													"    var jsonData = pm.response.json();",
-													"    pm.expect(jsonData.message).to.eql(\"[email: ne peut pas être nul]\");",
+													"    pm.expect(jsonData.message).to.eql(\"[email: must not be null]\");",
 													"});"
 												],
 												"type": "text/javascript"
@@ -1986,7 +1986,7 @@
 													"",
 													"pm.test(\"UM - create group - name field required\", function () {",
 													"    var jsonData = pm.response.json();",
-													"    pm.expect(jsonData.message).to.eql(\"[name: ne peut pas être nul]\");",
+													"    pm.expect(jsonData.message).to.eql(\"[name: must not be null]\");",
 													"});"
 												],
 												"type": "text/javascript"


Test patch:
diff --git a/gravitee-am-service/src/test/java/io/gravitee/am/service/ReCaptchaServiceImplTest.java b/gravitee-am-service/src/test/java/io/gravitee/am/service/ReCaptchaServiceImplTest.java
new file mode 100644
index 0000000000..0ba87e71be
--- /dev/null
+++ b/gravitee-am-service/src/test/java/io/gravitee/am/service/ReCaptchaServiceImplTest.java
@@ -0,0 +1,214 @@
+/**
+ * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.gravitee.am.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.gravitee.am.service.impl.ReCaptchaServiceImpl;
+import io.gravitee.common.http.HttpStatusCode;
+import io.reactivex.Single;
+import io.reactivex.observers.TestObserver;
+import io.reactivex.schedulers.TestScheduler;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.web.client.HttpResponse;
+import io.vertx.ext.web.client.impl.ClientPhase;
+import io.vertx.ext.web.client.impl.WebClientInternal;
+import io.vertx.reactivex.core.Vertx;
+import io.vertx.reactivex.ext.web.client.HttpRequest;
+import io.vertx.reactivex.ext.web.client.WebClient;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.concurrent.CompletableFuture;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * @author Jeoffrey HAEYAERT (jeoffrey.haeyaert at graviteesource.com)
+ * @author GraviteeSource Team
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class ReCaptchaServiceImplTest {
+
+    @InjectMocks
+    private ReCaptchaServiceImpl reCaptchaService = new ReCaptchaServiceImpl();
+
+    @Spy
+    protected WebClient client = WebClient.wrap(Vertx.vertx().createHttpClient());
+
+    @Mock
+    protected HttpResponse httpResponse;
+
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    @Before
+    public void before() {
+
+        ReflectionTestUtils.setField(reCaptchaService, "objectMapper", objectMapper);
+        ReflectionTestUtils.setField(reCaptchaService, "serviceUrl", "https://verif");
+    }
+
+    @Test
+    public void isValidWhenDisabled() {
+
+        ReflectionTestUtils.setField(reCaptchaService, "enabled", false);
+
+        TestObserver<Boolean> obs = reCaptchaService.isValid(null).test();
+        obs.awaitTerminalEvent();
+        obs.assertValue(true);
+
+        obs = reCaptchaService.isValid("").test();
+        obs.awaitTerminalEvent();
+        obs.assertValue(true);
+
+        obs = reCaptchaService.isValid("any").test();
+        obs.awaitTerminalEvent();
+        obs.assertValue(true);
+    }
+
+    @Test
+    public void isNotValidIfNoToken() {
+
+        ReflectionTestUtils.setField(reCaptchaService, "enabled", true);
+
+        TestObserver<Boolean> obs = reCaptchaService.isValid(null).test();
+        obs.awaitTerminalEvent();
+        obs.assertValue(false);
+
+        obs = reCaptchaService.isValid("").test();
+        obs.awaitTerminalEvent();
+        obs.assertValue(false);
+    }
+
+    @Test
+    public void isValid() {
+
+        ReflectionTestUtils.setField(reCaptchaService, "minScore", 0.5d);
+        ReflectionTestUtils.setField(reCaptchaService, "enabled", true);
+
+        when(httpResponse.bodyAsString())
+                .thenReturn(new JsonObject().put("success", true).put("score", 0.9d).toString());
+
+        spyHttpRequest(client.post(eq("https://verif")));
+
+        TestObserver<Boolean> obs = reCaptchaService.isValid("any").test();
+        obs.awaitTerminalEvent();
+        obs.assertValue(true);
+    }
+
+    @Test
+    public void isValidAboveMinScore() {
+
+        ReflectionTestUtils.setField(reCaptchaService, "minScore", 0.5d);
+        ReflectionTestUtils.setField(reCaptchaService, "enabled", true);
+
+        when(httpResponse.bodyAsString())
+                .thenReturn(new JsonObject().put("success", true).put("score", 1.0d).toString());
+
+        spyHttpRequest(client.post(eq("https://verif")));
+
+        TestObserver<Boolean> obs = reCaptchaService.isValid("any").test();
+        obs.awaitTerminalEvent();
+        obs.assertValue(true);
+    }
+
+    @Test
+    public void isNotValidBelowMinScore() {
+
+        ReflectionTestUtils.setField(reCaptchaService, "minScore", 0.5d);
+        ReflectionTestUtils.setField(reCaptchaService, "enabled", true);
+
+        when(httpResponse.bodyAsString())
+                .thenReturn(new JsonObject().put("success", true).put("score", 0.4d).toString());
+
+        spyHttpRequest(client.post(eq("https://verif")));
+
+        TestObserver<Boolean> obs = reCaptchaService.isValid("any").test();
+        obs.awaitTerminalEvent();
+        obs.assertValue(false);
+    }
+
+    @Test
+    public void isNotValidNoSuccess() {
+
+        ReflectionTestUtils.setField(reCaptchaService, "minScore", 0.5d);
+        ReflectionTestUtils.setField(reCaptchaService, "enabled", true);
+
+        when(httpResponse.bodyAsString())
+                .thenReturn(new JsonObject().put("success", false).put("score", 0.0d).toString());
+
+        spyHttpRequest(client.post(eq("https://verif")));
+
+        TestObserver<Boolean> obs = reCaptchaService.isValid("any").test();
+        obs.awaitTerminalEvent();
+        obs.assertValue(false);
+    }
+
+    @Test
+    public void isNotEnabled() {
+
+        ReflectionTestUtils.setField(reCaptchaService, "enabled", false);
+        assertFalse(reCaptchaService.isEnabled());
+    }
+
+    @Test
+    public void getSiteKeyNullByDefault() {
+
+        assertNull(reCaptchaService.getSiteKey());
+    }
+
+    @Test
+    public void getSiteKey() {
+
+        ReflectionTestUtils.setField(reCaptchaService, "siteKey", "test");
+
+        assertEquals("test", reCaptchaService.getSiteKey());
+    }
+
+    @Before
+    public void beforeEach() {
+        ((WebClientInternal) client.getDelegate()).addInterceptor(event -> {
+
+            if(event.phase() == ClientPhase.SEND_REQUEST) {
+                // By pass send request and jump directly to dispatch phase with the mocked http response.
+                event.dispatchResponse(httpResponse);
+            }
+
+            event.next();
+        });
+
+        verify(client).getDelegate();
+    }
+
+    protected Single<HttpRequest> spyHttpRequest(HttpRequest httpRequest) {
+
+
+        CompletableFuture<HttpRequest> spyHttpRequest = new CompletableFuture<>();
+
+        when(httpRequest).thenAnswer(invocationOnMock -> {
+            spyHttpRequest.complete((HttpRequest) spy(invocationOnMock.callRealMethod()));
+            return spyHttpRequest.get();
+        });
+
+        return Single.fromFuture(spyHttpRequest);
+    }
+}
\ No newline at end of file