This commit is contained in:
KONSTANTINOS GEORGILAKIS 2026-05-24 07:40:54 +02:00 committed by GitHub
commit eda6e8636e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 183 additions and 16 deletions

View file

@ -38,6 +38,7 @@ import org.keycloak.sessions.AuthenticationSessionModel;
public interface LoginFormsProvider extends Provider {
String UPDATE_PROFILE_CONTEXT_ATTR = "updateProfileCtx";
String TERMS_ACCEPTANCE_REQUIRED = "termsAcceptanceRequired";
String IDENTITY_PROVIDER_BROKER_CONTEXT = "identityProviderBrokerCtx";

View file

@ -544,6 +544,7 @@ public class DefaultAuthenticationFlows {
reviewProfileConfig.setAlias(IDP_REVIEW_PROFILE_CONFIG_ALIAS);
Map<String, String> config = new HashMap<>();
config.put("update.profile.on.first.login", IdentityProviderRepresentation.UPFLM_MISSING);
config.put("terms_and_conditions", "false");
reviewProfileConfig.setConfig(config);
reviewProfileConfig = realm.addAuthenticatorConfig(reviewProfileConfig);

View file

@ -55,6 +55,21 @@ public interface UserProfileProvider extends Provider {
*/
UserProfile create(UserProfileContext context, Map<String, ?> attributes);
/**
* <p>Creates a new {@link UserProfile} instance for a given {@code context} and {@code attributes} for update purposes.
*
* <p>Instances created from this method are going to run validations and updates based on the given {@code user}. This
* might be useful when updating an existing user.
*
* @param context the context
* @param attributes the attributes to associate with the instance returned from this method
* @param user the user to eventually update with the given {@code attributes}
* @param terms if terms and condition required action user attribute neeed to be saved
*
* @return the user profile instance
*/
UserProfile create(UserProfileContext context, Map<String, ?> attributes, UserModel user, boolean terms);
/**
* <p>Creates a new {@link UserProfile} instance for a given {@code context} and {@code attributes} for update purposes.
*

View file

@ -17,6 +17,7 @@
package org.keycloak.authentication.authenticators.broker;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@ -28,8 +29,10 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.authentication.requiredactions.TermsAndConditions;
import org.keycloak.broker.provider.AbstractIdentityProvider;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
@ -56,7 +59,8 @@ import org.jboss.logging.Logger;
public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
private static final Logger logger = Logger.getLogger(IdpReviewProfileAuthenticator.class);
private static final String TERMS_FIELD ="termsAccepted";
private boolean enabledRequiredAction= false;
@Override
public boolean requiresUser() {
return false;
@ -65,14 +69,20 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
@Override
protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext userCtx, BrokeredIdentityContext brokerContext) {
IdentityProviderModel idpConfig = brokerContext.getIdpConfig();
enabledRequiredAction = context.getRealm().getRequiredActionProviderByAlias(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name()).isEnabled() && Boolean.valueOf(context.getAuthenticatorConfig().getConfig().get(IdpReviewProfileAuthenticatorFactory.TERMS_AND_CONDITIONS));
boolean updateProfile = requiresUpdateProfilePage(context.getAuthenticatorConfig(), context, userCtx);
if (requiresUpdateProfilePage(context, userCtx, brokerContext)) {
if ( enabledRequiredAction || updateProfile) {
// set up form only if
// 1. terms and conditions is enabled and terms and condition configuration is true
// 2. based on UPDATE_PROFILE_ON_FIRST_LOGIN value and IdP release data
logger.debugf("Identity provider '%s' requires update profile action for broker user '%s'.", idpConfig.getAlias(), userCtx.getUsername());
// No formData for first render. The profile is rendered from userCtx
Response challengeResponse = context.form()
.setAttribute(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR, userCtx)
.setAttribute(LoginFormsProvider.TERMS_ACCEPTANCE_REQUIRED, enabledRequiredAction)
.setFormData(null)
.createUpdateProfilePage();
context.challenge(challengeResponse);
@ -82,14 +92,12 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
}
}
protected boolean requiresUpdateProfilePage(AuthenticationFlowContext context, SerializedBrokeredIdentityContext userCtx, BrokeredIdentityContext brokerContext) {
String enforceUpdateProfile = context.getAuthenticationSession().getAuthNote(ENFORCE_UPDATE_PROFILE);
if (Boolean.parseBoolean(enforceUpdateProfile)) {
protected boolean requiresUpdateProfilePage(AuthenticatorConfigModel authenticatorConfig, AuthenticationFlowContext context, SerializedBrokeredIdentityContext userCtx) {
if (Boolean.parseBoolean(context.getAuthenticationSession().getAuthNote(ENFORCE_UPDATE_PROFILE))) {
return true;
}
String updateProfileFirstLogin;
AuthenticatorConfigModel authenticatorConfig = context.getAuthenticatorConfig();
if (authenticatorConfig == null || !authenticatorConfig.getConfig().containsKey(IdpReviewProfileAuthenticatorFactory.UPDATE_PROFILE_ON_FIRST_LOGIN)) {
updateProfileFirstLogin = IdentityProviderRepresentation.UPFLM_MISSING;
} else {
@ -114,6 +122,19 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
EventBuilder event = context.getEvent();
event.event(EventType.UPDATE_PROFILE).detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name());
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
if (enabledRequiredAction && ! formData.containsKey(TERMS_FIELD)) {
Response challengeForTerms = context.form()
.setErrors(Collections.singletonList(new FormMessage(TERMS_FIELD, "termsAcceptanceRequired")))
.setAttribute(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR, userCtx)
.setAttribute(LoginFormsProvider.TERMS_ACCEPTANCE_REQUIRED, enabledRequiredAction)
.setFormData(formData)
.createUpdateProfilePage();
context.challenge(challengeForTerms);
return;
}
UserModelDelegate updatedProfile = new UserModelDelegate(null) {
@Override
@ -205,7 +226,13 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
UserProfileProvider profileProvider = context.getSession().getProvider(UserProfileProvider.class);
Map<String, List<String>> attributes = new HashMap<>(formData);
attributes.putIfAbsent(UserModel.USERNAME, Collections.singletonList(updatedProfile.getUsername()));
UserProfile profile = profileProvider.create(UserProfileContext.IDP_REVIEW, attributes, updatedProfile);
if (attributes.containsKey(TERMS_FIELD)) {
//if form contains terms and condition remove this field and add TermsAndConditions.USER_ATTRIBUTE
attributes.remove(TERMS_FIELD);
attributes.put(TermsAndConditions.USER_ATTRIBUTE, Arrays.asList(Integer.toString(Time.currentTime())));
}
UserProfile profile = profileProvider.create(UserProfileContext.IDP_REVIEW, attributes, updatedProfile, formData.containsKey(TERMS_FIELD));
try {
profile.update((attributeName, userModel, oldValue) -> {
@ -231,6 +258,7 @@ public class IdpReviewProfileAuthenticator extends AbstractIdpAuthenticator {
Response challenge = context.form()
.setErrors(errors)
.setAttribute(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR, userCtx)
.setAttribute(LoginFormsProvider.TERMS_ACCEPTANCE_REQUIRED, enabledRequiredAction)
.setFormData(formData)
.createUpdateProfilePage();

View file

@ -39,6 +39,7 @@ public class IdpReviewProfileAuthenticatorFactory implements AuthenticatorFactor
static IdpReviewProfileAuthenticator SINGLETON = new IdpReviewProfileAuthenticator();
public static final String UPDATE_PROFILE_ON_FIRST_LOGIN = "update.profile.on.first.login";
public static final String TERMS_AND_CONDITIONS = "terms_and_conditions";
@Override
public Authenticator create(KeycloakSession session) {
@ -110,7 +111,13 @@ public class IdpReviewProfileAuthenticatorFactory implements AuthenticatorFactor
+ " page for reviewing profile will be displayed and user can review and update his profile. Value 'off' means that page won't be displayed."
+ " Value 'missing' means that page is displayed just when some required attribute is missing (wasn't downloaded from identity provider). Value 'missing' is the default one."
+ " WARN: In case that user clicks 'Review profile info' on link duplications page, the update page will be always displayed. You would need to disable this authenticator to never display the page.");
configProperties.add(property);
property = new ProviderConfigProperty();
property.setName(TERMS_AND_CONDITIONS);
property.setLabel("Accept Terms and Conditions");
property.setType(ProviderConfigProperty.BOOLEAN_TYPE);
property.setHelpText("Enable this option to require users to accept terms and conditions before their profile is recorded."
+ "This ensures compliance with data regulation frameworks like GDPR on first login. You also need to enable the 'Terms and Conditions' Required action for this to take effect.");
configProperties.add(property);
}

View file

@ -30,8 +30,10 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.keycloak.authentication.requiredactions.TermsAndConditions;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
@ -50,6 +52,7 @@ import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.userprofile.validator.AttributeRequiredByMetadataValidator;
import org.keycloak.userprofile.validator.ImmutableAttributeValidator;
import org.keycloak.userprofile.validator.MultiValueValidator;
import org.keycloak.userprofile.validator.ReadOnlyAttributeUnchangedValidator;
import org.keycloak.util.JsonSerialization;
import org.keycloak.validate.AbstractSimpleValidator;
import org.keycloak.validate.ValidatorConfig;
@ -127,6 +130,33 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
return createUserProfile(context, attributes, null);
}
@Override
public UserProfile create(UserProfileContext context, Map<String, ?> attributes, UserModel user, boolean terms) {
return terms ? createUserProfileWithTerms(context, attributes, user) : createUserProfile(context, attributes, user);
}
private UserProfile createUserProfileWithTerms(UserProfileContext context, Map<String, ?> attributes, UserModel user) {
UserProfileMetadata defaultMetadata = contextualMetadataRegistry.get(context);
if (defaultMetadata == null) {
// some contexts (and their metadata) are available enabled when the corresponding feature is enabled
throw new RuntimeException("No metadata is bound to the " + context + " context");
}
UserProfileMetadata metadata = configureUserProfile(defaultMetadata, session);
List<AttributeValidatorMetadata> readonlyValidators = new ArrayList<>();
readonlyValidators.add(createReadOnlyAttributeUnchangedValidator(DeclarativeUserProfileProviderFactory.readOnlyAttributesPattern));
metadata.addAttribute(TermsAndConditions.USER_ATTRIBUTE, 1000, readonlyValidators);
Attributes profileAttributes = createAttributes(context, attributes, user, metadata);
return new DefaultUserProfile(metadata, profileAttributes, createUserFactory(), user, session);
}
private AttributeValidatorMetadata createReadOnlyAttributeUnchangedValidator(Pattern pattern) {
return new AttributeValidatorMetadata(ReadOnlyAttributeUnchangedValidator.ID,
ValidatorConfig.builder().config(ReadOnlyAttributeUnchangedValidator.CFG_PATTERN, pattern)
.build());
}
private UserProfile createUserProfile(UserProfileContext context, Map<String, ?> attributes, UserModel user) {
UserProfileMetadata defaultMetadata = contextualMetadataRegistry.get(context);

View file

@ -102,7 +102,7 @@ public class DeclarativeUserProfileProviderFactory implements UserProfileProvide
*/
private static final String[] DEFAULT_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp", "userCertificate", "saml.persistent.name.id.for.*", "ENABLED", "EMAIL_VERIFIED", "disabledReason", UserModel.EMAIL_PENDING };
private static final String[] DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES = { "KERBEROS_PRINCIPAL", "LDAP_ID", "LDAP_ENTRY_DN", "CREATED_TIMESTAMP", "createTimestamp", "modifyTimestamp" };
private static final Pattern readOnlyAttributesPattern = getRegexPatternString(DEFAULT_READ_ONLY_ATTRIBUTES);
public static final Pattern readOnlyAttributesPattern = getRegexPatternString(DEFAULT_READ_ONLY_ATTRIBUTES);
private static final Pattern adminReadOnlyAttributesPattern = getRegexPatternString(DEFAULT_ADMIN_READ_ONLY_ATTRIBUTES);
private static final String ANNOTATION_SCIM_SCHEMA_ATTRIBUTE = "kc.scim.schema.attribute";

View file

@ -45,7 +45,7 @@ public class InitialFlowsTest extends AbstractAuthenticationTest {
private HashMap<String, AuthenticatorConfigRepresentation> expectedConfigs = new HashMap<>();
{
expectedConfigs.put("idp-review-profile", newConfig("review profile config", new String[]{"update.profile.on.first.login", "missing"}));
expectedConfigs.put("idp-review-profile", newConfig("review profile config", new String[]{"update.profile.on.first.login", "missing","terms_and_conditions","false"}));
expectedConfigs.put("idp-create-user-if-unique", newConfig("create unique user config", new String[]{"require.password.update.after.registration", "false"}));
}

View file

@ -24,6 +24,9 @@ public class UpdateAccountInformationPage extends LanguageComboboxAwarePage {
@FindBy(name = "department")
private WebElement departmentInput;
@FindBy(name = "termsAccepted")
private WebElement termsAccepted;
@FindBy(css = "input[type=\"submit\"]")
private WebElement submitButton;
@ -46,6 +49,26 @@ public class UpdateAccountInformationPage extends LanguageComboboxAwarePage {
clickLink(submitButton);
}
public void acceptTerms(String userName,
String email,
String firstName,
String lastName) {
usernameInput.clear();
usernameInput.sendKeys(userName);
emailInput.clear();
emailInput.sendKeys(email);
firstNameInput.clear();
firstNameInput.sendKeys(firstName);
lastNameInput.clear();
lastNameInput.sendKeys(lastName);
termsAccepted.click();
clickLink(submitButton);
}
public void updateAccountInformation(String userName,
String email,
String firstName,

View file

@ -212,6 +212,7 @@ public abstract class AbstractBrokerTest extends AbstractInitializedBaseBrokerTe
} else if (execution.getAlias() != null && execution.getAlias().equals(IDP_REVIEW_PROFILE_CONFIG_ALIAS)) {
AuthenticatorConfigRepresentation config = flows.getAuthenticatorConfig(execution.getAuthenticationConfig());
config.getConfig().put("update.profile.on.first.login", IdentityProviderRepresentation.UPFLM_ON);
config.getConfig().put("terms_and_conditions", "false");
flows.updateAuthenticatorConfig(config.getId(), config);
}
}
@ -223,6 +224,7 @@ public abstract class AbstractBrokerTest extends AbstractInitializedBaseBrokerTe
} else if (execution.getAlias() != null && execution.getAlias().equals(IDP_REVIEW_PROFILE_CONFIG_ALIAS)) {
AuthenticatorConfigRepresentation config = flows.getAuthenticatorConfig(execution.getAuthenticationConfig());
config.getConfig().put("update.profile.on.first.login", IdentityProviderRepresentation.UPFLM_MISSING);
config.getConfig().put("terms_and_conditions", "false");
flows.updateAuthenticatorConfig(config.getId(), config);
}
}
@ -253,10 +255,24 @@ public abstract class AbstractBrokerTest extends AbstractInitializedBaseBrokerTe
} else if (execution.getAlias() != null && execution.getAlias().equals(IDP_REVIEW_PROFILE_CONFIG_ALIAS)) {
AuthenticatorConfigRepresentation config = flows.getAuthenticatorConfig(execution.getAuthenticationConfig());
config.getConfig().put("update.profile.on.first.login", IdentityProviderRepresentation.UPFLM_OFF);
config.getConfig().put("terms_and_conditions", "false");
flows.updateAuthenticatorConfig(config.getId(), config);
}
}
static void makeTermsAndCondionRequiredInLogin(AuthenticationExecutionInfoRepresentation execution, AuthenticationManagementResource flows) {
if (execution.getProviderId() != null && execution.getProviderId().equals(IdpCreateUserIfUniqueAuthenticatorFactory.PROVIDER_ID)) {
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.name());
flows.updateExecutions(DefaultAuthenticationFlows.FIRST_BROKER_LOGIN_FLOW, execution);
} else if (execution.getAlias() != null && execution.getAlias().equals(IDP_REVIEW_PROFILE_CONFIG_ALIAS)) {
AuthenticatorConfigRepresentation config = flows.getAuthenticatorConfig(execution.getAuthenticationConfig());
config.getConfig().put("update.profile.on.first.login", IdentityProviderRepresentation.UPFLM_ON);
config.getConfig().put("terms_and_conditions", "true");
flows.updateAuthenticatorConfig(config.getId(), config);
}
}
static void disableExistingUser(AuthenticationExecutionInfoRepresentation execution, AuthenticationManagementResource flows) {
if (execution.getProviderId() != null && (execution.getProviderId().equals(IdpCreateUserIfUniqueAuthenticatorFactory.PROVIDER_ID) || execution.getProviderId().equals(IdpConfirmLinkAuthenticatorFactory.PROVIDER_ID))) {
execution.setRequirement(AuthenticationExecutionModel.Requirement.DISABLED.name());

View file

@ -1,10 +1,12 @@
package org.keycloak.testsuite.broker;
import java.util.List;
import java.util.function.BiConsumer;
import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response;
import org.keycloak.admin.client.resource.AuthenticationManagementResource;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
@ -693,6 +695,29 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractInitializedBa
assertEquals("User with username consumer already exists. Please login to account management to link the account.", errorPage.getError());
}
@Test
public void testUserExistsFirstBrokerLoginFlowUpdateProfileOnAndTermsAccepted() {
createUser("consumer");
updateExecutionsAndEnableTermsAndCondition(AbstractBrokerTest::makeTermsAndCondionRequiredInLogin);
oauth.clientId("broker-app");
loginPage.open(bc.consumerRealmName());
logInWithBroker(bc);
Assert.assertTrue(updateAccountInformationPage.isCurrent());
Assert.assertTrue("We must be on correct realm right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
log.debug("Updating info on updateAccount page");
updateAccountInformationPage.acceptTerms("consumer", "consumer-user@redhat.com", "FirstName", "LastName");
waitForPage(driver, "we are sorry...", false);
assertEquals("User with username consumer already exists. Please login to account management to link the account.", errorPage.getError());
}
/**
* Refers to in old test suite: org.keycloak.testsuite.broker.AbstractFirstBrokerLoginTest#testRegistrationWithPasswordUpdateRequired
@ -1720,4 +1745,9 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractInitializedBa
return () -> toggleRegistrationAllowed(realmName, genuineValue);
}
private void updateExecutionsAndEnableTermsAndCondition(BiConsumer<AuthenticationExecutionInfoRepresentation, AuthenticationManagementResource> action) {
updateExecutions(action);
changeRequiredAction(true);
}
}

View file

@ -21,8 +21,10 @@ import java.util.function.BiConsumer;
import org.keycloak.admin.client.resource.AuthenticationManagementResource;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.authentication.requiredactions.TermsAndConditions;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.junit.Before;
@ -65,6 +67,16 @@ public abstract class AbstractInitializedBaseBrokerTest extends AbstractBaseBrok
addClientsToProviderAndConsumer();
testContext.setInitialized(true);
changeRequiredAction(false);
}
protected void changeRequiredAction(boolean enabled) {
AuthenticationManagementResource flows = adminClient.realm(bc.consumerRealmName()).flows();
RequiredActionProviderRepresentation rep = flows.getRequiredAction(TermsAndConditions.PROVIDER_ID);
rep.setEnabled(enabled);
flows.updateRequiredAction(TermsAndConditions.PROVIDER_ID, rep);
}
protected void updateExecutions(BiConsumer<AuthenticationExecutionInfoRepresentation, AuthenticationManagementResource> action) {

View file

@ -1,5 +1,6 @@
<#import "template.ftl" as layout>
<#import "user-profile-commons.ftl" as userProfileCommons>
<#import "register-commons.ftl" as registerCommons>
<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section>
<#if section = "header">
${msg("loginIdpReviewProfileTitle")}
@ -7,6 +8,7 @@
<form id="kc-idp-review-profile-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<@userProfileCommons.userProfileFormFields/>
<@registerCommons.termsAcceptance/>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">

View file

@ -1,5 +1,6 @@
<#import "template.ftl" as layout>
<#import "user-profile-commons.ftl" as userProfileCommons>
<#import "register-commons.ftl" as registerCommons>
<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section>
<#if section = "header">
${msg("loginProfileTitle")}
@ -7,10 +8,11 @@
<form id="kc-update-profile-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<@userProfileCommons.userProfileFormFields/>
<@registerCommons.termsAcceptance/>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
@ -25,4 +27,4 @@
</div>
</form>
</#if>
</@layout.registrationLayout>
</@layout.registrationLayout>

View file

@ -1,5 +1,5 @@
<#macro termsAcceptance>
<#if termsAcceptanceRequired??>
<#if termsAcceptanceRequired!false>
<div class="form-group">
<div class="${properties.kcInputWrapperClass!}">
${msg("termsTitle")}

View file

@ -1,5 +1,5 @@
<#macro termsAcceptance>
<#if termsAcceptanceRequired??>
<#if termsAcceptanceRequired!false>
<div class="form-group">
<div class="${properties.kcInputWrapperClass!}">
${msg("termsTitle")}