Improve invitation messages and flow

Closes #29945

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-05-28 18:00:41 -03:00 committed by Alexander Schwartz
parent 2c521bd64d
commit 320f8eb1b4
14 changed files with 141 additions and 34 deletions

View file

@ -17,6 +17,7 @@
package org.keycloak.authentication.actiontoken.inviteorg;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.TokenVerifier.Predicate;
@ -43,7 +44,7 @@ import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel;
import java.util.List;
import java.net.URI;
import java.util.Objects;
/**
@ -112,9 +113,9 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
return session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authSession)
.setSuccess(Messages.CONFIRM_EXECUTION_OF_ACTIONS)
.setSuccess(Messages.CONFIRM_ORGANIZATION_MEMBERSHIP, organization.getName())
.setAttribute("messageHeader", Messages.CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE)
.setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri)
.setAttribute(Constants.TEMPLATE_ATTR_REQUIRED_ACTIONS, List.of(Messages.CONFIRM_ORGANIZATION_MEMBERSHIP))
.setAttribute(OrganizationModel.ORGANIZATION_NAME_ATTRIBUTE, organization.getName())
.createInfoPage();
}
@ -135,6 +136,17 @@ public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler<Invi
tokenContext.setEvent(event.clone().removeDetail(Details.EMAIL).event(EventType.LOGIN));
String nextAction = AuthenticationManager.nextRequiredAction(session, authSession, tokenContext.getRequest(), event);
if (nextAction == null) {
// do not show account updated page
authSession.removeAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS);
if (redirectUri != null) {
// always redirect to the expected URI if provided
return Response.status(Status.FOUND).location(URI.create(redirectUri)).build();
}
}
return AuthenticationManager.redirectToRequiredActions(session, realm, authSession, uriInfo, nextAction);
}
}

View file

@ -17,17 +17,27 @@
package org.keycloak.authentication.forms;
import jakarta.ws.rs.core.Response.Status;
import org.keycloak.Config;
import org.keycloak.authentication.FormAuthenticator;
import org.keycloak.authentication.FormAuthenticatorFactory;
import org.keycloak.authentication.FormContext;
import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.VerificationException;
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.OrganizationModel;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.provider.ProviderConfigProperty;
import jakarta.ws.rs.core.Response;
import org.keycloak.services.messages.Messages;
import java.util.List;
/**
@ -47,6 +57,27 @@ public class RegistrationPage implements FormAuthenticator, FormAuthenticatorFac
@Override
public Response render(FormContext context, LoginFormsProvider form) {
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
try {
InviteOrgActionToken token = Organizations.parseInvitationToken(context.getHttpRequest());
if (token != null) {
KeycloakSession session = context.getSession();
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
OrganizationModel organization = provider.getById(token.getOrgId());
if (organization == null || !organization.isEnabled()) {
return form.setError(Messages.EXPIRED_ACTION).createErrorPage(Status.BAD_REQUEST);
}
form.setAttribute("messageHeader", Messages.REGISTER_ORGANIZATION_MEMBER);
form.setAttribute(OrganizationModel.ORGANIZATION_NAME_ATTRIBUTE, organization.getName());
}
} catch (VerificationException e) {
return form.setError(Messages.EXPIRED_ACTION).createErrorPage(Status.BAD_REQUEST);
}
}
return form.createRegistration();
}

View file

@ -19,7 +19,6 @@ package org.keycloak.authentication.forms;
import jakarta.ws.rs.core.MultivaluedHashMap;
import org.keycloak.Config;
import org.keycloak.TokenVerifier;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.authentication.FormAction;
@ -37,7 +36,6 @@ import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.OrganizationModel;
@ -46,6 +44,7 @@ import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.organization.utils.Organizations;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.messages.Messages;
@ -290,29 +289,24 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
private boolean validateOrganizationInvitation(ValidationContext context, MultivaluedMap<String, String> formData, String email) {
if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) {
MultivaluedMap<String, String> queryParameters = context.getHttpRequest().getUri().getQueryParameters();
String tokenFromQuery = queryParameters.getFirst(Constants.TOKEN);
if (tokenFromQuery == null) {
return true;
}
Consumer<List<FormMessage>> error = messages -> {
context.getEvent().detail(Messages.INVALID_ORG_INVITE, tokenFromQuery);
context.error(Errors.INVALID_TOKEN);
context.validationError(formData, messages);
};
TokenVerifier<InviteOrgActionToken> tokenVerifier = TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class);
InviteOrgActionToken token;
try {
token = tokenVerifier.getToken();
token = Organizations.parseInvitationToken(context.getHttpRequest());
} catch (VerificationException e) {
error.accept(List.of(new FormMessage("Unexpected error parsing the invitation token")));
return false;
}
if (token == null) {
return true;
}
KeycloakSession session = context.getSession();
OrganizationProvider provider = session.getProvider(OrganizationProvider.class);
OrganizationModel organization = provider.getById(token.getOrgId());

View file

@ -83,8 +83,13 @@ public class OrganizationInvitationResource {
return sendInvitation(user);
}
if (!realm.isRegistrationAllowed()) {
throw ErrorResponse.error("Realm does not allow self-registration", Status.BAD_REQUEST);
}
user = new InMemoryUserAdapter(session, realm, null);
user.setEmail(email);
if (firstName != null && lastName != null) {
user.setFirstName(firstName);
user.setLastName(lastName);

View file

@ -19,14 +19,21 @@ package org.keycloak.organization.utils;
import static java.util.Optional.ofNullable;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.keycloak.TokenVerifier;
import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken;
import org.keycloak.common.Profile;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.VerificationException;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.Constants;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderModel;
@ -180,4 +187,15 @@ public class Organizations {
public static OrganizationDomainModel toModel(OrganizationDomainRepresentation domainRepresentation) {
return new OrganizationDomainModel(domainRepresentation.getName(), domainRepresentation.isVerified());
}
public static InviteOrgActionToken parseInvitationToken(HttpRequest request) throws VerificationException {
MultivaluedMap<String, String> queryParameters = request.getUri().getQueryParameters();
String tokenFromQuery = queryParameters.getFirst(Constants.TOKEN);
if (tokenFromQuery == null) {
return null;
}
return TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class).getToken();
}
}

View file

@ -321,4 +321,6 @@ public class Messages {
public static final String OAUTH2_DEVICE_CONSENT_DENIED = "oauth2DeviceConsentDeniedMessage";
public static final String CONFIRM_ORGANIZATION_MEMBERSHIP = "organization.confirm-membership";
public static final String CONFIRM_ORGANIZATION_MEMBERSHIP_TITLE = "organization.confirm-membership.title";
public static final String REGISTER_ORGANIZATION_MEMBER = "organization.member.register.title";
}

View file

@ -58,6 +58,10 @@ public abstract class AbstractPage {
abstract public boolean isCurrent();
public boolean isCurrent(String expectedTitle) {
return PageUtils.getPageTitle(driver).equals(expectedTitle);
}
abstract public void open() throws Exception;
public WebDriver getDriver() {

View file

@ -26,6 +26,7 @@ import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.testsuite.auth.page.AccountFields;
import org.keycloak.testsuite.auth.page.PasswordFields;
import org.keycloak.testsuite.util.DroneUtils;
import org.keycloak.testsuite.util.UIUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
@ -245,7 +246,7 @@ public class RegisterPage extends AbstractPage {
public boolean isCurrent() {
return PageUtils.getPageTitle(driver).equals("Register");
return isCurrent("Register");
}
public AccountFields.AccountErrors getInputAccountErrors(){
@ -267,4 +268,9 @@ public class RegisterPage extends AbstractPage {
assertCurrent();
}
public void assertCurrent(String orgName) {
String name = getClass().getSimpleName();
Assert.assertTrue("Expected " + name + " but was " + DroneUtils.getCurrentDriver().getTitle() + " (" + DroneUtils.getCurrentDriver().getCurrentUrl() + ")",
isCurrent("Create an account to join the " + orgName + " organization"));
}
}

View file

@ -19,6 +19,7 @@ package org.keycloak.testsuite.organization.admin;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import java.io.IOException;
@ -29,6 +30,7 @@ import java.util.concurrent.TimeUnit;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Rule;
@ -37,6 +39,7 @@ import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.common.Profile.Feature;
import org.keycloak.common.util.UriUtils;
import org.keycloak.cookie.CookieType;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.Assert;
@ -135,7 +138,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(orgToken);
driver.navigate().to(link.trim());
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
registerPage.assertCurrent();
registerPage.assertCurrent(organizationName);
registerPage.register("firstName", "lastName", user.getEmail(),
user.getUsername(), "password", "password", null, false, null);
List<UserRepresentation> users = testRealm().users().searchByEmail(user.getEmail(), true);
@ -149,6 +152,27 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName()));
}
@Test
public void testFailRegistrationNotEnabledWhenInvitingNewUser() throws IOException, MessagingException {
UserRepresentation user = UserBuilder.create()
.username("invitedUser")
.email("inviteduser@email")
.enabled(true)
.build();
// User isn't created when we send the invite
OrganizationResource organization = testRealm().organizations().get(createOrganization().getId());
RealmRepresentation realm = testRealm().toRepresentation();
realm.setRegistrationAllowed(false);
testRealm().update(realm);
try (Response response = organization.members().inviteUser(user.getEmail(), null, null)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
assertEquals("Realm does not allow self-registration", response.readEntity(ErrorRepresentation.class).getErrorMessage());
} finally {
realm.setRegistrationAllowed(true);
testRealm().update(realm);
}
}
@Test
public void testEmailDoesNotChangeOnRegistration() throws IOException {
UserRepresentation user = UserBuilder.create()
@ -168,8 +192,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(orgToken);
driver.navigate().to(link.trim());
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
registerPage.assertCurrent();
driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS);
registerPage.assertCurrent(organizationName);
registerPage.register("firstName", "lastName", "invalid@email.com",
user.getUsername(), "password", "password", null, false, null);
Assert.assertTrue(driver.getPageSource().contains("Email does not match the invitation"));
@ -198,7 +221,7 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertNotNull(orgToken);
driver.navigate().to(link.trim());
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
registerPage.assertCurrent();
registerPage.assertCurrent(organizationName);
driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS);
registerPage.register("firstName", "lastName", "invalid@email.com",
user.getUsername(), "password", "password", null, false, null);
@ -216,16 +239,18 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest {
Assert.assertEquals("Invitation to join the " + organizationName + " organization", message.getSubject());
EmailBody body = MailUtils.getBody(message);
if (user.getFirstName() != null && user.getLastName() != null) {
assertThat(body.getText(), Matchers.containsString(user.getFirstName() + " " + user.getLastName()));
assertThat(body.getText(), Matchers.containsString("Hi, " + user.getFirstName() + " " + user.getLastName() + "."));
}
String link = MailUtils.getLink(body.getHtml());
driver.navigate().to(link.trim());
// not yet a member
Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId())));
// confirm the intent of membership
assertThat(infoPage.getInfo(), containsString("You are about to join organization " + organizationName));
assertThat(driver.getPageSource(), containsString("You are about to join organization " + organizationName));
assertThat(infoPage.getInfo(), containsString("By clicking on the link below, you will become a member of the " + organizationName + " organization:"));
infoPage.clickToContinue();
assertThat(infoPage.getInfo(), containsString("Your account has been updated."));
// redirect to the account console and eventually force the user to authenticate if not already
assertThat(driver.getTitle(), containsString("Account Management"));
// now a member
Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation());
}

View file

@ -4,8 +4,8 @@ emailVerificationBodyHtml=<p>Someone has created a {2} account with this email a
orgInviteSubject=Invitation to join the {0} organization
orgInviteBody=You were invited to join the "{3}" organization. Click the link below to join.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you don't want to join the organization, just ignore this message.
orgInviteBodyHtml=<p>You were invited to join the {3} organization. Click the link below to join. </p><p><a href="{0}">Link to join the organization</a></p><p>This link will expire within {4}.</p><p>If you don't want to join the organization, just ignore this message.</p>
orgInviteBodyPersonalized="{5}" "{6}"\n\n You were invited to join the "{3}" organization. Click the link below to join.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you don't want to join the organization, just ignore this message.
orgInviteBodyPersonalizedHtml=<p>{5} {6}</p><p>You were invited to join the {3} organization. Click the link below to join. </p><p><a href="{0}">Link to join the organization</a></p><p>This link will expire within {4}.</p><p>If you don't want to join the organization, just ignore this message.</p>
orgInviteBodyPersonalized=Hi, "{5}" "{6}".\n\n You were invited to join the "{3}" organization. Click the link below to join.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you don't want to join the organization, just ignore this message.
orgInviteBodyPersonalizedHtml=<p>Hi, {5} {6}.</p><p>You were invited to join the {3} organization. Click the link below to join. </p><p><a href="{0}">Link to join the organization</a></p><p>This link will expire within {4}.</p><p>If you don't want to join the organization, just ignore this message.</p>
emailUpdateConfirmationSubject=Verify new email
emailUpdateConfirmationBody=To update your {2} account with email address {1}, click the link below\n\n{0}\n\nThis link will expire within {3}.\n\nIf you don''t want to proceed with this modification, just ignore this message.
emailUpdateConfirmationBodyHtml=<p>To update your {2} account with email address {1}, click the link below</p><p><a href="{0}">{0}</a></p><p>This link will expire within {3}.</p><p>If you don''t want to proceed with this modification, just ignore this message.</p>

View file

@ -2,7 +2,7 @@
<@layout.registrationLayout displayMessage=false; section>
<#if section = "header">
<#if messageHeader??>
${messageHeader}
${kcSanitize(msg("${messageHeader}"))?no_esc}
<#else>
${message.summary}
</#if>

View file

@ -518,4 +518,6 @@ doLogout=Logout
readOnlyUsernameMessage=You can''t update your username as it is read-only.
error-invalid-multivalued-size=Attribute {0} must have at least {1} and at most {2} value(s).
requiredAction.organization.confirm-membership=You are about to join organization ${kc.org.name}
organization.confirm-membership.title=You are about to join organization ${kc.org.name}
organization.confirm-membership=By clicking on the link below, you will become a member of the {0} organization:
organization.member.register.title=Create an account to join the ${kc.org.name} organization

View file

@ -3,7 +3,11 @@
<#import "register-commons.ftl" as registerCommons>
<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section>
<#if section = "header">
<#if messageHeader??>
${kcSanitize(msg("${messageHeader}"))?no_esc}
<#else>
${msg("registerTitle")}
</#if>
<#elseif section = "form">
<form id="kc-register-form" class="${properties.kcFormClass!}" action="${url.registrationAction}" method="post">

View file

@ -3,7 +3,11 @@
<#import "register-commons.ftl" as registerCommons>
<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section>
<#if section = "header">
<#if messageHeader??>
${kcSanitize(msg("${messageHeader}"))?no_esc}
<#else>
${msg("registerTitle")}
</#if>
<#elseif section = "form">
<form id="kc-register-form" class="${properties.kcFormClass!}" action="${url.registrationAction}" method="post">