Update ReCAPTCHA and add support for ReCAPTCHA Enterprise
Closes #16138 Signed-off-by: Lucy Linder <lucy.derlin@gmail.com>
This commit is contained in:
parent
7adcc98c6c
commit
aa6771205a
9 changed files with 809 additions and 199 deletions
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
@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<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;
|
||||
|
||||
|
||||
}
|
||||
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<String, String> 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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
||||
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
|
||||
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doRegister")}"/>
|
||||
</div>
|
||||
<#if recaptchaRequired?? && !(recaptchaVisible!false)>
|
||||
<script>
|
||||
function onSubmitRecaptcha(token) {
|
||||
document.getElementById("kc-register-form").submit();
|
||||
}
|
||||
</script>
|
||||
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
|
||||
<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>
|
||||
|
|
Loading…
Reference in a new issue