diff --git a/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java b/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java index 81ac48e7ed..a5c6e21aa4 100755 --- a/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java +++ b/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java @@ -27,15 +27,23 @@ import org.keycloak.authentication.FormActionFactory; import org.keycloak.authentication.FormContext; import org.keycloak.authentication.ValidationContext; import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken; -import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionTokenHandler; import org.keycloak.authentication.requiredactions.TermsAndConditions; +import org.keycloak.common.Profile; +import org.keycloak.common.Profile.Feature; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Time; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.forms.login.LoginFormsProvider; -import org.keycloak.models.*; +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; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredActionProviderModel; +import org.keycloak.models.UserModel; import org.keycloak.models.utils.FormMessage; import org.keycloak.organization.OrganizationProvider; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -50,6 +58,7 @@ import org.keycloak.userprofile.UserProfile; import jakarta.ws.rs.core.MultivaluedMap; import java.util.List; +import java.util.function.Consumer; /** * @author Bill Burke @@ -72,13 +81,16 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory { @Override public void validate(ValidationContext context) { MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - MultivaluedMap queryParameters = context.getHttpRequest().getUri().getQueryParameters(); - context.getEvent().detail(Details.REGISTER_METHOD, "form"); UserProfile profile = getOrCreateUserProfile(context, formData); Attributes attributes = profile.getAttributes(); String email = attributes.getFirst(UserModel.EMAIL); + + if (!validateOrganizationInvitation(context, formData, email)) { + return; + } + String username = attributes.getFirst(UserModel.USERNAME); String firstName = attributes.getFirst(UserModel.FIRST_NAME); String lastName = attributes.getFirst(UserModel.LAST_NAME); @@ -112,27 +124,6 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory { context.validationError(formData, errors); return; } - - // handle parsing of an organization invite token from the url - String tokenFromQuery = queryParameters.getFirst(Constants.ORG_TOKEN); - if (tokenFromQuery != null) { - TokenVerifier tokenVerifier = TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class); - try { - InviteOrgActionToken aToken = tokenVerifier.getToken(); - if (aToken.isExpired() || !aToken.getActionId().equals(InviteOrgActionToken.TOKEN_TYPE) || !aToken.getEmail().equals(email)) { - throw new VerificationException("The provided token is not valid. It may be expired or issued for a different email"); - } - // TODO probably need to check if string is empty or null - if (context.getSession().getProvider(OrganizationProvider.class).getById(aToken.getOrgId()) == null) { - throw new VerificationException("The provided token contains an invalid organization id"); - } - } catch (VerificationException e) { - // TODO we can be more specific here just trying to get something working... - context.getEvent().detail(Messages.INVALID_ORG_INVITE, tokenFromQuery); - context.error(Errors.INVALID_TOKEN); - return; - } - } context.success(); } @@ -146,19 +137,6 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory { checkNotOtherUserAuthenticating(context); MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - String tokenFromQuery = context.getHttpRequest().getUri().getQueryParameters().getFirst(Constants.ORG_TOKEN); - - DefaultActionTokenKey aToken = null; - if(tokenFromQuery != null) { - try { - TokenVerifier tokenVerifier = TokenVerifier.create(tokenFromQuery, DefaultActionTokenKey.class); - aToken = tokenVerifier.getToken(); - } catch (VerificationException e) { - // TODO in theory this should never happen since we already validated. We should either encapsulate decoding the token somehow (add to context or make new class?) - // for now we can panic run this exception if we somehow end up here - throw new RuntimeException(e); - } - } String email = formData.getFirst(UserModel.EMAIL); String username = formData.getFirst(UserModel.USERNAME); @@ -174,16 +152,7 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory { UserProfile profile = getOrCreateUserProfile(context, formData); UserModel user = profile.create(); - // since we already validated the token we can just add the user to the organization - if (aToken != null) { - String org = aToken.getOtherClaims().get("org_id").toString(); - KeycloakSession session = context.getSession(); - OrganizationProvider provider = session.getProvider(OrganizationProvider.class); - OrganizationModel orgModel = provider.getById(org); - provider.addMember(orgModel, user); - context.getEvent().detail(Details.ORG_ID, org); - context.getAuthenticationSession().setRedirectUri(aToken.getOtherClaims().get("reduri").toString()); - } + addOrganizationMember(context, user); user.setEnabled(true); @@ -318,4 +287,71 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory { } return profile; } + + private boolean validateOrganizationInvitation(ValidationContext context, MultivaluedMap formData, String email) { + if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + MultivaluedMap queryParameters = context.getHttpRequest().getUri().getQueryParameters(); + String tokenFromQuery = queryParameters.getFirst(Constants.ORG_TOKEN); + + if (tokenFromQuery == null) { + return true; + } + + Consumer> error = messages -> { + context.getEvent().detail(Messages.INVALID_ORG_INVITE, tokenFromQuery); + context.error(Errors.INVALID_TOKEN); + context.validationError(formData, messages); + }; + + TokenVerifier tokenVerifier = TokenVerifier.create(tokenFromQuery, InviteOrgActionToken.class); + InviteOrgActionToken token; + + try { + token = tokenVerifier.getToken(); + } catch (VerificationException e) { + error.accept(List.of(new FormMessage("Unexpected error parsing the invitation token"))); + return false; + } + + KeycloakSession session = context.getSession(); + OrganizationProvider provider = session.getProvider(OrganizationProvider.class); + OrganizationModel organization = provider.getById(token.getOrgId()); + + if (organization == null) { + error.accept(List.of(new FormMessage("The provided token contains an invalid organization id"))); + return false; + } + + // make sure the organization is set to the session so that UP org-related validators can run + session.setAttribute(OrganizationModel.class.getName(), organization); + session.setAttribute(InviteOrgActionToken.class.getName(), token); + + if (token.isExpired() || !token.getActionId().equals(InviteOrgActionToken.TOKEN_TYPE)) { + error.accept(List.of(new FormMessage("The provided token is not valid or has expired."))); + return false; + } + + if (!token.getEmail().equals(email)) { + error.accept(List.of(new FormMessage(UserModel.EMAIL, "Email does not match the invitation"))); + return false; + } + } + + return true; + } + + private void addOrganizationMember(FormContext context, UserModel user) { + if (Profile.isFeatureEnabled(Feature.ORGANIZATION)) { + InviteOrgActionToken token = (InviteOrgActionToken) context.getSession().getAttribute(InviteOrgActionToken.class.getName()); + + if (token != null) { + KeycloakSession session = context.getSession(); + OrganizationProvider provider = session.getProvider(OrganizationProvider.class); + OrganizationModel orgModel = provider.getById(token.getOrgId()); + provider.addMember(orgModel, user); + context.getEvent().detail(Details.ORG_ID, orgModel.getId()); + context.getAuthenticationSession().setRedirectUri(token.getRedirectUri()); + } + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java index 2936c1a4f5..de1ff3856f 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/admin/OrganizationInvitationLinkTest.java @@ -23,6 +23,7 @@ import static org.hamcrest.Matchers.containsString; import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import jakarta.mail.internet.MimeMessage; import jakarta.ws.rs.core.Response; @@ -98,7 +99,6 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation()); } - @Test public void testInviteNewUserRegistration() throws IOException { UserRepresentation user = UserBuilder.create() @@ -124,9 +124,69 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { Assert.assertFalse(users.isEmpty()); // user is a member Assert.assertNotNull(organization.members().member(users.get(0).getId()).toRepresentation()); + getCleanup().addCleanup(() -> testRealm().users().get(users.get(0).getId()).remove()); // authenticated to the account console Assert.assertTrue(driver.getPageSource().contains("Account Management")); Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName())); } + + @Test + public void testEmailDoesNotChangeOnRegistration() throws IOException { + 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()); + organization.members().inviteUser(user.getEmail()).close(); + + MimeMessage message = greenMail.getLastReceivedMessage(); + Assert.assertNotNull(message); + String link = MailUtils.getPasswordResetEmailLink(message); + String orgToken = UriUtils.parseQueryParameters(link, false).values().stream().map(strings -> strings.get(0)).findFirst().orElse(null); + 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.register("firstName", "lastName", "invalid@email.com", + user.getUsername(), "password", "password", null, false, null); + Assert.assertTrue(driver.getPageSource().contains("Email does not match the invitation")); + List users = testRealm().users().searchByEmail(user.getEmail(), true); + Assert.assertTrue(users.isEmpty()); + } + + @Test + public void testLinkExpired() throws IOException { + 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()); + organization.members().inviteUser(user.getEmail()).close(); + + try { + setTimeOffset((int) TimeUnit.DAYS.toSeconds(1)); + MimeMessage message = greenMail.getLastReceivedMessage(); + Assert.assertNotNull(message); + String link = MailUtils.getPasswordResetEmailLink(message); + String orgToken = UriUtils.parseQueryParameters(link, false).values().stream().map(strings -> strings.get(0)).findFirst().orElse(null); + 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.register("firstName", "lastName", "invalid@email.com", + user.getUsername(), "password", "password", null, false, null); + Assert.assertTrue(driver.getPageSource().contains("The provided token is not valid or has expired.")); + List users = testRealm().users().searchByEmail(user.getEmail(), true); + Assert.assertTrue(users.isEmpty()); + } finally { + resetTimeOffset(); + } + } } \ No newline at end of file