Require user to agree to 'terms and conditions' during registration

This commit is contained in:
Réda Housni Alaoui 2023-05-26 19:06:26 +02:00 committed by Pedro Igor
parent 8080085cc1
commit eb9bb281ec
17 changed files with 332 additions and 20 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -14,6 +14,7 @@ include::users/proc-configuring-user-attributes.adoc[leveloffset=+2]
include::users/con-user-registration.adoc[leveloffset=+2]
include::users/proc-enabling-user-registration.adoc[leveloffset=3]
include::users/proc-registering-new-user.adoc[leveloffset=3]
include::users/proc-requiring-tac-agreement-at-registration.adoc[leveloffset=3]
include::users/con-required-actions.adoc[leveloffset=+2]
include::users/proc-setting-required-actions.adoc[leveloffset=+3]

View File

@ -0,0 +1,24 @@
// Module included in the following assemblies:
//
// con-user-registration.adoc
[id="proc-requiring-tac-agreement-at-registration_{context}"]
= Requiring user to agree to terms and conditions during registration
[role="_abstract"]
For a user to register, you can require agreement to your terms and conditions.
.Registration form with required terms and conditions agreement
image:images/registration-form-with-required-tac.png[]
.Prerequisite
* User registration is enabled.
* Terms and conditions required action is enabled.
.Procedure
. Click the *Flows* tab.
. Click the *registration* flow.
. Select *Required* on the *Terms and Conditions* row.
+
.Make the terms and conditions agreement required at registration
image:images/require-tac-agreement-at-registration.png[]

View File

