diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java index a3b6ab6fb7..a5271e1cb3 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/OrganizationMembersResource.java @@ -75,7 +75,12 @@ public interface OrganizationMembersResource { OrganizationMemberResource member(@PathParam("id") String id); @POST - @Path("invite") + @Path("invite-user") @Consumes(MediaType.APPLICATION_JSON) - Response inviteMember(UserRepresentation rep); + Response inviteUser(String email); + + @POST + @Path("invite-existing-user") + @Consumes(MediaType.APPLICATION_JSON) + Response inviteExistingUser(String id); } diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java new file mode 100644 index 0000000000..6b6e5d321f --- /dev/null +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationInvitationResource.java @@ -0,0 +1,137 @@ +package org.keycloak.organization.admin.resource; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken; +import org.keycloak.common.util.Time; +import org.keycloak.email.EmailException; +import org.keycloak.email.EmailTemplateProvider; +import org.keycloak.events.admin.OperationType; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OrganizationModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ServicesLogger; +import org.keycloak.services.Urls; +import org.keycloak.services.resources.LoginActionsService; +import org.keycloak.services.resources.admin.AdminEventBuilder; +import org.keycloak.storage.adapter.InMemoryUserAdapter; +import org.keycloak.utils.StringUtil; + +/** + * @author Pedro Igor + */ +public class OrganizationInvitationResource { + + private final KeycloakSession session; + private final RealmModel realm; + private final OrganizationProvider provider; + private final OrganizationModel organization; + private final AdminEventBuilder adminEvent; + private final int tokenExpiration; + + public OrganizationInvitationResource(KeycloakSession session, OrganizationModel organization, AdminEventBuilder adminEvent) { + this.session = session; + this.realm = session.getContext().getRealm(); + this.provider = session.getProvider(OrganizationProvider.class); + this.organization = organization; + this.adminEvent = adminEvent; + this.tokenExpiration = getTokenExpiration(); + } + + public Response inviteUser(String email) { + if (StringUtil.isBlank(email)) { + throw ErrorResponse.error("To invite a member you need to provide an email", Status.BAD_REQUEST); + } + + UserModel user = session.users().getUserByEmail(realm, email); + + if (user != null) { + OrganizationModel org = provider.getByMember(user); + + if (org.equals(organization)) { + throw ErrorResponse.error("User already a member of the organization", Status.CONFLICT); + } + + throw ErrorResponse.error("User already exists with this e-mail", Status.BAD_REQUEST); + } + + user = new InMemoryUserAdapter(session, realm, null); + user.setEmail(email); + + return sendInvitation(user); + } + + public Response inviteExistingUser(String id) { + if (StringUtil.isBlank(id)) { + throw new BadRequestException("To invite a member you need to provide the user id"); + } + + UserModel user = session.users().getUserById(realm, id); + + if (user == null) { + throw ErrorResponse.error("User does not exist", Status.BAD_REQUEST); + } + + return sendInvitation(user); + } + + private Response sendInvitation(UserModel user) { + String link = user.getId() == null ? createRegistrationLink(user) : createInvitationLink(user); + + try { + session.getProvider(EmailTemplateProvider.class) + .setRealm(realm) + .setUser(user) + .sendOrgInviteEmail(link, TimeUnit.SECONDS.toMinutes(tokenExpiration)); + } catch (EmailException e) { + ServicesLogger.LOGGER.failedToSendEmail(e); + throw ErrorResponse.error("Failed to send invite email", Status.INTERNAL_SERVER_ERROR); + } + + adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).success(); + + return Response.noContent().build(); + } + + private int getTokenExpiration() { + return Time.currentTime() + realm.getActionTokenGeneratedByAdminLifespan(); + } + + private String createInvitationLink(UserModel user) { + return LoginActionsService.actionTokenProcessor(session.getContext().getUri()) + .queryParam("key", createToken(user)) + .build(realm.getName()).toString(); + } + + private String createRegistrationLink(UserModel user) { + return OIDCLoginProtocolService.registrationsUrl(session.getContext().getUri().getBaseUriBuilder()) + .queryParam(OAuth2Constants.RESPONSE_TYPE, OIDCResponseType.CODE) + .queryParam(Constants.CLIENT_ID, Constants.ACCOUNT_MANAGEMENT_CLIENT_ID) + .queryParam(Constants.ORG_TOKEN, createToken(user)) + .buildFromMap(Map.of("realm", realm.getName(), "protocol", OIDCLoginProtocol.LOGIN_PROTOCOL)).toString(); + } + + private String createToken(UserModel user) { + InviteOrgActionToken token = new InviteOrgActionToken(user.getId(), tokenExpiration, user.getEmail(), Constants.ACCOUNT_MANAGEMENT_CLIENT_ID); + + token.setOrgId(organization.getId()); + + String redirectUri = Urls.accountBase(session.getContext().getUri().getBaseUri()).path("/").build(realm.getName()).toString(); + token.setRedirectUri(redirectUri); + + return token.serialize(session, realm, session.getContext().getUri()); + } + +} diff --git a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java index 71a13ae470..02cfe3aecb 100644 --- a/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java +++ b/services/src/main/java/org/keycloak/organization/admin/resource/OrganizationMemberResource.java @@ -35,9 +35,7 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; -import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.ext.Provider; -import java.util.Objects; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; @@ -54,9 +52,9 @@ import org.keycloak.email.EmailException; import org.keycloak.email.EmailTemplateProvider; import org.keycloak.events.admin.OperationType; import org.keycloak.models.*; -import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.organization.OrganizationProvider; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.idm.OrganizationRepresentation; @@ -65,12 +63,9 @@ import org.keycloak.services.ErrorResponse; import org.keycloak.services.ServicesLogger; import org.keycloak.services.Urls; import org.keycloak.services.resources.LoginActionsService; -import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.resources.admin.AdminEventBuilder; import org.keycloak.services.resources.admin.UserResource; -import org.keycloak.services.resources.admin.UsersResource; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; -import org.keycloak.storage.adapter.InMemoryUserAdapter; import org.keycloak.utils.StringUtil; @Provider @@ -122,60 +117,16 @@ public class OrganizationMemberResource { throw ErrorResponse.error("User is already a member of the organization.", Status.CONFLICT); } + @Path("invite-user") + public Response inviteUser(String email) { + return new OrganizationInvitationResource(session, organization, adminEvent).inviteUser(email); + } + @POST - @Path("invite") + @Path("invite-existing-user") @Consumes(MediaType.APPLICATION_JSON) - public Response inviteMember(UserRepresentation rep) { - if (rep == null || StringUtil.isBlank(rep.getEmail())) { - throw new BadRequestException("To invite a member you need to provide an email and/or username"); - } - - UserModel user = session.users().getUserByEmail(realm, rep.getEmail()); - - InviteOrgActionToken token = null; - String link = null; - int tokenExpiration = Time.currentTime() + realm.getActionTokenGeneratedByAdminLifespan(); - String redirectUri = Urls.accountBase(session.getContext().getUri().getBaseUri()).path("/").build(realm.getName()).toString(); - - if (user != null) { - token = new InviteOrgActionToken(user.getId(), tokenExpiration, user.getEmail(), Constants.ACCOUNT_MANAGEMENT_CLIENT_ID); - token.setOrgId(organization.getId()); - token.setRedirectUri(redirectUri); - link = LoginActionsService.actionTokenProcessor(session.getContext().getUri()) - .queryParam("key", token.serialize(session, realm, session.getContext().getUri())) - .build(realm.getName()).toString(); - } else { - // TODO this link really only works with implicit token grants enabled for the given client - // this path lets us invite a user that doesn't exist yet, letting them register into the organization - token = new InviteOrgActionToken(null, tokenExpiration, rep.getEmail(), Constants.ACCOUNT_MANAGEMENT_CLIENT_ID); - token.setOrgId(organization.getId()); - token.setRedirectUri(redirectUri); - Map params = Map.of("realm", realm.getName(), "protocol", "openid-connect"); - link = OIDCLoginProtocolService.registrationsUrl(session.getContext().getUri().getBaseUriBuilder()) - .queryParam(OAuth2Constants.RESPONSE_TYPE, OIDCResponseType.CODE) - .queryParam(Constants.CLIENT_ID, Constants.ACCOUNT_MANAGEMENT_CLIENT_ID) - .queryParam(Constants.ORG_TOKEN, token.serialize(session, realm, session.getContext().getUri())) - .buildFromMap(params).toString(); - } - - - if (user == null ) { - user = new InMemoryUserAdapter(session, realm, null); - user.setEmail(rep.getEmail()); - } - - try { - session - .getProvider(EmailTemplateProvider.class) - .setRealm(realm) - .setUser(user) - .sendOrgInviteEmail(link, TimeUnit.SECONDS.toMinutes(token.getExp())); - } catch (EmailException e) { - ServicesLogger.LOGGER.failedToSendEmail(e); - throw ErrorResponse.error("Failed to send invite email", Status.INTERNAL_SERVER_ERROR); - } - adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).success(); - return Response.noContent().build(); + public Response inviteExistingUser(String id) { + return new OrganizationInvitationResource(session, organization, adminEvent).inviteExistingUser(id); } @GET 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 9c104eab0d..2936c1a4f5 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 @@ -19,38 +19,28 @@ 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 static org.keycloak.models.Constants.ACCOUNT_MANAGEMENT_CLIENT_ID; import java.io.IOException; +import java.util.List; import java.util.Map; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; import jakarta.mail.internet.MimeMessage; import jakarta.ws.rs.core.Response; -import org.jboss.arquillian.drone.webdriver.htmlunit.DroneHtmlUnitDriver; import org.jboss.arquillian.graphene.page.Page; import org.junit.Rule; import org.junit.Test; import org.keycloak.admin.client.resource.OrganizationResource; -import org.keycloak.authentication.requiredactions.TermsAndConditions; import org.keycloak.common.Profile.Feature; -import org.keycloak.common.util.Time; import org.keycloak.common.util.UriUtils; -import org.keycloak.models.AuthenticationExecutionModel; -import org.keycloak.models.Constants; -import org.keycloak.models.UserModel; +import org.keycloak.cookie.CookieProvider; +import org.keycloak.cookie.CookieScope; +import org.keycloak.cookie.CookieType; import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; -import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.pages.RegisterPage; import org.keycloak.testsuite.util.GreenMailRule; @@ -93,23 +83,24 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); - organization.members().inviteMember(user).close(); + organization.members().inviteExistingUser(user.getId()).close(); MimeMessage message = greenMail.getLastReceivedMessage(); Assert.assertNotNull(message); String link = MailUtils.getPasswordResetEmailLink(message); - 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 infoPage.clickToContinue(); assertThat(infoPage.getInfo(), containsString("Your account has been updated.")); - Assert.assertTrue(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId()))); + // now a member + Assert.assertNotNull(organization.members().member(user.getId()).toRepresentation()); } @Test public void testInviteNewUserRegistration() throws IOException { - driver.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS); UserRepresentation user = UserBuilder.create() .username("invitedUser") .email("inviteduser@email") @@ -117,29 +108,25 @@ public class OrganizationInvitationLinkTest extends AbstractOrganizationTest { .build(); // User isn't created when we send the invite OrganizationResource organization = testRealm().organizations().get(createOrganization().getId()); - organization.members().inviteMember(user).close(); + 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.manage().timeouts().pageLoadTimeout(1, TimeUnit.DAYS); driver.navigate().to(link.trim()); Assert.assertFalse(organization.members().getAll().stream().anyMatch(actual -> user.getId().equals(actual.getId()))); - registerPage.assertCurrent(); registerPage.register("firstName", "lastName", user.getEmail(), user.getUsername(), "password", "password", null, false, null); - } + List users = testRealm().users().searchByEmail(user.getEmail(), true); + Assert.assertFalse(users.isEmpty()); + // user is a member + Assert.assertNotNull(organization.members().member(users.get(0).getId()).toRepresentation()); - private UserRepresentation assertUserRegistered(String userId, String username) { - events.expectLogin().detail("username", username.toLowerCase()).user(userId).assertEvent(); - - UserRepresentation user = testRealm().users().get(userId).toRepresentation(); - org.junit.Assert.assertNotNull(user); - org.junit.Assert.assertNotNull(user.getCreatedTimestamp()); - return user; + // authenticated to the account console + Assert.assertTrue(driver.getPageSource().contains("Account Management")); + Assert.assertNotNull(driver.manage().getCookieNamed(CookieType.IDENTITY.getName())); } } \ No newline at end of file