From aa6771205adafa7790f812f4fb41cec144464ee2 Mon Sep 17 00:00:00 2001 From: Lucy Linder Date: Sat, 3 Feb 2024 15:37:22 +0100 Subject: [PATCH] Update ReCAPTCHA and add support for ReCAPTCHA Enterprise Closes #16138 Signed-off-by: Lucy Linder --- .../forms/AbstractRegistrationRecaptcha.java | 210 +++++++++++++++ .../forms/RecaptchaAssessmentRequest.java | 89 +++++++ .../forms/RecaptchaAssessmentResponse.java | 247 ++++++++++++++++++ .../forms/RegistrationRecaptcha.java | 247 ++++-------------- .../RegistrationRecaptchaEnterprise.java | 179 +++++++++++++ ....keycloak.authentication.FormActionFactory | 1 + .../authentication/InitialFlowsTest.java | 2 +- .../admin/authentication/ProvidersTest.java | 7 +- .../resources/theme/base/login/register.ftl | 26 +- 9 files changed, 809 insertions(+), 199 deletions(-) create mode 100644 services/src/main/java/org/keycloak/authentication/forms/AbstractRegistrationRecaptcha.java create mode 100644 services/src/main/java/org/keycloak/authentication/forms/RecaptchaAssessmentRequest.java create mode 100644 services/src/main/java/org/keycloak/authentication/forms/RecaptchaAssessmentResponse.java create mode 100644 services/src/main/java/org/keycloak/authentication/forms/RegistrationRecaptchaEnterprise.java diff --git a/services/src/main/java/org/keycloak/authentication/forms/AbstractRegistrationRecaptcha.java b/services/src/main/java/org/keycloak/authentication/forms/AbstractRegistrationRecaptcha.java new file mode 100644 index 0000000000..f207103933 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/forms/AbstractRegistrationRecaptcha.java @@ -0,0 +1,210 @@ +/* + * + * * Copyright 2024 Red Hat, Inc. and/or its affiliates + * * and other contributors as indicated by the @author tags. + * * + * * 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.keycloak.authentication.forms; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.google.common.base.Strings; +import jakarta.ws.rs.core.MultivaluedMap; +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.events.Errors; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.validation.Validation; + +public abstract class AbstractRegistrationRecaptcha implements FormAction, FormActionFactory { + + public static final String G_RECAPTCHA_RESPONSE = "g-recaptcha-response"; + public static final String RECAPTCHA_REFERENCE_CATEGORY = "recaptcha"; + + // option keys + public static final String SITE_KEY = "site.key"; + public static final String ACTION = "action"; + public static final String INVISIBLE = "recaptcha.v3"; + public static final String USE_RECAPTCHA_NET = "useRecaptchaNet"; + + private static final Logger LOGGER = Logger.getLogger(AbstractRegistrationRecaptcha.class); + + @Override + public String getReferenceCategory() { + return RECAPTCHA_REFERENCE_CATEGORY; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return new AuthenticationExecutionModel.Requirement[] { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + } + + protected String getRecaptchaDomain(Map config) { + return Boolean.parseBoolean(config.get(USE_RECAPTCHA_NET)) ? "recaptcha.net" : "google.com"; + } + + @Override + public void buildPage(FormContext context, LoginFormsProvider form) { + LOGGER.trace("Building page with reCAPTCHA"); + + Map config = context.getAuthenticatorConfig().getConfig(); + + if (config == null) { + form.addError(new FormMessage(null, Messages.RECAPTCHA_NOT_CONFIGURED)); + return; + } + + if (!validateConfig(config)) { + form.addError(new FormMessage(null, Messages.RECAPTCHA_NOT_CONFIGURED)); + return; + } + + String userLanguageTag = context.getSession().getContext().resolveLocale(context.getUser()) + .toLanguageTag(); + boolean invisible = Boolean.parseBoolean(config.get(INVISIBLE)); + String action = Strings.isNullOrEmpty(config.get(ACTION)) ? "register" : config.get(ACTION); + + form.setAttribute("recaptchaRequired", true); + form.setAttribute("recaptchaSiteKey", config.get(SITE_KEY)); + form.setAttribute("recaptchaAction", action); + form.setAttribute("recaptchaVisible", !invisible); + form.addScript(getScriptUrl(config, userLanguageTag)); + } + + protected abstract String getScriptUrl(Map config, String userLanguageTag); + + protected abstract boolean validateConfig(Map config); + + @Override + public void validate(ValidationContext context) { + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + String captcha = formData.getFirst(G_RECAPTCHA_RESPONSE); + LOGGER.trace("Got captcha: " + captcha); + + Map config = context.getAuthenticatorConfig().getConfig(); + + if (!Validation.isBlank(captcha)) { + if (validate(context, captcha, config)) { + context.success(); + return; + } + } + + List errors = new ArrayList<>(); + errors.add(new FormMessage(null, Messages.RECAPTCHA_FAILED)); + formData.remove(G_RECAPTCHA_RESPONSE); + context.error(Errors.INVALID_REGISTRATION); + context.validationError(formData, errors); + context.excludeOtherErrors(); + + } + + protected abstract boolean validate(ValidationContext context, String captcha, Map config); + + @Override + public void success(FormContext context) { + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public void close() { + } + + @Override + public FormAction create(KeycloakSession session) { + return this; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public List getConfigProperties() { + return ProviderConfigurationBuilder.create() + .property() + .name(ACTION) + .label("Action Name") + .helpText("A meaningful name for this reCAPTCHA context (e.g. login, register). " + + "An action name can only contain alphanumeric characters, " + + "slashes and underscores and is not case-sensitive.") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue("register") + .add() + .property() + .name(USE_RECAPTCHA_NET) + .label("Use recaptcha.net") + .helpText("Whether to use recaptcha.net instead of google.com, " + + "which may have other cookies set.") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(false) + .add() + .property() + .name(INVISIBLE) + .label("reCAPTCHA v3") + .helpText("Whether the site key belongs to a v3 (invisible, score-based reCAPTCHA) " + + "or v2 site (visible, checkbox-based).") + .type(ProviderConfigProperty.BOOLEAN_TYPE) + .defaultValue(false) + .add() + .build(); + } +} diff --git a/services/src/main/java/org/keycloak/authentication/forms/RecaptchaAssessmentRequest.java b/services/src/main/java/org/keycloak/authentication/forms/RecaptchaAssessmentRequest.java new file mode 100644 index 0000000000..d729eb13cf --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/forms/RecaptchaAssessmentRequest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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.keycloak.authentication.forms; + +import static java.lang.String.format; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RecaptchaAssessmentRequest { + @JsonProperty("event") + private Event event; + + public RecaptchaAssessmentRequest(String token, String siteKey, String action) { + this.event = new Event(token, siteKey, action); + } + + public String toString() { + return format("RecaptchaAssessmentRequest(event=%s)", this.getEvent()); + } + + public Event getEvent() { + return event; + } + + public void setEvent(Event event) { + this.event = event; + } + + public static class Event { + @JsonProperty("token") + private String token; + + @JsonProperty("siteKey") + private String siteKey; + + @JsonProperty("expectedAction") + private String action; + + public Event(String token, String siteKey, String action) { + this.token = token; + this.siteKey = siteKey; + this.action = action; + } + + public String toString() { + return format("Event(token=%s, siteKey=%s, action=%s)", + this.getToken(), this.getSiteKey(), this.getAction()); + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getSiteKey() { + return siteKey; + } + + public void setSiteKey(String siteKey) { + this.siteKey = siteKey; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + } +} diff --git a/services/src/main/java/org/keycloak/authentication/forms/RecaptchaAssessmentResponse.java b/services/src/main/java/org/keycloak/authentication/forms/RecaptchaAssessmentResponse.java new file mode 100644 index 0000000000..e50dd24376 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/forms/RecaptchaAssessmentResponse.java @@ -0,0 +1,247 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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.keycloak.authentication.forms; + +import static java.lang.String.format; + +import java.util.Arrays; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RecaptchaAssessmentResponse { + + @JsonProperty("name") + private String name; + + @JsonProperty("riskAnalysis") + private RiskAnalysis riskAnalysis; + + @JsonProperty("tokenProperties") + private TokenProperties tokenProperties; + + @JsonProperty("event") + private Event event; + + public String toString() { + return format("RecaptchaAssessmentResponse(name=%s, riskAnalysis=%s, tokenProperties=%s, event=%s)", + this.getName(), this.getRiskAnalysis(), this.getTokenProperties(), this.getEvent()); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public RiskAnalysis getRiskAnalysis() { + return riskAnalysis; + } + + public void setRiskAnalysis(RiskAnalysis riskAnalysis) { + this.riskAnalysis = riskAnalysis; + } + + public TokenProperties getTokenProperties() { + return tokenProperties; + } + + public void setTokenProperties(TokenProperties tokenProperties) { + this.tokenProperties = tokenProperties; + } + + public Event getEvent() { + return event; + } + + public void setEvent(Event event) { + this.event = event; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RiskAnalysis { + @JsonProperty("score") + private double score; + + @JsonProperty("reasons") + private String[] reasons; + + public String toString() { + return format("RiskAnalysis(score=%s, reasons=%s)", this.getScore(), Arrays.toString(this.getReasons())); + } + + public double getScore() { + return score; + } + + public void setScore(double score) { + this.score = score; + } + + public String[] getReasons() { + return reasons; + } + + public void setReasons(String[] reasons) { + this.reasons = reasons; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TokenProperties { + @JsonProperty("valid") + private boolean valid; + + @JsonProperty("invalidReason") + private String invalidReason; + + @JsonProperty("hostname") + private String hostname; + + @JsonProperty("action") + private String action; + + @JsonProperty("createTime") + private String createTime; + + public String toString() { + return format("TokenProperties(valid=%s, invalidReason=%s, hostname=%s, action=%s, createTime=%s)", + this.isValid(), this.getInvalidReason(), this.getHostname(), this.getAction(), + this.getCreateTime()); + } + + public boolean isValid() { + return valid; + } + + public void setValid(boolean valid) { + this.valid = valid; + } + + public String getInvalidReason() { + return invalidReason; + } + + public void setInvalidReason(String invalidReason) { + this.invalidReason = invalidReason; + } + + public String getHostname() { + return hostname; + } + + public void setHostname(String hostname) { + this.hostname = hostname; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getCreateTime() { + return createTime; + } + + public void setCreateTime(String createTime) { + this.createTime = createTime; + } + + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Event { + + @JsonProperty("expectedAction") + private String expectedAction; + + @JsonProperty("hashedAccountId") + private String hashedAccountId; + + @JsonProperty("siteKey") + private String siteKey; + + @JsonProperty("token") + private String token; + + @JsonProperty("userAgent") + private String userAgent; + + @JsonProperty("userIpAddress") + private String userIpAddress; + + public String toString() { + return format("Event(expectedAction=%s, userAgent=%s)", this.getExpectedAction(), this.getUserAgent()); + } + + public String getExpectedAction() { + return expectedAction; + } + + public void setExpectedAction(String expectedAction) { + this.expectedAction = expectedAction; + } + + public String getHashedAccountId() { + return hashedAccountId; + } + + public void setHashedAccountId(String hashedAccountId) { + this.hashedAccountId = hashedAccountId; + } + + public String getSiteKey() { + return siteKey; + } + + public void setSiteKey(String siteKey) { + this.siteKey = siteKey; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + public String getUserIpAddress() { + return userIpAddress; + } + + public void setUserIpAddress(String userIpAddress) { + this.userIpAddress = userIpAddress; + } + + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/forms/RegistrationRecaptcha.java b/services/src/main/java/org/keycloak/authentication/forms/RegistrationRecaptcha.java index 495a387914..78d25c6782 100755 --- a/services/src/main/java/org/keycloak/authentication/forms/RegistrationRecaptcha.java +++ b/services/src/main/java/org/keycloak/authentication/forms/RegistrationRecaptcha.java @@ -17,146 +17,70 @@ package org.keycloak.authentication.forms; -import org.apache.http.NameValuePair; -import org.apache.http.client.entity.UrlEncodedFormEntity; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.message.BasicNameValuePair; -import org.jboss.logging.Logger; -import org.keycloak.Config; -import org.keycloak.authentication.FormAction; -import org.keycloak.authentication.FormActionFactory; -import org.keycloak.authentication.FormContext; -import org.keycloak.authentication.ValidationContext; -import org.keycloak.connections.httpclient.HttpClientProvider; -import org.keycloak.events.Details; -import org.keycloak.events.Errors; -import org.keycloak.forms.login.LoginFormsProvider; -import org.keycloak.models.AuthenticationExecutionModel; -import org.keycloak.models.AuthenticatorConfigModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.RealmModel; -import org.keycloak.models.UserModel; -import org.keycloak.models.utils.FormMessage; -import org.keycloak.provider.ConfiguredProvider; -import org.keycloak.provider.ProviderConfigProperty; -import org.keycloak.services.ServicesLogger; -import org.keycloak.services.messages.Messages; -import org.keycloak.services.validation.Validation; -import org.keycloak.util.JsonSerialization; - import java.io.InputStream; -import jakarta.ws.rs.core.MultivaluedMap; -import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Optional; + +import com.google.common.base.Strings; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.message.BasicNameValuePair; import org.apache.http.util.EntityUtils; +import org.jboss.logging.Logger; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.connections.httpclient.HttpClientProvider; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.services.ServicesLogger; +import org.keycloak.util.JsonSerialization; -/** - * @author Bill Burke - * @version $Revision: 1 $ - */ -public class RegistrationRecaptcha implements FormAction, FormActionFactory, ConfiguredProvider { - public static final String G_RECAPTCHA_RESPONSE = "g-recaptcha-response"; - public static final String RECAPTCHA_REFERENCE_CATEGORY = "recaptcha"; - public static final String SITE_KEY = "site.key"; - public static final String SITE_SECRET = "secret"; - public static final String USE_RECAPTCHA_NET = "useRecaptchaNet"; - private static final Logger logger = Logger.getLogger(RegistrationRecaptcha.class); +public class RegistrationRecaptcha extends AbstractRegistrationRecaptcha { + private static final Logger LOGGER = Logger.getLogger(RegistrationRecaptcha.class); public static final String PROVIDER_ID = "registration-recaptcha-action"; + // option keys + public static final String SECRET_KEY = "secret.key"; + @Override public String getDisplayType() { - return "Recaptcha"; + return "reCAPTCHA"; } @Override - public String getReferenceCategory() { - return RECAPTCHA_REFERENCE_CATEGORY; - } - - @Override - public boolean isConfigurable() { - return true; - } - - private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { - AuthenticationExecutionModel.Requirement.REQUIRED, - AuthenticationExecutionModel.Requirement.DISABLED - }; - @Override - public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { - return REQUIREMENT_CHOICES; - } - @Override - public void buildPage(FormContext context, LoginFormsProvider form) { - AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig(); - String userLanguageTag = context.getSession().getContext().resolveLocale(context.getUser()).toLanguageTag(); - if (captchaConfig == null || captchaConfig.getConfig() == null - || captchaConfig.getConfig().get(SITE_KEY) == null - || captchaConfig.getConfig().get(SITE_SECRET) == null - ) { - form.addError(new FormMessage(null, Messages.RECAPTCHA_NOT_CONFIGURED)); - return; - } - String siteKey = captchaConfig.getConfig().get(SITE_KEY); - form.setAttribute("recaptchaRequired", true); - form.setAttribute("recaptchaSiteKey", siteKey); - form.addScript("https://www." + getRecaptchaDomain(captchaConfig) + "/recaptcha/api.js?hl=" + userLanguageTag); + public String getHelpText() { + return "Adds Google reCAPTCHA to the form."; } @Override - public void validate(ValidationContext context) { - MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - List errors = new ArrayList<>(); - boolean success = false; - context.getEvent().detail(Details.REGISTER_METHOD, "form"); - - String captcha = formData.getFirst(G_RECAPTCHA_RESPONSE); - if (!Validation.isBlank(captcha)) { - AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig(); - String secret = captchaConfig.getConfig().get(SITE_SECRET); - - success = validateRecaptcha(context, success, captcha, secret); - } - if (success) { - context.success(); - } else { - errors.add(new FormMessage(null, Messages.RECAPTCHA_FAILED)); - formData.remove(G_RECAPTCHA_RESPONSE); - context.error(Errors.INVALID_REGISTRATION); - context.validationError(formData, errors); - context.excludeOtherErrors(); - return; - - - } + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return new AuthenticationExecutionModel.Requirement[] { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; } - private String getRecaptchaDomain(AuthenticatorConfigModel config) { - Boolean useRecaptcha = Optional.ofNullable(config) - .map(configModel -> configModel.getConfig()) - .map(cfg -> Boolean.valueOf(cfg.get(USE_RECAPTCHA_NET))) - .orElse(false); - if (useRecaptcha) { - return "recaptcha.net"; - } - - return "google.com"; + @Override + protected boolean validateConfig(Map config) { + return !(Strings.isNullOrEmpty(config.get(SITE_KEY)) || Strings.isNullOrEmpty(config.get(SECRET_KEY))); } - protected boolean validateRecaptcha(ValidationContext context, boolean success, String captcha, String secret) { + @Override + protected boolean validate(ValidationContext context, String captcha, Map config) { + LOGGER.trace("Verifying reCAPTCHA using non-enterprise API"); CloseableHttpClient httpClient = context.getSession().getProvider(HttpClientProvider.class).getHttpClient(); - HttpPost post = new HttpPost("https://www." + getRecaptchaDomain(context.getAuthenticatorConfig()) + "/recaptcha/api/siteverify"); + + HttpPost post = new HttpPost("https://www." + getRecaptchaDomain(config) + "/recaptcha/api/siteverify"); List formparams = new LinkedList<>(); - formparams.add(new BasicNameValuePair("secret", secret)); + formparams.add(new BasicNameValuePair("secret", config.get(SECRET_KEY))); formparams.add(new BasicNameValuePair("response", captcha)); formparams.add(new BasicNameValuePair("remoteip", context.getConnection().getRemoteAddr())); + try { UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8"); post.setEntity(form); @@ -164,8 +88,7 @@ public class RegistrationRecaptcha implements FormAction, FormActionFactory, Con InputStream content = response.getEntity().getContent(); try { Map json = JsonSerialization.readValue(content, Map.class); - Object val = json.get("success"); - success = Boolean.TRUE.equals(val); + return Boolean.TRUE.equals(json.get("success")); } finally { EntityUtils.consumeQuietly(response.getEntity()); } @@ -173,53 +96,12 @@ public class RegistrationRecaptcha implements FormAction, FormActionFactory, Con } catch (Exception e) { ServicesLogger.LOGGER.recaptchaFailed(e); } - return success; - } - - @Override - public void success(FormContext context) { - - } - - @Override - public boolean requiresUser() { return false; } @Override - public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { - return true; - } - - @Override - public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { - - } - - @Override - public boolean isUserSetupAllowed() { - return false; - } - - - @Override - public void close() { - - } - - @Override - public FormAction create(KeycloakSession session) { - return this; - } - - @Override - public void init(Config.Scope config) { - - } - - @Override - public void postInit(KeycloakSessionFactory factory) { - + protected String getScriptUrl(Map config, String userLanguageTag) { + return "https://www." + getRecaptchaDomain(config) + "/recaptcha/api.js?hl=" + userLanguageTag; } @Override @@ -227,39 +109,24 @@ public class RegistrationRecaptcha implements FormAction, FormActionFactory, Con return PROVIDER_ID; } - @Override - public String getHelpText() { - return "Adds Google Recaptcha button. Recaptchas verify that the entity that is registering is a human. This can only be used on the internet and must be configured after you add it."; - } - - private static final List CONFIG_PROPERTIES = new ArrayList(); - - static { - ProviderConfigProperty property; - property = new ProviderConfigProperty(); - property.setName(SITE_KEY); - property.setLabel("Recaptcha Site Key"); - property.setType(ProviderConfigProperty.STRING_TYPE); - property.setHelpText("Google Recaptcha Site Key"); - CONFIG_PROPERTIES.add(property); - property = new ProviderConfigProperty(); - property.setName(SITE_SECRET); - property.setLabel("Recaptcha Secret"); - property.setType(ProviderConfigProperty.STRING_TYPE); - property.setHelpText("Google Recaptcha Secret"); - CONFIG_PROPERTIES.add(property); - - property = new ProviderConfigProperty(); - property.setName(USE_RECAPTCHA_NET); - property.setLabel("use recaptcha.net"); - property.setType(ProviderConfigProperty.BOOLEAN_TYPE); - property.setHelpText("Use recaptcha.net? (or else google.com)"); - CONFIG_PROPERTIES.add(property); - } - - @Override public List getConfigProperties() { - return CONFIG_PROPERTIES; + List properties = ProviderConfigurationBuilder.create() + .property() + .name(SITE_KEY) + .label("reCAPTCHA Site Key") + .helpText("The site key.") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property() + .name(SECRET_KEY) + .label("reCAPTCHA Secret") + .helpText("The secret key.") + .type(ProviderConfigProperty.STRING_TYPE) + .secret(true) + .add() + .build(); + properties.addAll(super.getConfigProperties()); + return properties; } } diff --git a/services/src/main/java/org/keycloak/authentication/forms/RegistrationRecaptchaEnterprise.java b/services/src/main/java/org/keycloak/authentication/forms/RegistrationRecaptchaEnterprise.java new file mode 100644 index 0000000000..918b3f6b34 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/forms/RegistrationRecaptchaEnterprise.java @@ -0,0 +1,179 @@ +/* + * + * * Copyright 2024 Red Hat, Inc. and/or its affiliates + * * and other contributors as indicated by the @author tags. + * * + * * 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.keycloak.authentication.forms; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import com.google.common.base.Strings; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.jboss.logging.Logger; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.connections.httpclient.HttpClientProvider; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.services.ServicesLogger; +import org.keycloak.util.JsonSerialization; + +public class RegistrationRecaptchaEnterprise extends AbstractRegistrationRecaptcha { + public static final String PROVIDER_ID = "registration-recaptcha-enterprise"; + + // option keys + public static final String PROJECT_ID = "project.id"; + public static final String API_KEY = "api.key"; + public static final String SCORE_THRESHOLD = "score.threshold"; + + private static final Logger LOGGER = Logger.getLogger(RegistrationRecaptchaEnterprise.class); + + @Override + public String getDisplayType() { + return "reCAPTCHA Enterprise"; + } + + @Override + public String getHelpText() { + return "Adds Google reCAPTCHA Enterprise to the form."; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + protected boolean validateConfig(Map config) { + return !(Stream.of(PROJECT_ID, SITE_KEY, API_KEY, ACTION) + .anyMatch(key -> Strings.isNullOrEmpty(config.get(key))) + || parseDoubleFromConfig(config, SCORE_THRESHOLD) == null); + } + + @Override + protected String getScriptUrl(Map config, String userLanguageTag) { + return "https://www." + getRecaptchaDomain(config) + "/recaptcha/enterprise.js?hl=" + userLanguageTag; + + } + + @Override + protected boolean validate(ValidationContext context, String captcha, Map config) { + LOGGER.trace("Requesting assessment of Google reCAPTCHA Enterprise"); + try { + HttpPost request = buildAssessmentRequest(captcha, config); + HttpClient httpClient = context.getSession().getProvider(HttpClientProvider.class).getHttpClient(); + HttpResponse response = httpClient.execute(request); + + if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { + LOGGER.errorf("Could not create reCAPTCHA assessment: %s", response.getStatusLine()); + EntityUtils.consumeQuietly(response.getEntity()); + throw new Exception(response.getStatusLine().getReasonPhrase()); + } + + RecaptchaAssessmentResponse assessment = JsonSerialization.readValue( + response.getEntity().getContent(), RecaptchaAssessmentResponse.class); + LOGGER.tracef("Got assessment response: %s", assessment); + + String tokenAction = assessment.getTokenProperties().getAction(); + String expectedAction = assessment.getEvent().getExpectedAction(); + if (!tokenAction.equals(expectedAction)) { + // This may indicates that an attacker is attempting to falsify actions + LOGGER.warnf("The action name of the reCAPTCHA token '%s' does not match the expected action '%s'!", + tokenAction, expectedAction); + return false; + } + + boolean valid = assessment.getTokenProperties().isValid(); + double score = assessment.getRiskAnalysis().getScore(); + LOGGER.debugf("reCAPTCHA assessment: valid=%s, score=%f", valid, score); + + return valid && score >= parseDoubleFromConfig(config, SCORE_THRESHOLD); + + } catch (Exception e) { + ServicesLogger.LOGGER.recaptchaFailed(e); + } + + return false; + } + + private HttpPost buildAssessmentRequest(String captcha, Map config) throws IOException { + + String url = String.format("https://recaptchaenterprise.googleapis.com/v1/projects/%s/assessments?key=%s", + config.get(PROJECT_ID), config.get(API_KEY)); + + HttpPost request = new HttpPost(url); + RecaptchaAssessmentRequest body = new RecaptchaAssessmentRequest( + captcha, config.get(SITE_KEY), config.get(ACTION)); + request.setEntity(new StringEntity(JsonSerialization.writeValueAsString(body))); + request.setHeader("Content-type", "application/json; charset=utf-8"); + + LOGGER.tracef("Built assessment request: %s", body); + return request; + } + + @Override + public List getConfigProperties() { + List properties = ProviderConfigurationBuilder.create() + .property() + .name(PROJECT_ID) + .label("Project ID") + .helpText("Project ID the site key belongs to.") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property() + .name(SITE_KEY) + .label("reCAPTCHA Site Key") + .helpText("The site key.") + .type(ProviderConfigProperty.STRING_TYPE) + .add() + .property() + .name(API_KEY) + .label("Google API Key") + .helpText("An API key with the reCAPTCHA Enterprise API enabled in the given project ID.") + .type(ProviderConfigProperty.STRING_TYPE) + .secret(true) + .add() + .property() + .name(SCORE_THRESHOLD) + .label("Min. Score Threshold") + .helpText("The minimum score threshold for considering the reCAPTCHA valid (inclusive). " + + "Must be a valid double between 0.0 and 1.0.") + .type(ProviderConfigProperty.STRING_TYPE) + .defaultValue("0.7") + .add() + .build(); + properties.addAll(super.getConfigProperties()); + return properties; + } + + private Double parseDoubleFromConfig(Map config, String key) { + String value = config.getOrDefault(key, ""); + try { + return Double.parseDouble(value); + } catch (NumberFormatException e) { + LOGGER.warnf("Could not parse config %s as double: '%s'", key, value); + } + return null; + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory index 24db202c5d..ee936ba020 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory @@ -18,4 +18,5 @@ org.keycloak.authentication.forms.RegistrationPassword org.keycloak.authentication.forms.RegistrationUserCreation org.keycloak.authentication.forms.RegistrationRecaptcha +org.keycloak.authentication.forms.RegistrationRecaptchaEnterprise org.keycloak.authentication.forms.RegistrationTermsAndConditions diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/InitialFlowsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/InitialFlowsTest.java index 6665a076db..f2da7d7a34 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/InitialFlowsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/InitialFlowsTest.java @@ -199,7 +199,7 @@ public class InitialFlowsTest extends AbstractAuthenticationTest { addExecInfo(execs, "registration form", "registration-page-form", false, 0, 0, REQUIRED, true, new String[]{REQUIRED, DISABLED}); addExecInfo(execs, "Registration User Profile Creation", "registration-user-creation", false, 1, 0, REQUIRED, null, new String[]{REQUIRED, DISABLED}); addExecInfo(execs, "Password Validation", "registration-password-action", false, 1, 1, REQUIRED, null, new String[]{REQUIRED, DISABLED}); - addExecInfo(execs, "Recaptcha", "registration-recaptcha-action", true, 1, 2, DISABLED, null, new String[]{REQUIRED, DISABLED}); + addExecInfo(execs, "reCAPTCHA", "registration-recaptcha-action", true, 1, 2, DISABLED, null, new String[]{REQUIRED, DISABLED}); addExecInfo(execs, "Terms and conditions", "registration-terms-and-conditions", false, 1, 3, DISABLED, null, new String[]{REQUIRED, DISABLED}); expected.add(new FlowExecutions(flow, execs)); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java index 7b0c0cb28c..8296bb81f7 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java @@ -19,6 +19,8 @@ package org.keycloak.testsuite.admin.authentication; import org.junit.Test; import org.keycloak.authentication.authenticators.broker.IdpCreateUserIfUniqueAuthenticatorFactory; +import org.keycloak.authentication.forms.RegistrationRecaptcha; +import org.keycloak.authentication.forms.RegistrationRecaptchaEnterprise; import org.keycloak.common.Profile; import org.keycloak.representations.idm.AuthenticatorConfigInfoRepresentation; import org.keycloak.representations.idm.ConfigPropertyRepresentation; @@ -61,9 +63,8 @@ public class ProvidersTest extends AbstractAuthenticationTest { List> result = authMgmtResource.getFormActionProviders(); List> expected = new LinkedList<>(); - addProviderInfo(expected, "registration-recaptcha-action", "Recaptcha", - "Adds Google Recaptcha button. Recaptchas verify that the entity that is registering is a human. " + - "This can only be used on the internet and must be configured after you add it."); + addProviderInfo(expected, RegistrationRecaptcha.PROVIDER_ID, "reCAPTCHA", "Adds Google reCAPTCHA to the form."); + addProviderInfo(expected, RegistrationRecaptchaEnterprise.PROVIDER_ID, "reCAPTCHA Enterprise", "Adds Google reCAPTCHA Enterprise to the form."); addProviderInfo(expected, "registration-password-action", "Password Validation", "Validates that password matches password confirmation field. It also will store password in user's credential store."); addProviderInfo(expected, "registration-user-creation", "Registration User Profile Creation", diff --git a/themes/src/main/resources/theme/base/login/register.ftl b/themes/src/main/resources/theme/base/login/register.ftl index d95274a149..c054c53804 100755 --- a/themes/src/main/resources/theme/base/login/register.ftl +++ b/themes/src/main/resources/theme/base/login/register.ftl @@ -69,10 +69,10 @@ <@registerCommons.termsAcceptance/> - <#if recaptchaRequired??> + <#if recaptchaRequired?? && (recaptchaVisible!false)>
-
+
@@ -84,9 +84,25 @@ -
- -
+ <#if recaptchaRequired?? && !(recaptchaVisible!false)> + +
+ +
+ <#else> +
+ +
+