@ -51,7 +51,7 @@ public class DefaultAuthenticationFlows {
public static void addFlows(RealmModel realm) {
if (realm.getFlowByAlias(BROWSER_FLOW) == null) browserFlow(realm);
if (realm.getFlowByAlias(DIRECT_GRANT_FLOW) == null) directGrantFlow(realm, false);
if (realm.getFlowByAlias(REGISTRATION_FLOW) == null) registrationFlow(realm);
if (realm.getFlowByAlias(REGISTRATION_FLOW) == null) registrationFlow(realm, false);
if (realm.getFlowByAlias(RESET_CREDENTIALS_FLOW) == null) resetCredentialsFlow(realm);
if (realm.getFlowByAlias(CLIENT_AUTHENTICATION_FLOW) == null) clientAuthFlow(realm);
if (realm.getFlowByAlias(FIRST_BROKER_LOGIN_FLOW) == null) firstBrokerLoginFlow(realm, false);
@ -61,7 +61,7 @@ public class DefaultAuthenticationFlows {
public static void migrateFlows(RealmModel realm) {
if (realm.getFlowByAlias(BROWSER_FLOW) == null) browserFlow(realm, true);
if (realm.getFlowByAlias(DIRECT_GRANT_FLOW) == null) directGrantFlow(realm, true);
if (realm.getFlowByAlias(REGISTRATION_FLOW) == null) registrationFlow(realm);
if (realm.getFlowByAlias(REGISTRATION_FLOW) == null) registrationFlow(realm, true);
if (realm.getFlowByAlias(RESET_CREDENTIALS_FLOW) == null) resetCredentialsFlow(realm);
if (realm.getFlowByAlias(CLIENT_AUTHENTICATION_FLOW) == null) clientAuthFlow(realm);
if (realm.getFlowByAlias(FIRST_BROKER_LOGIN_FLOW) == null) firstBrokerLoginFlow(realm, true);
@ -69,7 +69,7 @@ public class DefaultAuthenticationFlows {
if (realm.getFlowByAlias(DOCKER_AUTH) == null) dockerAuthenticationFlow(realm);
}
public static void registrationFlow(RealmModel realm) {
public static void registrationFlow(RealmModel realm, boolean migrate) {
AuthenticationFlowModel registrationFlow = new AuthenticationFlowModel();
registrationFlow.setAlias(REGISTRATION_FLOW);
registrationFlow.setDescription("registration flow");
@ -137,6 +137,16 @@ public class DefaultAuthenticationFlows {
execution.setAuthenticatorFlow(false);
//execution.setAuthenticatorConfig(captchaConfig.getId());
realm.addAuthenticatorExecution(execution);
if (!migrate) {
execution = new AuthenticationExecutionModel();
execution.setParentFlow(registrationFormFlow.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.DISABLED);
execution.setAuthenticator("registration-terms-and-conditions");
execution.setPriority(70);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
}
}
public static void browserFlow(RealmModel realm) {

View File

@ -0,0 +1,145 @@
/*
* Copyright 2021 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.Collections;
import java.util.List;
import jakarta.ws.rs.core.MultivaluedMap;
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.ConfiguredProvider;
import org.keycloak.provider.ProviderConfigProperty;
public class RegistrationTermsAndConditions implements FormAction, FormActionFactory, ConfiguredProvider {
public static final String PROVIDER_ID = "registration-terms-and-conditions";
protected static final String FIELD = "termsAccepted";
@Override
public String getDisplayType() {
return "Terms and conditions";
}
@Override
public String getReferenceCategory() {
return "terms-and-conditions";
}
@Override
public boolean isConfigurable() {
return false;
}
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED
};
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public void buildPage(FormContext context, LoginFormsProvider form) {
form.setAttribute("termsAcceptanceRequired", true);
}
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
if (formData.containsKey(FIELD)) {
context.success();
return;
}
context.error(Errors.INVALID_REGISTRATION);
context.validationError(formData, Collections.singletonList(new FormMessage(FIELD, "termsAcceptanceRequired")));
}
@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 String getHelpText() {
return "Asks the user to accept terms and conditions before submitting its registration form.";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
@Override
public FormAction create(KeycloakSession session) {
return this;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View File

@ -18,4 +18,5 @@
org.keycloak.authentication.forms.RegistrationPassword
org.keycloak.authentication.forms.RegistrationProfile
org.keycloak.authentication.forms.RegistrationUserCreation
org.keycloak.authentication.forms.RegistrationRecaptcha
org.keycloak.authentication.forms.RegistrationRecaptcha
org.keycloak.authentication.forms.RegistrationTermsAndConditions

View File

@ -162,6 +162,9 @@ public class AccountFields extends FieldsBase {
@FindBy(id = "input-error-username")
private WebElement usernameError;
@FindBy(id = "input-error-terms-accepted")
private WebElement termsError;
public String getFirstNameError() {
try {
return getTextFromElement(firstNameError);
@ -201,5 +204,13 @@ public class AccountFields extends FieldsBase {
return null;
}
}
public String getTermsError(){
try {
return getTextFromElement(termsError);
} catch (NoSuchElementException e) {
return null;
}
}
}
}

View File

@ -56,10 +56,13 @@ public class RegisterPage extends AbstractPage {
@FindBy(id = "password-confirm")
private WebElement passwordConfirmInput;
@FindBy(id = "department")
private WebElement departmentInput;
@FindBy(id = "termsAccepted")
private WebElement termsAcceptedInput;
@FindBy(css = "input[type=\"submit\"]")
private WebElement submitButton;
@ -77,6 +80,10 @@ public class RegisterPage extends AbstractPage {
}
public void register(String firstName, String lastName, String email, String username, String password, String passwordConfirm, String department) {
register(firstName, lastName, email, username, password, passwordConfirm, department, null);
}
public void register(String firstName, String lastName, String email, String username, String password, String passwordConfirm, String department, Boolean termsAccepted) {
firstNameInput.clear();
if (firstName != null) {
firstNameInput.sendKeys(firstName);
@ -114,6 +121,15 @@ public class RegisterPage extends AbstractPage {
}
}
try {
termsAcceptedInput.clear();
} catch (NoSuchElementException e) {
// ignore
}
if (termsAccepted != null && termsAccepted) {
termsAcceptedInput.click();
}
submitButton.click();
}
@ -173,7 +189,7 @@ public class RegisterPage extends AbstractPage {
}
return null;
}
public String getLabelForField(String fieldId) {
return driver.findElement(By.cssSelector("label[for="+fieldId+"]")).getText();
}
@ -218,7 +234,7 @@ public class RegisterPage extends AbstractPage {
}
}
public boolean isCurrent() {
return PageUtils.getPageTitle(driver).equals("Register");
}

View File

@ -70,6 +70,8 @@ public class ProvidersTest extends AbstractAuthenticationTest {
addProviderInfo(expected, "registration-user-creation", "Registration User Creation",
"This action must always be first! Validates the username of the user in validation phase. " +
"In success phase, this will create the user in the database.");
addProviderInfo(expected, "registration-terms-and-conditions", "Terms and conditions",
"Asks the user to accept terms and conditions before submitting its registration form.");
compareProviders(expected, result);
}

View File

@ -27,6 +27,7 @@ import org.keycloak.authentication.authenticators.browser.CookieAuthenticatorFac
import org.keycloak.authentication.forms.RegistrationPassword;
import org.keycloak.authentication.forms.RegistrationProfile;
import org.keycloak.authentication.forms.RegistrationRecaptcha;
import org.keycloak.authentication.forms.RegistrationTermsAndConditions;
import org.keycloak.authentication.forms.RegistrationUserCreation;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
@ -34,6 +35,7 @@ import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.pages.AppPage;
@ -52,6 +54,7 @@ import org.keycloak.testsuite.util.AccountHelper;
import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.util.UUID;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.anyOf;
@ -353,7 +356,7 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest {
//contains few special characters we want to be sure they are allowed in username
String username = "register.U-se@rS_uccess";
registerPage.register("firstName", "lastName", "registerUserSuccess@email", username, "password", "password");
appPage.assertCurrent();
@ -644,6 +647,56 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest {
registerPage.assertCurrent();
}
//KEYCLOAK-15244
@Test
public void registerUserMissingTermsAcceptance() {
configureRegistrationFlowWithCustomRegistrationPageForm(UUID.randomUUID().toString(),
AuthenticationExecutionModel.Requirement.REQUIRED);
try {
loginPage.open();
loginPage.clickRegister();
registerPage.assertCurrent();
registerPage.register("firstName", "lastName", "registerUserMissingTermsAcceptance@email",
"registerUserMissingTermsAcceptance", "password", "password", null, false);
registerPage.assertCurrent();
assertEquals("You must agree to our terms and conditions.", registerPage.getInputAccountErrors().getTermsError());
events.expectRegister("registerUserMissingTermsAcceptance", "registerUserMissingTermsAcceptance@email")
.removeDetail(Details.USERNAME)
.removeDetail(Details.EMAIL)
.error("invalid_registration").assertEvent();
} finally {
configureRegistrationFlowWithCustomRegistrationPageForm(UUID.randomUUID().toString());
}
}
//KEYCLOAK-15244
@Test
public void registerUserSuccessTermsAcceptance() {
configureRegistrationFlowWithCustomRegistrationPageForm(UUID.randomUUID().toString(),
AuthenticationExecutionModel.Requirement.REQUIRED);
try {
loginPage.open();
loginPage.clickRegister();
registerPage.assertCurrent();
registerPage.register("firstName", "lastName", "registerUserSuccessTermsAcceptance@email",
"registerUserSuccessTermsAcceptance", "password", "password", null, true);
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
String userId = events.expectRegister("registerUserSuccessTermsAcceptance", "registerUserSuccessTermsAcceptance@email")
.assertEvent().getUserId();
assertUserRegistered(userId, "registerUserSuccessTermsAcceptance", "registerUserSuccessTermsAcceptance@email");
} finally {
configureRegistrationFlowWithCustomRegistrationPageForm(UUID.randomUUID().toString());
}
}
protected RealmAttributeUpdater configureRealmRegistrationEmailAsUsername(final boolean value) {
return getRealmAttributeUpdater().setRegistrationEmailAsUsername(value);
}
@ -709,19 +762,24 @@ public class RegisterTest extends AbstractTestRealmKeycloakTest {
}
private void configureRegistrationFlowWithCustomRegistrationPageForm(String newFlowAlias) {
configureRegistrationFlowWithCustomRegistrationPageForm(newFlowAlias, AuthenticationExecutionModel.Requirement.DISABLED);
}
private void configureRegistrationFlowWithCustomRegistrationPageForm(String newFlowAlias, AuthenticationExecutionModel.Requirement termsAndConditionRequirement) {
testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyRegistrationFlow(newFlowAlias));
testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session)
.selectFlow(newFlowAlias)
.clear()
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, CookieAuthenticatorFactory.PROVIDER_ID)
.addSubFlowExecution("Sub Flow", AuthenticationFlow.BASIC_FLOW, AuthenticationExecutionModel.Requirement.ALTERNATIVE, subflow -> subflow
.addSubFlowExecution("Sub sub Form Flow", AuthenticationFlow.FORM_FLOW, AuthenticationExecutionModel.Requirement.REQUIRED, subsubflow -> subsubflow
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, RegistrationUserCreation.PROVIDER_ID)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, RegistrationProfile.PROVIDER_ID)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, RegistrationPassword.PROVIDER_ID)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.DISABLED, RegistrationRecaptcha.PROVIDER_ID)
)
.clear()
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, CookieAuthenticatorFactory.PROVIDER_ID)
.addSubFlowExecution("Sub Flow", AuthenticationFlow.BASIC_FLOW, AuthenticationExecutionModel.Requirement.ALTERNATIVE, subflow -> subflow
.addSubFlowExecution("Sub sub Form Flow", AuthenticationFlow.FORM_FLOW, AuthenticationExecutionModel.Requirement.REQUIRED, subsubflow -> subsubflow
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, RegistrationUserCreation.PROVIDER_ID)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, RegistrationProfile.PROVIDER_ID)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, RegistrationPassword.PROVIDER_ID)
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.DISABLED, RegistrationRecaptcha.PROVIDER_ID)
.addAuthenticatorExecution(termsAndConditionRequirement, RegistrationTermsAndConditions.PROVIDER_ID)
)
)
.defineAsRegistrationFlow() // Activate this new flow
);
}

View File

@ -63,6 +63,8 @@ termsTitle=Termes et Conditions
termsTitleHtml=Termes et Conditions
termsText=<p>Termes et conditions \u00e0 d\u00e9finir</p>
termsPlainText=Termes et conditions \u00e0 d\u00e9finir
termsAcceptanceRequired=Vous devez accepter les termes et conditions.
acceptTerms=J''accepte les termes et conditions
recaptchaFailed=Re-captcha invalide
recaptchaNotConfigured=Re-captcha est requis, mais il n''est pas configur\u00e9

View File

@ -68,6 +68,8 @@ unknown=Unknown
termsTitle=Terms and Conditions
termsText=<p>Terms and conditions to be defined</p>
termsPlainText=Terms and conditions to be defined.
termsAcceptanceRequired=You must agree to our terms and conditions.
acceptTerms=I agree to the terms and conditions
recaptchaFailed=Invalid Recaptcha
recaptchaNotConfigured=Recaptcha is required, but not configured

View File

@ -0,0 +1,27 @@
<#macro termsAcceptance>
<#if termsAcceptanceRequired??>
<div class="form-group">
<div class="${properties.kcInputWrapperClass!}">
${msg("termsTitle")}
<div id="kc-registration-terms-text">
${kcSanitize(msg("termsText"))?no_esc}
</div>
</div>
</div>
<div class="form-group">
<div class="${properties.kcLabelWrapperClass!}">
<input type="checkbox" id="termsAccepted" name="termsAccepted" class="${properties.kcCheckboxInputClass!}"
aria-invalid="<#if messagesPerField.existsError('termsAccepted')>true</#if>"
/>
<label for="termsAccepted" class="${properties.kcLabelClass!}">${msg("acceptTerms")}</label>
</div>
<#if messagesPerField.existsError('termsAccepted')>
<div class="${properties.kcLabelWrapperClass!}">
<span id="input-error-terms-accepted" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.get('termsAccepted'))?no_esc}
</span>
</div>
</#if>
</div>
</#if>
</#macro>

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("registerTitle")}
@ -49,6 +50,8 @@
</#if>
</#if>
</@userProfileCommons.userProfileFormFields>
<@registerCommons.termsAcceptance/>
<#if recaptchaRequired??>
<div class="form-group">
@ -71,4 +74,4 @@
</div>
</form>
</#if>
</@layout.registrationLayout>
</@layout.registrationLayout>

View File

@ -1,5 +1,6 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm'); section>
<#import "register-commons.ftl" as registerCommons>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm','termsAccepted'); section>
<#if section = "header">
${msg("registerTitle")}
<#elseif section = "form">
@ -117,6 +118,8 @@
</div>
</#if>
<@registerCommons.termsAcceptance/>
<#if recaptchaRequired??>
<div class="form-group">
<div class="${properties.kcInputWrapperClass!}">
@ -138,4 +141,4 @@
</div>
</form>
</#if>
</@layout.registrationLayout>
</@layout.registrationLayout>

View File

@ -178,6 +178,13 @@ div.kc-logo-text span {
margin-bottom: 20px;
}
#kc-registration-terms-text {
max-height: 100px;
overflow-y: auto;
overflow-x: hidden;
margin: 5px;
}
#kc-registration {
margin-bottom: 0;
}