Update ReCAPTCHA and add support for ReCAPTCHA Enterprise

Closes #16138

Signed-off-by: Lucy Linder <lucy.derlin@gmail.com>
This commit is contained in:
Lucy Linder 2024-02-03 15:37:22 +01:00 committed by Pedro Igor
parent 7adcc98c6c
commit aa6771205a
9 changed files with 809 additions and 199 deletions

View file

@ -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<String, String> 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<String, String> 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<String, String> config, String userLanguageTag);
protected abstract boolean validateConfig(Map<String, String> config);
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String captcha = formData.getFirst(G_RECAPTCHA_RESPONSE);
LOGGER.trace("Got captcha: " + captcha);
Map<String, String> config = context.getAuthenticatorConfig().getConfig();
if (!Validation.isBlank(captcha)) {
if (validate(context, captcha, config)) {
context.success();
return;
}
}
List<FormMessage> 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<String, String> 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<ProviderConfigProperty> 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();
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @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;
public String getHelpText() {
return "Adds Google reCAPTCHA to the form.";
}
@Override
public boolean isConfigurable() {
return true;
}
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return new AuthenticationExecutionModel.Requirement[] {
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);
}
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
List<FormMessage> 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;
}
protected boolean validateConfig(Map<String, String> config) {
return !(Strings.isNullOrEmpty(config.get(SITE_KEY)) || Strings.isNullOrEmpty(config.get(SECRET_KEY)));
}
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";
}
protected boolean validateRecaptcha(ValidationContext context, boolean success, String captcha, String secret) {
@Override
protected boolean validate(ValidationContext context, String captcha, Map<String, String> 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<NameValuePair> 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<String, String> 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<ProviderConfigProperty> CONFIG_PROPERTIES = new ArrayList<ProviderConfigProperty>();
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<ProviderConfigProperty> getConfigProperties() {
return CONFIG_PROPERTIES;
List<ProviderConfigProperty> 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;
}
}

View file

@ -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<String, String> 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<String, String> config, String userLanguageTag) {
return "https://www." + getRecaptchaDomain(config) + "/recaptcha/enterprise.js?hl=" + userLanguageTag;
}
@Override
protected boolean validate(ValidationContext context, String captcha, Map<String, String> 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<String, String> 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<ProviderConfigProperty> getConfigProperties() {
List<ProviderConfigProperty> 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<String, String> 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;
}
}

View file

@ -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

View file

@ -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));

View file

@ -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<Map<String, Object>> result = authMgmtResource.getFormActionProviders();
List<Map<String, Object>> 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",

View file

@ -69,10 +69,10 @@
<@registerCommons.termsAcceptance/>
<#if recaptchaRequired??>
<#if recaptchaRequired?? && (recaptchaVisible!false)>
<div class="form-group">
<div class="${properties.kcInputWrapperClass!}">
<div class="g-recaptcha" data-size="compact" data-sitekey="${recaptchaSiteKey}"></div>
<div class="g-recaptcha" data-size="compact" data-sitekey="${recaptchaSiteKey}" data-action="${recaptchaAction}"></div>
</div>
</div>
</#if>
@ -84,9 +84,25 @@
</div>
</div>
<#if recaptchaRequired?? && !(recaptchaVisible!false)>
<script>
function onSubmitRecaptcha(token) {
document.getElementById("kc-register-form").submit();
}
</script>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doRegister")}"/>
<button class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!} g-recaptcha"
data-sitekey="${recaptchaSiteKey}" data-callback='onSubmitRecaptcha' data-action='${recaptchaAction}' type="submit">
${msg("doRegister")}
</button>
</div>
<#else>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<button class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit">
${msg("doRegister")}
</button>
</div>
</#if>
</div>
</form>
<script type="module" src="${url.resourcesPath}/js/passwordVisibility.js"></script>