I have a custom login with recaptcha flow on Keycloak, and I have replaced the usual browser flow in the Keycloak admin dashboard with this one. Here’s the code for the custom class I’m using:
package org.keycloak.authentication.authenticator.recaptcha;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
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.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.authenticators.browser.UsernamePasswordForm;
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.AuthenticatorConfigModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.protocol.LoginProtocol.Error;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.util.JsonSerialization;
import com.google.zxing.Result;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import java.io.InputStream;
import java.util.*;
public class RecaptchaUsernamePasswordForm extends UsernamePasswordForm implements Authenticator{
public static final String G_RECAPTCHA_RESPONSE = "g-recaptcha-response";
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(RecaptchaUsernamePasswordForm.class);
private String siteKey;
@Override
public void authenticate(AuthenticationFlowContext context) {
context.getEvent().detail(Details.AUTH_METHOD, "auth_method");
displayRecaptcha(context);
super.authenticate(context);
}
private void displayRecaptcha(AuthenticationFlowContext context) {
AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig();
LoginFormsProvider form = context.form();
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 action(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
List<FormMessage> errors = new ArrayList<>();
boolean success = false;
context.getEvent().detail(Details.AUTH_METHOD, "auth_method");
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);
}
displayRecaptcha(context);
if (success) {
super.action(context);
} else {
errors.add(new FormMessage(null, Messages.RECAPTCHA_FAILED));
formData.remove(G_RECAPTCHA_RESPONSE);
context.getEvent().error(Messages.RECAPTCHA_FAILED);
Response challengeResponse = context.form()
.setError(Messages.RECAPTCHA_FAILED)
.createLoginUsernamePassword();
context.forceChallenge(challengeResponse);
return;
}
}
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(AuthenticationFlowContext context, boolean success, String captcha, String secret) {
logger.info("validateRecaptcha(AuthenticationFlowContext, boolean, String, String) start");
HttpClient httpClient = context.getSession().getProvider(HttpClientProvider.class).getHttpClient();
HttpPost post = new HttpPost("https://www." + getRecaptchaDomain(context.getAuthenticatorConfig()) + "/recaptcha/api/siteverify");
List<NameValuePair> formparams = new LinkedList<>();
formparams.add(new BasicNameValuePair("secret", secret));
formparams.add(new BasicNameValuePair("response", captcha));
try {
UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8");
post.setEntity(form);
HttpResponse response = httpClient.execute(post);
InputStream content = response.getEntity().getContent();
try {
Map json = JsonSerialization.readValue(content, Map.class);
Object val = json.get("success");
success = Boolean.TRUE.equals(val);
} finally {
content.close();
}
} catch (Exception e) {
ServicesLogger.LOGGER.recaptchaFailed(e);
}
return success;
}
}
For some reason, Keycloak’s persistent session using rememberMe is not working with this flow. It does work if I use the default browser flow. Any help or pointers would be appreciated.
Here’s the docker file I use locally if this helps:
FROM quay.io/keycloak/keycloak:22.0 as builder
# Enable health and metrics support
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
WORKDIR /opt/keycloak
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:22.0
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ADD --chown=keycloak:keycloak providers/ /opt/keycloak/providers/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start-dev --spi-theme-static-max-age=-1 --spi-theme-cache-themes=false --spi-theme-cache-templates=false"]