diff --git a/components/org.wso2.carbon.identity.api.user.recovery/src/gen/java/org/wso2/carbon/identity/recovery/endpoint/dto/ReCaptchaPropertiesDTO.java b/components/org.wso2.carbon.identity.api.user.recovery/src/gen/java/org/wso2/carbon/identity/recovery/endpoint/dto/ReCaptchaPropertiesDTO.java index 1e3ca858cd..7172a0ab70 100644 --- a/components/org.wso2.carbon.identity.api.user.recovery/src/gen/java/org/wso2/carbon/identity/recovery/endpoint/dto/ReCaptchaPropertiesDTO.java +++ b/components/org.wso2.carbon.identity.api.user.recovery/src/gen/java/org/wso2/carbon/identity/recovery/endpoint/dto/ReCaptchaPropertiesDTO.java @@ -10,18 +10,21 @@ @ApiModel(description = "") public class ReCaptchaPropertiesDTO { - - - + + + private Boolean reCaptchaEnabled = null; - - + + + private String reCaptchaType = null; + + private String reCaptchaKey = null; - - + + private String reCaptchaAPI = null; - + /** **/ @ApiModelProperty(value = "") @@ -33,7 +36,19 @@ public void setReCaptchaEnabled(Boolean reCaptchaEnabled) { this.reCaptchaEnabled = reCaptchaEnabled; } - + + /** + **/ + @ApiModelProperty(value = "") + @JsonProperty("reCaptchaType") + public String getReCaptchaType() { + return reCaptchaType; + } + public void setReCaptchaType(String reCaptchaType) { + this.reCaptchaType = reCaptchaType; + } + + /** **/ @ApiModelProperty(value = "") @@ -45,7 +60,7 @@ public void setReCaptchaKey(String reCaptchaKey) { this.reCaptchaKey = reCaptchaKey; } - + /** **/ @ApiModelProperty(value = "") @@ -57,14 +72,15 @@ public void setReCaptchaAPI(String reCaptchaAPI) { this.reCaptchaAPI = reCaptchaAPI; } - + @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append("class ReCaptchaPropertiesDTO {\n"); - + sb.append(" reCaptchaEnabled: ").append(reCaptchaEnabled).append("\n"); + sb.append(" reCaptchaType: ").append(reCaptchaType).append("\n"); sb.append(" reCaptchaKey: ").append(reCaptchaKey).append("\n"); sb.append(" reCaptchaAPI: ").append(reCaptchaAPI).append("\n"); sb.append("}\n"); diff --git a/components/org.wso2.carbon.identity.api.user.recovery/src/main/java/org/wso2/carbon/identity/recovery/endpoint/Utils/RecoveryUtil.java b/components/org.wso2.carbon.identity.api.user.recovery/src/main/java/org/wso2/carbon/identity/recovery/endpoint/Utils/RecoveryUtil.java index fd90f2821d..2b3569a29a 100644 --- a/components/org.wso2.carbon.identity.api.user.recovery/src/main/java/org/wso2/carbon/identity/recovery/endpoint/Utils/RecoveryUtil.java +++ b/components/org.wso2.carbon.identity.api.user.recovery/src/main/java/org/wso2/carbon/identity/recovery/endpoint/Utils/RecoveryUtil.java @@ -452,6 +452,7 @@ public static Properties getValidatedCaptchaConfigs() { private static Properties validateCaptchaConfigs(Properties properties) { boolean reCaptchaEnabled = Boolean.valueOf(properties.getProperty(CaptchaConstants.RE_CAPTCHA_ENABLED)); + String reCaptchaType = properties.getProperty(CaptchaConstants.RE_CAPTCHA_TYPE); if (reCaptchaEnabled && StringUtils.isBlank(properties.getProperty(CaptchaConstants.RE_CAPTCHA_SITE_KEY))) { RecoveryUtil.handleBadRequest(String.format("%s is not found ", CaptchaConstants.RE_CAPTCHA_SITE_KEY), @@ -469,6 +470,11 @@ private static Properties validateCaptchaConfigs(Properties properties) { RecoveryUtil.handleBadRequest(String.format("%s is not found ", CaptchaConstants.RE_CAPTCHA_VERIFY_URL), Constants.STATUS_INTERNAL_SERVER_ERROR_MESSAGE_DEFAULT); } + if (CaptchaConstants.RE_CAPTCHA_TYPE_ENTERPRISE.equals(reCaptchaType) && + StringUtils.isBlank(properties.getProperty(CaptchaConstants.RE_CAPTCHA_PROJECT_ID))) { + RecoveryUtil.handleBadRequest(String.format("%s is not found ", CaptchaConstants + .RE_CAPTCHA_PROJECT_ID), Constants.STATUS_INTERNAL_SERVER_ERROR_MESSAGE_DEFAULT); + } return properties; } diff --git a/components/org.wso2.carbon.identity.api.user.recovery/src/main/java/org/wso2/carbon/identity/recovery/endpoint/impl/CaptchaApiServiceImpl.java b/components/org.wso2.carbon.identity.api.user.recovery/src/main/java/org/wso2/carbon/identity/recovery/endpoint/impl/CaptchaApiServiceImpl.java index 3171ccb798..50d928c8ba 100644 --- a/components/org.wso2.carbon.identity.api.user.recovery/src/main/java/org/wso2/carbon/identity/recovery/endpoint/impl/CaptchaApiServiceImpl.java +++ b/components/org.wso2.carbon.identity.api.user.recovery/src/main/java/org/wso2/carbon/identity/recovery/endpoint/impl/CaptchaApiServiceImpl.java @@ -20,6 +20,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.http.HttpEntity; @@ -42,7 +43,6 @@ */ public class CaptchaApiServiceImpl extends CaptchaApiService { - private static final String SUCCESS = "success"; private static final Log log = LogFactory.getLog(CaptchaApiServiceImpl.class); private final String RECAPTCHA = "ReCaptcha"; @@ -64,11 +64,11 @@ public Response getCaptcha(String captchaType, String recoveryType, String tenan reCaptchaPropertiesDTO.setReCaptchaEnabled(true); reCaptchaPropertiesDTO.setReCaptchaKey(properties.getProperty(CaptchaConstants.RE_CAPTCHA_SITE_KEY)); reCaptchaPropertiesDTO.setReCaptchaAPI(properties.getProperty(CaptchaConstants.RE_CAPTCHA_API_URL)); - return Response.ok(reCaptchaPropertiesDTO).build(); + reCaptchaPropertiesDTO.setReCaptchaType(properties.getProperty(CaptchaConstants.RE_CAPTCHA_TYPE)); } else { reCaptchaPropertiesDTO.setReCaptchaEnabled(false); - return Response.ok(reCaptchaPropertiesDTO).build(); } + return Response.ok(reCaptchaPropertiesDTO).build(); } @Override @@ -80,6 +80,7 @@ public Response verifyCaptcha(ReCaptchaResponseTokenDTO reCaptchaResponse, Strin Properties properties = RecoveryUtil.getValidatedCaptchaConfigs(); boolean reCaptchaEnabled = Boolean.valueOf(properties.getProperty(CaptchaConstants.RE_CAPTCHA_ENABLED)); + String reCaptchaType = properties.getProperty(CaptchaConstants.RE_CAPTCHA_TYPE); if (!reCaptchaEnabled) { RecoveryUtil.handleBadRequest("ReCaptcha is disabled", Constants.INVALID); @@ -89,21 +90,44 @@ public Response verifyCaptcha(ReCaptchaResponseTokenDTO reCaptchaResponse, Strin HttpEntity entity = response.getEntity(); ReCaptchaVerificationResponseDTO reCaptchaVerificationResponseDTO = new ReCaptchaVerificationResponseDTO(); - try { + if (CaptchaConstants.RE_CAPTCHA_TYPE_ENTERPRISE.equals(reCaptchaType)) { + // For ReCaptcha Enterprise. if (entity == null) { - RecoveryUtil.handleBadRequest("ReCaptcha verification response is not received.", + RecoveryUtil.handleBadRequest("ReCaptcha Enterprise verification response is not received.", Constants.STATUS_INTERNAL_SERVER_ERROR_MESSAGE_DEFAULT); - } else { + } + try { + try (InputStream in = entity.getContent()) { + JsonObject verificationResponse = new JsonParser().parse(IOUtils.toString(in)).getAsJsonObject(); + JsonObject tokenProperties = verificationResponse.get(CaptchaConstants.CAPTCHA_TOKEN_PROPERTIES) + .getAsJsonObject(); + boolean success = tokenProperties.get(CaptchaConstants.CAPTCHA_VALID).getAsBoolean(); + reCaptchaVerificationResponseDTO.setSuccess(success); + } + } catch (IOException e) { + log.error("Unable to read the verification response.", e); + RecoveryUtil.handleBadRequest("Unable to read the verification response.", + Constants.STATUS_INTERNAL_SERVER_ERROR_MESSAGE_DEFAULT); + } + } else { + // For ReCaptcha v2 and v3. + try { + if (entity == null) { + RecoveryUtil.handleBadRequest("ReCaptcha verification response is not received.", + Constants.STATUS_INTERNAL_SERVER_ERROR_MESSAGE_DEFAULT); + } try (InputStream in = entity.getContent()) { JsonObject verificationResponse = new JsonParser().parse(IOUtils.toString(in)).getAsJsonObject(); - reCaptchaVerificationResponseDTO.setSuccess(verificationResponse.get(SUCCESS).getAsBoolean()); + reCaptchaVerificationResponseDTO.setSuccess(verificationResponse.get( + CaptchaConstants.CAPTCHA_SUCCESS).getAsBoolean()); } + } catch (IOException e) { + log.error("Unable to read the verification response.", e); + RecoveryUtil.handleBadRequest("Unable to read the verification response.", + Constants.STATUS_INTERNAL_SERVER_ERROR_MESSAGE_DEFAULT); } - } catch (IOException e) { - log.error("Unable to read the verification response.", e); - RecoveryUtil.handleBadRequest("Unable to read the verification response.", - Constants.STATUS_INTERNAL_SERVER_ERROR_MESSAGE_DEFAULT); } + return Response.ok(reCaptchaVerificationResponseDTO).build(); } } diff --git a/components/org.wso2.carbon.identity.api.user.recovery/src/main/resources/api.identity.recovery.yaml b/components/org.wso2.carbon.identity.api.user.recovery/src/main/resources/api.identity.recovery.yaml index f32bf762e4..770c4c24d8 100644 --- a/components/org.wso2.carbon.identity.api.user.recovery/src/main/resources/api.identity.recovery.yaml +++ b/components/org.wso2.carbon.identity.api.user.recovery/src/main/resources/api.identity.recovery.yaml @@ -790,6 +790,8 @@ definitions: properties: reCaptchaEnabled: type: boolean + reCaptchaType: + type: string reCaptchaKey: type: string reCaptchaAPI: diff --git a/components/org.wso2.carbon.identity.api.user.recovery/src/test/java/org/wso2/carbon/identity/recovery/endpoint/impl/CaptchaApiServiceImplTest.java b/components/org.wso2.carbon.identity.api.user.recovery/src/test/java/org/wso2/carbon/identity/recovery/endpoint/impl/CaptchaApiServiceImplTest.java index 70088128a9..354792e128 100644 --- a/components/org.wso2.carbon.identity.api.user.recovery/src/test/java/org/wso2/carbon/identity/recovery/endpoint/impl/CaptchaApiServiceImplTest.java +++ b/components/org.wso2.carbon.identity.api.user.recovery/src/test/java/org/wso2/carbon/identity/recovery/endpoint/impl/CaptchaApiServiceImplTest.java @@ -17,16 +17,18 @@ package org.wso2.carbon.identity.recovery.endpoint.impl; +import org.apache.commons.lang.StringUtils; import org.mockito.InjectMocks; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import org.wso2.carbon.identity.captcha.util.CaptchaConstants; import org.wso2.carbon.identity.recovery.endpoint.Utils.RecoveryUtil; - +import org.wso2.carbon.identity.recovery.endpoint.dto.ReCaptchaPropertiesDTO; import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; @@ -35,8 +37,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Properties; - +import javax.ws.rs.core.Response; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; /** * Unit tests for CaptchaApiServiceImpl.java @@ -61,9 +64,51 @@ public void tearDown() { mockedRecoveryUtil.close(); } - @Test(description = "This method test, getReCaptcha method for username recovery") - public void testGetCaptcha() throws IOException { + @DataProvider(name = "captchaTestDataProvider") + public static Object[][] getCaptchaTestDataProvider() { + + String reCaptchaEnterprise = CaptchaConstants.RE_CAPTCHA_TYPE_ENTERPRISE; + + return new Object[][]{ + {false, "", ""}, + {true, "", "https://www.google.com/recaptcha/api.js"}, + {true, reCaptchaEnterprise, "https://www.google.com/recaptcha/enterprise.js"} + }; + } + + @Test(description = "This method test, getReCaptcha method for username recovery", + dataProvider = "captchaTestDataProvider") + public void testGetCaptcha(boolean reCaptchaEnabled, String reCaptchaType, + String reCaptchaAPI) throws IOException { + Properties sampleProperties = getSampleConfigFile(); + + sampleProperties.setProperty(CaptchaConstants.RE_CAPTCHA_ENABLED, String.valueOf(reCaptchaEnabled)); + sampleProperties.setProperty(CaptchaConstants.RE_CAPTCHA_TYPE, reCaptchaType); + sampleProperties.setProperty(CaptchaConstants.RE_CAPTCHA_API_URL, reCaptchaAPI); + + mockedRecoveryUtil.when(RecoveryUtil::getValidatedCaptchaConfigs).thenReturn(sampleProperties); + mockedRecoveryUtil.when(() -> RecoveryUtil.checkCaptchaEnabledResidentIdpConfiguration(Mockito.anyString(), + Mockito.anyString())).thenReturn(true); + Response response = captchaApiService.getCaptcha("ReCaptcha", "username-recovery", + "test.org"); + + ReCaptchaPropertiesDTO reCaptchaPropertiesDTO = response.readEntity(ReCaptchaPropertiesDTO.class); + + assertEquals(reCaptchaPropertiesDTO.getReCaptchaEnabled().booleanValue(), reCaptchaEnabled); + if (reCaptchaPropertiesDTO.getReCaptchaType() == null) { + assertNull(reCaptchaPropertiesDTO.getReCaptchaType()); + } else { + assertEquals(reCaptchaPropertiesDTO.getReCaptchaType(), reCaptchaType); + } + if (StringUtils.isBlank(reCaptchaAPI)) { + assertNull(reCaptchaPropertiesDTO.getReCaptchaAPI()); + } else { + assertEquals(reCaptchaPropertiesDTO.getReCaptchaAPI(), reCaptchaAPI); + } + } + + public Properties getSampleConfigFile() throws IOException { Path path = Paths.get("src/test/resources", "repository", "conf", "identity", CaptchaConstants.CAPTCHA_CONFIG_FILE_NAME); Properties sampleProperties = new Properties(); @@ -74,11 +119,6 @@ public void testGetCaptcha() throws IOException { throw new IOException("Unable to read the captcha configuration file.", e); } } - - mockedRecoveryUtil.when(RecoveryUtil::getValidatedCaptchaConfigs).thenReturn(sampleProperties); - mockedRecoveryUtil.when(() -> RecoveryUtil.checkCaptchaEnabledResidentIdpConfiguration(Mockito.anyString(), - Mockito.anyString())).thenReturn(true); - assertEquals(captchaApiService.getCaptcha("ReCaptcha", "username-recovery", - null).getStatus(), 200); + return sampleProperties; } } diff --git a/components/org.wso2.carbon.identity.api.user.recovery/src/test/resources/repository/conf/identity/captcha-config.properties b/components/org.wso2.carbon.identity.api.user.recovery/src/test/resources/repository/conf/identity/captcha-config.properties index 7980a19a37..b920c175db 100644 --- a/components/org.wso2.carbon.identity.api.user.recovery/src/test/resources/repository/conf/identity/captcha-config.properties +++ b/components/org.wso2.carbon.identity.api.user.recovery/src/test/resources/repository/conf/identity/captcha-config.properties @@ -21,6 +21,9 @@ # Enable Google reCAPTCHA recaptcha.enabled=true +# Google reCAPTCHA type +recaptcha.type=default + # reCaptcha API URL recaptcha.api.url=https://www.google.com/recaptcha/api.js diff --git a/components/org.wso2.carbon.identity.captcha/pom.xml b/components/org.wso2.carbon.identity.captcha/pom.xml index fda4b2af16..4337e4c97c 100644 --- a/components/org.wso2.carbon.identity.captcha/pom.xml +++ b/components/org.wso2.carbon.identity.captcha/pom.xml @@ -114,6 +114,22 @@ org.wso2.securevault org.wso2.securevault + + org.testng + testng + test + + + org.jacoco + org.jacoco.agent + runtime + test + + + org.mockito + mockito-inline + test + @@ -185,6 +201,16 @@ + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + src/test/resources/testng.xml + + + com.github.spotbugs spotbugs-maven-plugin diff --git a/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/internal/CaptchaDataHolder.java b/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/internal/CaptchaDataHolder.java index 0ddf99ea64..260bb81417 100644 --- a/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/internal/CaptchaDataHolder.java +++ b/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/internal/CaptchaDataHolder.java @@ -37,6 +37,8 @@ public class CaptchaDataHolder { private boolean reCaptchaEnabled; + private String reCaptchaType; + private String reCaptchaAPIUrl; private String reCaptchaVerifyUrl; @@ -45,6 +47,8 @@ public class CaptchaDataHolder { private String reCaptchaSecretKey; + private String reCaptchaProjectID; + private String reCaptchaErrorRedirectUrls; private String reCaptchaRequestWrapUrls; @@ -84,6 +88,26 @@ public void setReCaptchaEnabled(boolean reCaptchaEnabled) { this.reCaptchaEnabled = reCaptchaEnabled; } + public String getReCaptchaType() { + + return reCaptchaType; + } + + public void setReCaptchaType(String reCaptchaType) { + + this.reCaptchaType = reCaptchaType; + } + + public String getReCaptchaProjectID() { + + return reCaptchaProjectID; + } + + public void setReCaptchaProjectID(String reCaptchaProjectID) { + + this.reCaptchaProjectID = reCaptchaProjectID; + } + public String getReCaptchaAPIUrl() { return reCaptchaAPIUrl; } diff --git a/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/util/CaptchaConstants.java b/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/util/CaptchaConstants.java index 985ef64f7e..19f59e3e1d 100644 --- a/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/util/CaptchaConstants.java +++ b/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/util/CaptchaConstants.java @@ -34,6 +34,8 @@ public class CaptchaConstants { public static final String RE_CAPTCHA_ENABLED = "recaptcha.enabled"; + public static final String RE_CAPTCHA_TYPE = "recaptcha.type"; + public static final String FORCEFULLY_ENABLED_RECAPTCHA_FOR_ALL_TENANTS = "recaptcha" + ".forcefullyEnabledForAllTenants"; @@ -43,6 +45,8 @@ public class CaptchaConstants { public static final String RE_CAPTCHA_SITE_KEY = "recaptcha.site.key"; + public static final String RE_CAPTCHA_PROJECT_ID = "recaptcha.project.id"; + public static final String RE_CAPTCHA_SECRET_KEY = "recaptcha.secret.key"; public static final String RE_CAPTCHA_REQUEST_WRAP_URLS = "recaptcha.request.wrap.urls"; @@ -66,6 +70,11 @@ public class CaptchaConstants { public static final String AUTH_FAILURE_MSG = "authFailureMsg"; public static final String RECAPTCHA_FAIL_MSG_KEY = "recaptcha.fail.message"; public static final String TRUE = "true"; + public static final String CAPTCHA_VALID = "valid"; + public static final String CAPTCHA_TOKEN_PROPERTIES = "tokenProperties"; + public static final String CAPTCHA_RISK_ANALYSIS = "riskAnalysis"; + // Captcha Types. + public static final String RE_CAPTCHA_TYPE_ENTERPRISE = "enterprise"; // Default value for threshold for score in reCAPTCHA v3. public static final double CAPTCHA_V3_DEFAULT_THRESHOLD = 0.5; diff --git a/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/util/CaptchaUtil.java b/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/util/CaptchaUtil.java index 4e091b19d9..4a5bb6a1f2 100644 --- a/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/util/CaptchaUtil.java +++ b/components/org.wso2.carbon.identity.captcha/src/main/java/org/wso2/carbon/identity/captcha/util/CaptchaUtil.java @@ -18,17 +18,19 @@ package org.wso2.carbon.identity.captcha.util; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.apache.commons.collections.MapUtils; -import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.math.NumberUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.http.HttpEntity; +import org.apache.http.HttpHeaders; import org.apache.http.HttpResponse; import org.apache.http.NameValuePair; +import org.apache.http.entity.StringEntity; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.utils.URIBuilder; @@ -124,6 +126,7 @@ public static void buildReCaptchaFilterProperties() { CaptchaDataHolder.getInstance().setReCaptchaEnabled(false); } + } } @@ -269,36 +272,98 @@ public static Map getClaimValues(User user, int tenantId, return claimValues; } - public static boolean isValidCaptcha(String reCaptchaResponse) throws CaptchaException { + private static HttpPost createReCaptchaEnterpriseVerificationHttpPost(String reCaptchaResponse) { - CloseableHttpClient httpclient = HttpClientBuilder.create().useSystemProperties().build(); - HttpPost httppost = new HttpPost(CaptchaDataHolder.getInstance().getReCaptchaVerifyUrl()); - final double scoreThreshold = CaptchaDataHolder.getInstance().getReCaptchaScoreThreshold(); + HttpPost httpPost; + String recaptchaUrl = CaptchaDataHolder.getInstance().getReCaptchaVerifyUrl(); + String projectID = CaptchaDataHolder.getInstance().getReCaptchaProjectID(); + String siteKey = CaptchaDataHolder.getInstance().getReCaptchaSiteKey(); + String secretKey = CaptchaDataHolder.getInstance().getReCaptchaSecretKey(); + + String verifyUrl = recaptchaUrl + "/v1/projects/" + projectID + "/assessments?key=" + secretKey; + httpPost = new HttpPost(verifyUrl); + httpPost.setHeader(HttpHeaders.CONTENT_TYPE, "application/json"); + + String json = String.format("{ \"event\": { \"token\": \"%s\", \"siteKey\": \"%s\" } }", reCaptchaResponse, + siteKey); + + StringEntity entity = new StringEntity(json, StandardCharsets.UTF_8); + + httpPost.setEntity(entity); + + return httpPost; + } + + private static HttpPost createReCaptchaVerificationHttpPost(String reCaptchaResponse) { + + HttpPost httpPost; + httpPost = new HttpPost(CaptchaDataHolder.getInstance().getReCaptchaVerifyUrl()); List params = Arrays.asList(new BasicNameValuePair("secret", CaptchaDataHolder - .getInstance().getReCaptchaSecretKey()), new BasicNameValuePair("response", reCaptchaResponse)); - httppost.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); + .getInstance().getReCaptchaSecretKey()), + new BasicNameValuePair("response", reCaptchaResponse)); + httpPost.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); + + return httpPost; + } + + private static void verifyReCaptchaEnterpriseResponse(HttpEntity entity) + throws CaptchaServerException, CaptchaClientException { + + final double scoreThreshold = CaptchaDataHolder.getInstance().getReCaptchaScoreThreshold(); - HttpResponse response; try { - response = httpclient.execute(httppost); + try (InputStream in = entity.getContent()) { + JsonElement jsonElement = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + JsonObject verificationResponse = jsonElement.getAsJsonObject(); + if (verificationResponse == null) { + throw new CaptchaClientException("Error receiving reCaptcha response from the server"); + } + JsonObject tokenProperties = verificationResponse.get(CaptchaConstants.CAPTCHA_TOKEN_PROPERTIES). + getAsJsonObject(); + boolean success = tokenProperties.get(CaptchaConstants.CAPTCHA_VALID).getAsBoolean(); + + JsonObject riskAnalysis = verificationResponse.get(CaptchaConstants.CAPTCHA_RISK_ANALYSIS). + getAsJsonObject(); + + // Whether this request was a valid reCAPTCHA token. + if (!success) { + throw new CaptchaClientException("reCaptcha token is invalid. Error:" + + verificationResponse.get("error-codes")); + } + if (riskAnalysis.get(CaptchaConstants.CAPTCHA_SCORE) != null) { + double score = riskAnalysis.get(CaptchaConstants.CAPTCHA_SCORE).getAsDouble(); + // reCAPTCHA enterprise response contains score. + if (log.isDebugEnabled()) { + log.debug("reCAPTCHA Enterprise response { timestamp:" + + tokenProperties.get("createTime") + ", action: " + + tokenProperties.get("action") + ", score: " + score + " }"); + } + if (score < scoreThreshold) { + throw new CaptchaClientException("reCaptcha score is less than the threshold."); + } + } + } } catch (IOException e) { - throw new CaptchaServerException("Unable to get the verification response.", e); + throw new CaptchaServerException("Unable to read the verification response.", e); + } catch (ClassCastException e) { + throw new CaptchaServerException("Unable to cast the response value.", e); } + } - HttpEntity entity = response.getEntity(); - if (entity == null) { - throw new CaptchaServerException("reCaptcha verification response is not received."); - } + private static void verifyReCaptchaResponse(HttpEntity entity) + throws CaptchaServerException, CaptchaClientException { + + final double scoreThreshold = CaptchaDataHolder.getInstance().getReCaptchaScoreThreshold(); try { try (InputStream in = entity.getContent()) { - JsonObject verificationResponse = new JsonParser().parse(IOUtils.toString(in)).getAsJsonObject(); + JsonElement jsonElement = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + JsonObject verificationResponse = jsonElement.getAsJsonObject(); if (verificationResponse == null) { throw new CaptchaClientException("Error receiving reCaptcha response from the server"); } - boolean success = verificationResponse.get(CaptchaConstants.CAPTCHA_SUCCESS) != null - && verificationResponse.get(CaptchaConstants.CAPTCHA_SUCCESS).getAsBoolean(); + boolean success = verificationResponse.get(CaptchaConstants.CAPTCHA_SUCCESS).getAsBoolean(); // Whether this request was a valid reCAPTCHA token. if (!success) { throw new CaptchaClientException("reCaptcha token is invalid. Error:" + @@ -306,7 +371,7 @@ public static boolean isValidCaptcha(String reCaptchaResponse) throws CaptchaExc } if (verificationResponse.get(CaptchaConstants.CAPTCHA_SCORE) != null) { double score = verificationResponse.get(CaptchaConstants.CAPTCHA_SCORE).getAsDouble(); - // reCAPTCHA v3 response contains score + // reCAPTCHA v3 response contains score. if (log.isDebugEnabled()) { log.debug("reCAPTCHA v3 response { timestamp:" + verificationResponse.get("challenge_ts") + ", action: " + @@ -327,6 +392,45 @@ public static boolean isValidCaptcha(String reCaptchaResponse) throws CaptchaExc } catch (ClassCastException e) { throw new CaptchaServerException("Unable to cast the response value.", e); } + } + + public static boolean isValidCaptcha(String reCaptchaResponse) throws CaptchaException { + + CloseableHttpClient httpclient = HttpClientBuilder.create().useSystemProperties().build(); + String reCaptchaType = CaptchaDataHolder.getInstance().getReCaptchaType(); + + HttpPost httpPost; + + // If the reCaptcha type is defined and, it is enterprise, the enterprise process will be done. Otherwise, + // the reCaptcha v2/v3 process will be done. + if (CaptchaConstants.RE_CAPTCHA_TYPE_ENTERPRISE.equals(reCaptchaType)) { + // For ReCaptcha Enterprise. + httpPost = createReCaptchaEnterpriseVerificationHttpPost(reCaptchaResponse); + } else { + // For ReCaptcha v2 and v3. + httpPost = createReCaptchaVerificationHttpPost(reCaptchaResponse); + } + + HttpResponse response; + try { + response = httpclient.execute(httpPost); + } catch (IOException e) { + throw new CaptchaServerException("Unable to get the verification response."); + } + + HttpEntity responseEntity = response.getEntity(); + + if (responseEntity == null) { + throw new CaptchaServerException("reCaptcha verification response is not received."); + } + + if (CaptchaConstants.RE_CAPTCHA_TYPE_ENTERPRISE.equals(reCaptchaType)) { + // For ReCaptcha Enterprise. + verifyReCaptchaEnterpriseResponse(responseEntity); + } else { + // For Recaptcha v2 and v3. + verifyReCaptchaResponse(responseEntity); + } return true; } @@ -456,6 +560,20 @@ private static UserStoreManager getUserStoreManagerForUser(String userName, private static void setReCaptchaConfigs(Properties properties) { + String reCaptchaType = properties.getProperty(CaptchaConstants.RE_CAPTCHA_TYPE); + if (!StringUtils.isBlank(reCaptchaType)) { + CaptchaDataHolder.getInstance().setReCaptchaType(reCaptchaType); + } + + if (CaptchaConstants.RE_CAPTCHA_TYPE_ENTERPRISE.equals(reCaptchaType)) { + // ReCaptcha Enterprise require Project ID. + String reCaptchaProjectID = properties.getProperty(CaptchaConstants.RE_CAPTCHA_PROJECT_ID); + if (StringUtils.isBlank(reCaptchaProjectID)) { + throw new RuntimeException(getValidationErrorMessage(CaptchaConstants.RE_CAPTCHA_PROJECT_ID)); + } + CaptchaDataHolder.getInstance().setReCaptchaProjectID(reCaptchaProjectID); + } + String reCaptchaAPIUrl = properties.getProperty(CaptchaConstants.RE_CAPTCHA_API_URL); if (StringUtils.isBlank(reCaptchaAPIUrl)) { throw new RuntimeException(getValidationErrorMessage(CaptchaConstants.RE_CAPTCHA_API_URL)); @@ -692,6 +810,15 @@ public static Boolean isReCaptchaEnabled() { return CaptchaDataHolder.getInstance().isReCaptchaEnabled(); } + /** + * Get the ReCaptcha Type. + * @return ReCaptcha Type as a String. + */ + public static String reCaptchaType() { + + return CaptchaDataHolder.getInstance().getReCaptchaType(); + } + /** * Check whether ReCaptcha is enabled for the given flow. * diff --git a/components/org.wso2.carbon.identity.captcha/src/test/java/org/wso2/carbon/identity/captcha/util/CaptchaUtilTest.java b/components/org.wso2.carbon.identity.captcha/src/test/java/org/wso2/carbon/identity/captcha/util/CaptchaUtilTest.java new file mode 100644 index 0000000000..37cbdc497e --- /dev/null +++ b/components/org.wso2.carbon.identity.captcha/src/test/java/org/wso2/carbon/identity/captcha/util/CaptchaUtilTest.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2023, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * + * 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 org.wso2.carbon.identity.captcha.util; + +import com.google.gson.JsonObject; +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.HttpPost; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.captcha.internal.CaptchaDataHolder; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import static org.testng.Assert.*; + +/** + * Unit tests for CaptchaUtil.java + */ +public class CaptchaUtilTest { + + private final String RECAPTCHA_API_URL = "https://www.google.com/recaptcha/api/siteverify"; + + @BeforeMethod + public void setUp() { + + MockitoAnnotations.openMocks(this); + } + + private Method getCreateReCaptchaEnterpriseVerificationHttpPostMethod() throws NoSuchMethodException { + + Method method = CaptchaUtil.class.getDeclaredMethod("createReCaptchaEnterpriseVerificationHttpPost", + String.class); + method.setAccessible(true); + return method; + } + + private Method getCreateReCaptchaVerificationHttpPostMethod() throws NoSuchMethodException { + + Method method = CaptchaUtil.class.getDeclaredMethod("createReCaptchaVerificationHttpPost", + String.class); + method.setAccessible(true); + return method; + } + + private Method getVerifyReCaptchaEnterpriseResponseMethod() throws NoSuchMethodException { + + Method method = CaptchaUtil.class.getDeclaredMethod("verifyReCaptchaEnterpriseResponse", + HttpEntity.class); + method.setAccessible(true); + return method; + } + + private Method getVerifyReCaptchaResponseMethod() throws NoSuchMethodException { + + Method method = CaptchaUtil.class.getDeclaredMethod("verifyReCaptchaResponse", + HttpEntity.class); + method.setAccessible(true); + return method; + } + + private JsonObject getReCaptchaEnterpriseJsonObject(boolean valid, double score) { + + JsonObject verificationResponse = new JsonObject(); + JsonObject tokenProperties = new JsonObject(); + tokenProperties.addProperty(CaptchaConstants.CAPTCHA_VALID, valid); + verificationResponse.add(CaptchaConstants.CAPTCHA_TOKEN_PROPERTIES, tokenProperties); + JsonObject riskAnalysis = new JsonObject(); + riskAnalysis.addProperty(CaptchaConstants.CAPTCHA_SCORE, score); + verificationResponse.add(CaptchaConstants.CAPTCHA_RISK_ANALYSIS, riskAnalysis); + return verificationResponse; + } + + private JsonObject getReCaptchaJsonObject(boolean valid, double score) { + + JsonObject verificationResponse = new JsonObject(); + verificationResponse.addProperty(CaptchaConstants.CAPTCHA_SUCCESS, valid); + verificationResponse.addProperty(CaptchaConstants.CAPTCHA_SCORE, score); + return verificationResponse; + } + + @Test (description = "This method is used to test the createReCaptchaEnterpriseVerificationHttpPost method") + public void testCreateReCaptchaEnterpriseVerificationHttpPost() throws NoSuchMethodException, + InvocationTargetException, IllegalAccessException { + + CaptchaDataHolder.getInstance().setReCaptchaVerifyUrl(RECAPTCHA_API_URL); + CaptchaDataHolder.getInstance().setReCaptchaSecretKey("dummyKey"); + CaptchaDataHolder.getInstance().setReCaptchaSiteKey("dummySiteKey"); + CaptchaDataHolder.getInstance().setReCaptchaProjectID("dummyProjectId"); + + + + Method method = getCreateReCaptchaEnterpriseVerificationHttpPostMethod(); + HttpPost httpPost = (HttpPost) method.invoke(null, "reCaptchaEnterpriseResponse"); + String expectedURI = RECAPTCHA_API_URL+ "/v1/projects/dummyProjectId/assessments?key=dummyKey"; + Assert.assertEquals(httpPost.getURI().toString(), expectedURI); + + } + + @Test (description = "This method is used to test the createReCaptchaEnterpriseVerificationHttpPost method") + public void testCreateReCaptchaVerificationHttpPost() throws NoSuchMethodException, + InvocationTargetException, IllegalAccessException { + + CaptchaDataHolder.getInstance().setReCaptchaVerifyUrl(RECAPTCHA_API_URL); + CaptchaDataHolder.getInstance().setReCaptchaSecretKey("dummyKey"); + CaptchaDataHolder.getInstance().setReCaptchaSiteKey("dummySiteKey"); + CaptchaDataHolder.getInstance().setReCaptchaProjectID("dummyProjectId"); + + Method method = getCreateReCaptchaVerificationHttpPostMethod(); + HttpPost httpPost = (HttpPost) method.invoke(null, "reCaptchaEnterpriseResponse"); + Assert.assertEquals(httpPost.getURI().toString(), RECAPTCHA_API_URL); + } + + + @Test (description = "This method is used to test the verifyReCaptchaEnterpriseResponse method, " + + "with high captcha score") + public void testVerifyReCaptchaEnterpriseResponseWithHighScore() throws IOException, NoSuchMethodException, + InvocationTargetException, IllegalAccessException { + + CaptchaDataHolder.getInstance().setReCaptchaScoreThreshold(CaptchaConstants.CAPTCHA_V3_DEFAULT_THRESHOLD); + + JsonObject verificationResponse = getReCaptchaEnterpriseJsonObject(true, 0.7); + Method method = getVerifyReCaptchaEnterpriseResponseMethod(); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + Mockito.when(httpEntity.getContent()).thenReturn(new ByteArrayInputStream( + verificationResponse.toString().getBytes())); + // verify no exception is thrown for high score + method.invoke(null, httpEntity); + } + + @Test (description = "This method is used to test the verifyReCaptchaEnterpriseResponse method, " + + "with low captcha score") + public void testVerifyReCaptchaEnterpriseResponseWithLowScore() throws IOException, NoSuchMethodException { + + CaptchaDataHolder.getInstance().setReCaptchaScoreThreshold(CaptchaConstants.CAPTCHA_V3_DEFAULT_THRESHOLD); + + JsonObject verificationResponse = getReCaptchaEnterpriseJsonObject(true, 0.4); + Method method = getVerifyReCaptchaEnterpriseResponseMethod(); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + Mockito.when(httpEntity.getContent()).thenReturn(new ByteArrayInputStream(verificationResponse.toString(). + getBytes())); + // verify an exception is thrown for low score + assertThrows(InvocationTargetException.class, () -> method.invoke(null, httpEntity)); + } + + @Test (description = "This method is used to test the verifyReCaptchaEnterpriseResponse method, " + + "with invalid response") + public void testVerifyReCaptchaEnterpriseResponseWithInvalidResponse() throws IOException, NoSuchMethodException { + + CaptchaDataHolder.getInstance().setReCaptchaScoreThreshold(CaptchaConstants.CAPTCHA_V3_DEFAULT_THRESHOLD); + + JsonObject verificationResponse = getReCaptchaEnterpriseJsonObject(false, 0.7); + Method method = getVerifyReCaptchaEnterpriseResponseMethod(); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + Mockito.when(httpEntity.getContent()).thenReturn(new ByteArrayInputStream(verificationResponse. + toString().getBytes())); + // verify an exception is thrown for invalid response + assertThrows(InvocationTargetException.class, () -> method.invoke(null, httpEntity)); + } + + @Test (description = "This method is used to test the verifyReCaptchaResponse method, " + + "with high captcha score") + public void testVerifyReCaptchaResponseWithHighScore() throws IOException, NoSuchMethodException, + InvocationTargetException, IllegalAccessException { + + CaptchaDataHolder.getInstance().setReCaptchaScoreThreshold(CaptchaConstants.CAPTCHA_V3_DEFAULT_THRESHOLD); + + JsonObject verificationResponse = getReCaptchaJsonObject(true, 0.7); + Method method = getVerifyReCaptchaResponseMethod(); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + Mockito.when(httpEntity.getContent()).thenReturn(new ByteArrayInputStream(verificationResponse.toString(). + getBytes())); + // verify no exception is thrown for high score + method.invoke(null, httpEntity); + } + + @Test (description = "This method is used to test the verifyReCaptchaResponse method, " + + "with low captcha score") + public void testVerifyReCaptchaResponseWithLowScore() throws IOException, NoSuchMethodException { + + CaptchaDataHolder.getInstance().setReCaptchaScoreThreshold(CaptchaConstants.CAPTCHA_V3_DEFAULT_THRESHOLD); + + JsonObject verificationResponse = getReCaptchaJsonObject(true, 0.4); + Method method = getVerifyReCaptchaResponseMethod(); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + Mockito.when(httpEntity.getContent()).thenReturn(new ByteArrayInputStream(verificationResponse.toString(). + getBytes())); + // verify no exception is thrown for low score + assertThrows(InvocationTargetException.class, () -> method.invoke(null, httpEntity)); + } + + @Test (description = "This method is used to test the verifyReCaptchaResponse method, " + + "with invalid response") + public void testVerifyReCaptchaResponseWithInvalidResponse() throws IOException, NoSuchMethodException { + + CaptchaDataHolder.getInstance().setReCaptchaScoreThreshold(CaptchaConstants.CAPTCHA_V3_DEFAULT_THRESHOLD); + + JsonObject verificationResponse = getReCaptchaJsonObject(false, 0.7); + Method method = getVerifyReCaptchaResponseMethod(); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + Mockito.when(httpEntity.getContent()).thenReturn(new ByteArrayInputStream(verificationResponse.toString(). + getBytes())); + // verify no exception is thrown for invalid response + assertThrows(InvocationTargetException.class, () -> method.invoke(null, httpEntity)); + } +} diff --git a/components/org.wso2.carbon.identity.captcha/src/test/resources/testng.xml b/components/org.wso2.carbon.identity.captcha/src/test/resources/testng.xml new file mode 100644 index 0000000000..9dbe5a2ae6 --- /dev/null +++ b/components/org.wso2.carbon.identity.captcha/src/test/resources/testng.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/features/org.wso2.carbon.identity.captcha.server.feature/resources/conf/captcha-config.properties.j2 b/features/org.wso2.carbon.identity.captcha.server.feature/resources/conf/captcha-config.properties.j2 index 45078ab9ef..bd014187fa 100644 --- a/features/org.wso2.carbon.identity.captcha.server.feature/resources/conf/captcha-config.properties.j2 +++ b/features/org.wso2.carbon.identity.captcha.server.feature/resources/conf/captcha-config.properties.j2 @@ -21,6 +21,9 @@ # Enable Google reCAPTCHA recaptcha.enabled={{recaptcha.enabled}} +# captcha type +recaptcha.type={{recaptcha.type}} + # Forcefully enable Google reCAPTCHA for all tenants recaptcha.forcefullyEnabledForAllTenants={{recaptcha.forcefully_enabled_for_all_tenants}} @@ -36,6 +39,9 @@ recaptcha.site.key={{recaptcha.site_key}} # reCaptcha secret key recaptcha.secret.key={{recaptcha.secret_key}} +# reCaptcha Enterprise project id +recaptcha.project.id={{recaptcha.project_id}} + # login.do URL paths {% if recaptcha.redirect_urls is defined %} recaptcha.failed.redirect.urls={{recaptcha.redirect_urls}}