From 584e92aabada4053f4c027bcf0dabbe2c4261274 Mon Sep 17 00:00:00 2001 From: Alice W <105500542+alice-wondered@users.noreply.github.com> Date: Thu, 25 Apr 2024 16:38:59 -0400 Subject: [PATCH] Add support for organizational invites to new and existing users based on tokens Signed-off-by: Alice W <105500542+alice-wondered@users.noreply.github.com> --- .../infinispan/entities/CachedRealm.java | 1 + .../keycloak/email/EmailTemplateProvider.java | 2 + .../java/org/keycloak/events/Details.java | 1 + .../main/java/org/keycloak/events/Errors.java | 2 + .../java/org/keycloak/events/EventType.java | 5 +- .../java/org/keycloak/models/Constants.java | 2 + .../inviteorg/InviteOrgActionToken.java | 66 ++++++++ .../InviteOrgActionTokenHandler.java | 141 ++++++++++++++++++ .../forms/RegistrationUserCreation.java | 58 ++++++- .../FreeMarkerEmailTemplateProvider.java | 7 + .../FreeMarkerLoginFormsProvider.java | 8 + .../resource/OrganizationMemberResource.java | 53 +++++++ .../keycloak/services/messages/Messages.java | 8 + .../resources/LoginActionsService.java | 8 + ...tion.actiontoken.ActionTokenHandlerFactory | 1 + .../theme/base/email/html/org-invite.ftl | 4 + .../email/messages/messages_en.properties | 1 + 17 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionToken.java create mode 100644 services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java create mode 100644 themes/src/main/resources/theme/base/email/html/org-invite.ftl diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java index 29e3c8c0fc..dfccd91df3 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java @@ -139,6 +139,7 @@ public class CachedRealm extends AbstractExtendableRevisioned { protected AuthenticationFlowModel browserFlow; protected AuthenticationFlowModel registrationFlow; + protected AuthenticationFlowModel orgRegistrationFlow; protected AuthenticationFlowModel directGrantFlow; protected AuthenticationFlowModel resetCredentialsFlow; protected AuthenticationFlowModel clientAuthenticationFlow; diff --git a/server-spi-private/src/main/java/org/keycloak/email/EmailTemplateProvider.java b/server-spi-private/src/main/java/org/keycloak/email/EmailTemplateProvider.java index e5ddbcccbd..fc53d4f408 100755 --- a/server-spi-private/src/main/java/org/keycloak/email/EmailTemplateProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/email/EmailTemplateProvider.java @@ -77,6 +77,8 @@ public interface EmailTemplateProvider extends Provider { void sendVerifyEmail(String link, long expirationInMinutes) throws EmailException; + void sendOrgInviteEmail(String link, long expirationInMinutes) throws EmailException; + void sendEmailUpdateConfirmation(String link, long expirationInMinutes, String address) throws EmailException; /** diff --git a/server-spi-private/src/main/java/org/keycloak/events/Details.java b/server-spi-private/src/main/java/org/keycloak/events/Details.java index 505808a191..0b8cd3d95b 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/Details.java +++ b/server-spi-private/src/main/java/org/keycloak/events/Details.java @@ -49,6 +49,7 @@ public interface Details { String UPDATED_LAST_NAME = PREF_UPDATED + "last_name"; String REMEMBER_ME = "remember_me"; String TOKEN_ID = "token_id"; + String ORG_ID = "org_id"; String REFRESH_TOKEN_ID = "refresh_token_id"; String REFRESH_TOKEN_TYPE = "refresh_token_type"; String REFRESH_TOKEN_SUB = "refresh_token_sub"; diff --git a/server-spi-private/src/main/java/org/keycloak/events/Errors.java b/server-spi-private/src/main/java/org/keycloak/events/Errors.java index fb57d025d5..3b1cdeaf0d 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/Errors.java +++ b/server-spi-private/src/main/java/org/keycloak/events/Errors.java @@ -47,6 +47,8 @@ public interface Errors { String USERNAME_IN_USE = "username_in_use"; String EMAIL_IN_USE = "email_in_use"; String EMAIL_ALREADY_VERIFIED = "email_already_verified"; + String ORG_NOT_FOUND = "org_not_found"; + String USER_ORG_MEMBER_ALREADY = "user_org_member_already"; String INVALID_REDIRECT_URI = "invalid_redirect_uri"; String INVALID_CODE = "invalid_code"; diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java index 586076761f..a203562d7b 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java @@ -57,6 +57,7 @@ public enum EventType implements EnumWithStableIndex { REMOVE_FEDERATED_IDENTITY(9, true), REMOVE_FEDERATED_IDENTITY_ERROR(0x10000 + REMOVE_FEDERATED_IDENTITY.getStableIndex(), true), + UPDATE_EMAIL(10, true), UPDATE_EMAIL_ERROR(0x10000 + UPDATE_EMAIL.getStableIndex(), true), UPDATE_PROFILE(11, true), @@ -167,7 +168,9 @@ public enum EventType implements EnumWithStableIndex { OAUTH2_EXTENSION_GRANT_ERROR(0x10000 + OAUTH2_EXTENSION_GRANT.getStableIndex(), true), FEDERATED_IDENTITY_OVERRIDE_LINK(55, true), - FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR(0x10000 + FEDERATED_IDENTITY_OVERRIDE_LINK.getStableIndex(), true); + FEDERATED_IDENTITY_OVERRIDE_LINK_ERROR(0x10000 + FEDERATED_IDENTITY_OVERRIDE_LINK.getStableIndex(), true), + INVITE_ORG(60, true), + INVITE_ORG_ERROR(0x10000 + INVITE_ORG.getStableIndex(), true); private final int stableIndex; private final boolean saveByDefault; diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java index f6256a9994..b10de5fa45 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java +++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java @@ -79,6 +79,8 @@ public final class Constants { public static final String VERIFY_EMAIL_KEY = "VERIFY_EMAIL_KEY"; public static final String EXECUTION = "execution"; public static final String CLIENT_ID = "client_id"; + public static final String ORG_TOKEN = "org_token"; + public static final String ORG_INVITE = "org_invite"; public static final String TAB_ID = "tab_id"; public static final String CLIENT_DATA = "client_data"; diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionToken.java b/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionToken.java new file mode 100644 index 0000000000..b09daa8aca --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionToken.java @@ -0,0 +1,66 @@ +/* + * Copyright 2017 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.actiontoken.inviteorg; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.authentication.actiontoken.DefaultActionToken; +import org.keycloak.models.Constants; + +/** + * Representation of a token that represents a time-limited verify e-mail action. + * + * @author hmlnarik + */ +public class InviteOrgActionToken extends DefaultActionToken { + + public static final String TOKEN_TYPE = "invite-org"; + + private static final String JSON_FIELD_REDIRECT_URI = "reduri"; + private static final String JSON_ORG_ID = "org_id"; + + @JsonProperty(JSON_FIELD_REDIRECT_URI) + private String redirectUri; + + + @JsonProperty(JSON_ORG_ID) + private String orgId; + + public InviteOrgActionToken(String userId, int absoluteExpirationInSecs, String email, String clientId) { + super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null); + setEmail(email); + this.issuedFor = clientId; + } + + private InviteOrgActionToken() { + } + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public String getOrgId() { + return orgId; + } + + public void setOrgId(String orgId) { + this.orgId = orgId; + } +} diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java new file mode 100644 index 0000000000..b07b63afe8 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java @@ -0,0 +1,141 @@ +/* + * Copyright 2017 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.actiontoken.inviteorg; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.UriInfo; +import org.keycloak.TokenVerifier.Predicate; +import org.keycloak.authentication.AuthenticationProcessor; +import org.keycloak.authentication.actiontoken.AbstractActionTokenHandler; +import org.keycloak.authentication.actiontoken.ActionTokenContext; +import org.keycloak.authentication.actiontoken.TokenUtils; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.*; +import org.keycloak.models.UserModel.RequiredAction; +import org.keycloak.organization.OrganizationProvider; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.RedirectUtils; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.services.messages.Messages; +import org.keycloak.sessions.AuthenticationSessionCompoundId; +import org.keycloak.sessions.AuthenticationSessionModel; + +import java.util.Objects; +import java.util.stream.Stream; + +/** + * Action token handler for handling invitation of an existing user to an organization. A new user is handled in registration {@link org.keycloak.services.resources.LoginActionsService}. + */ +public class InviteOrgActionTokenHandler extends AbstractActionTokenHandler { + + public InviteOrgActionTokenHandler() { + super( + InviteOrgActionToken.TOKEN_TYPE, + InviteOrgActionToken.class, + Messages.STALE_INVITE_ORG_LINK, + EventType.INVITE_ORG, + Errors.INVALID_TOKEN + ); + } + + @Override + public Predicate[] getVerifiers(ActionTokenContext tokenContext) { + return TokenUtils.predicates( + TokenUtils.checkThat( + t -> Objects.equals(t.getEmail(), tokenContext.getAuthenticationSession().getAuthenticatedUser().getEmail()), + Errors.INVALID_EMAIL, getDefaultErrorMessage() + ) + ); + } + + @Override + public Response handleToken(InviteOrgActionToken token, ActionTokenContext tokenContext) { + UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser(); + KeycloakSession session = tokenContext.getSession(); + OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class); + AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); + EventBuilder event = tokenContext.getEvent(); + + event.event(EventType.INVITE_ORG).detail(Details.USERNAME, user.getUsername()); + + OrganizationModel organization = orgProvider.getById(token.getOrgId()); + + if (orgProvider.getByMember(user) != null) { + event.user(user).error(Errors.USER_ORG_MEMBER_ALREADY); + return session.getProvider(LoginFormsProvider.class) + .setAuthenticationSession(authSession) + .setInfo(Messages.ORG_MEMBER_ALREADY, user.getUsername()) + .createInfoPage(); + } + + if (organization == null) { + event.user(user).error(Errors.ORG_NOT_FOUND); + return session.getProvider(LoginFormsProvider.class) + .setAuthenticationSession(authSession) + .setInfo(Messages.ORG_NOT_FOUND, token.getOrgId()) + .createInfoPage(); + } + + final UriInfo uriInfo = tokenContext.getUriInfo(); + final RealmModel realm = tokenContext.getRealm(); + + if (tokenContext.isAuthenticationSessionFresh()) { + // Update the authentication session in the token + String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId(); + token.setCompoundAuthenticationSessionId(authSessionEncodedId); + UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo), + authSession.getClient().getClientId(), authSession.getTabId(), AuthenticationProcessor.getClientData(session, authSession)); + String confirmUri = builder.build(realm.getName()).toString(); + + return session.getProvider(LoginFormsProvider.class) + .setAuthenticationSession(authSession) + .setSuccess(Messages.CONFIRM_EXECUTION_OF_ACTIONS) + .setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri) + .createInfoPage(); + } + + // if we made it this far then go ahead and add the user to the organization + orgProvider.addMember(orgProvider.getById(token.getOrgId()), user); + + String redirectUri = RedirectUtils.verifyRedirectUri(tokenContext.getSession(), token.getRedirectUri(), authSession.getClient()); + + if (redirectUri != null) { + authSession.setAuthNote(AuthenticationManager.SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS, "true"); + authSession.setRedirectUri(redirectUri); + authSession.setClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM, redirectUri); + } + + event.success(); + + tokenContext.setEvent(event.clone().removeDetail(Details.EMAIL).event(EventType.LOGIN)); + + String nextAction = AuthenticationManager.nextRequiredAction(session, authSession, tokenContext.getRequest(), event); + return AuthenticationManager.redirectToRequiredActions(session, realm, authSession, uriInfo, nextAction); + } + + private boolean isVerifyEmailActionSet(UserModel user, AuthenticationSessionModel authSession) { + return Stream.concat(user.getRequiredActionsStream(), authSession.getRequiredActions().stream()) + .anyMatch(RequiredAction.VERIFY_EMAIL.name()::equals); + } +} 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 63d90906c1..f9204a49f1 100755 --- a/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java +++ b/services/src/main/java/org/keycloak/authentication/forms/RegistrationUserCreation.java @@ -19,25 +19,25 @@ 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; 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.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.AuthenticationExecutionModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.models.RealmModel; -import org.keycloak.models.RequiredActionProviderModel; -import org.keycloak.models.UserModel; +import org.keycloak.models.*; import org.keycloak.models.utils.FormMessage; +import org.keycloak.organization.OrganizationProvider; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.services.messages.Messages; @@ -72,6 +72,8 @@ 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); @@ -110,6 +112,27 @@ 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(); } @@ -123,6 +146,19 @@ 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); @@ -138,6 +174,16 @@ 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.getSubject(); + 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); + } + user.setEnabled(true); if ("on".equals(formData.getFirst(RegistrationTermsAndConditions.FIELD))) { diff --git a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java index 8cfc1daeb1..57ed7a9e22 100755 --- a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java +++ b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java @@ -162,6 +162,13 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider { send("emailVerificationSubject", "email-verification.ftl", attributes); } + @Override + public void sendOrgInviteEmail(String link, long expirationInMinutes) throws EmailException { + Map attributes = new HashMap<>(this.attributes); + addLinkInfoIntoAttributes(link, expirationInMinutes, attributes); + send("orgInviteSubject", "org-invite.ftl", attributes); + } + @Override public void sendEmailUpdateConfirmation(String link, long expirationInMinutes, String newEmail) throws EmailException { if (newEmail == null) { diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index a863693559..08d91075b5 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -131,6 +131,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { protected RealmModel realm; protected ClientModel client; protected UriInfo uriInfo; + protected String tokenString; protected FreeMarkerProvider freeMarker; @@ -146,6 +147,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { this.realm = session.getContext().getRealm(); this.client = session.getContext().getClient(); this.uriInfo = session.getContext().getUri(); + this.tokenString = uriInfo.getQueryParameters().getFirst(Constants.ORG_TOKEN); } @SuppressWarnings("unchecked") @@ -287,6 +289,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { case LOGIN_RESET_OTP: attributes.put("configuredOtpCredentials", new TotpLoginBean(session, realm, user, (String) this.attributes.get(OTPFormAuthenticator.SELECTED_OTP_CREDENTIAL_ID))); break; + // case ORG_REGISTER: + // TODO can consume this attribute to display some information on the organization, but will require more processing than what's here now + // attributes.put("org", tokenString); case REGISTER: RegisterBean rb = new RegisterBean(formData, session); //legacy bean for static template @@ -370,6 +375,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { uriBuilder.replaceQuery(null); } + if (tokenString != null) { + uriBuilder.queryParam(Constants.ORG_TOKEN, tokenString); + } if (client != null) { uriBuilder.queryParam(Constants.CLIENT_ID, client.getClientId()); } 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 11c60b3b28..cc46b6a887 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 @@ -17,6 +17,7 @@ package org.keycloak.organization.admin.resource; +import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import jakarta.ws.rs.Consumes; @@ -43,11 +44,20 @@ import org.keycloak.models.ModelException; import org.keycloak.models.OrganizationModel; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; + +import org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionToken; +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.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.services.ErrorResponse; +import org.keycloak.services.ServicesLogger; +import org.keycloak.services.resources.LoginActionsService; import org.keycloak.services.resources.admin.AdminEventBuilder; import org.keycloak.services.resources.admin.UserResource; import org.keycloak.services.resources.admin.UsersResource; @@ -103,6 +113,49 @@ public class OrganizationMemberResource { throw ErrorResponse.error("User is already a member of the organization.", Status.CONFLICT); } + @POST + @Path("invite") + @Consumes(MediaType.APPLICATION_JSON) + public Response inviteMember(UserRepresentation rep) { + if (rep == null || StringUtil.isBlank(rep.getEmail()) || StringUtil.isBlank(rep.getUsername())) { + throw new BadRequestException("To invite a member you need to provide an email and/or username"); + } + + UserModel user = session.users().getUserByUsername(realm, rep.getEmail()); + + InviteOrgActionToken token = null; + // TODO not sure if this client id is right or if we should get one from the user somehow... + // TODO not really sure if the token is getting signed so we need to figure out where that's happening... maybe in the serialize method? + // TODO the expiration is set to a day in seconds but we should probably get this from configuration instead + String link = null; + if (user != null) { + token = new InviteOrgActionToken(user.getId(), 86400, user.getEmail(), session.getContext().getClient().getClientId()); + link = LoginActionsService.actionTokenProcessor(session.getContext().getUri()) + .queryParam("key", token.serialize(session, realm, session.getContext().getUri())) + .build(realm.getName()).toString(); + } else { + // this path lets us invite a user that doesn't exist yet, letting them register into the organization + token = new InviteOrgActionToken(null, 86400, rep.getEmail(), session.getContext().getClient().getClientId()); + link = LoginActionsService.registrationFormProcessor(session.getContext().getUri()) + .queryParam(Constants.ORG_TOKEN, token.serialize(session, realm, session.getContext().getUri())) + .build(realm.getName()).toString(); + } + token.setOrgId(organization.getId()); + + 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(); + } + @GET @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Return a paginated list of organization members filtered according to the specified parameters") diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index 02a4036bba..12d991bb2d 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -37,6 +37,12 @@ public class Messages { public static final String INVALID_EMAIL = "invalidEmailMessage"; + public static final String ORG_NOT_FOUND = "orgNotFoundMessage"; + + public static final String ORG_MEMBER_ALREADY = "orgMemberAlready"; + + public static final String INVALID_ORG_INVITE = "invalidOrgInviteMessage"; + public static final String ACCOUNT_DISABLED = "accountDisabledMessage"; public static final String ACCOUNT_TEMPORARILY_DISABLED = "accountTemporarilyDisabledMessage"; @@ -183,6 +189,8 @@ public class Messages { public static final String STALE_VERIFY_EMAIL_LINK = "staleEmailVerificationLink"; + public static final String STALE_INVITE_ORG_LINK = "staleInviteOrgLink"; + public static final String IDENTITY_PROVIDER_UNEXPECTED_ERROR = "identityProviderUnexpectedErrorMessage"; public static final String IDENTITY_PROVIDER_UNMATCHED_ESSENTIAL_CLAIM_ERROR = "federatedIdentityUnmatchedEssentialClaimMessage"; diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 71fc06a9bb..2fa75accfa 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -746,10 +746,14 @@ public class LoginActionsService { @GET public Response registerPage(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead @QueryParam(SESSION_CODE) String code, + // TODO this is unused but having it here adds it to openapi. What's the better approach? + // Should this be pulled off the query params and then injected into the flow processor as its own thing? + @QueryParam(Constants.ORG_TOKEN) String orgToken, @QueryParam(Constants.EXECUTION) String execution, @QueryParam(Constants.CLIENT_ID) String clientId, @QueryParam(Constants.CLIENT_DATA) String clientData, @QueryParam(Constants.TAB_ID) String tabId) { + return registerRequest(authSessionId, code, execution, clientId, tabId,clientData); } @@ -764,6 +768,7 @@ public class LoginActionsService { @POST public Response processRegister(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead @QueryParam(SESSION_CODE) String code, + @QueryParam(Constants.ORG_TOKEN) String orgToken, @QueryParam(Constants.EXECUTION) String execution, @QueryParam(Constants.CLIENT_ID) String clientId, @QueryParam(Constants.CLIENT_DATA) String clientData, @@ -774,6 +779,9 @@ public class LoginActionsService { private Response registerRequest(String authSessionId, String code, String execution, String clientId, String tabId, String clientData) { event.event(EventType.REGISTER); + + // TODO if we parse the org token here and then pass in the already decoded token we can save ourselves some duplicated work + if (!realm.isRegistrationAllowed()) { event.error(Errors.REGISTRATION_DISABLED); return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.REGISTRATION_NOT_ALLOWED); diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory index a345bfaf71..fe8d6adf99 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory @@ -3,3 +3,4 @@ org.keycloak.authentication.actiontoken.execactions.ExecuteActionsActionTokenHan org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionTokenHandler org.keycloak.authentication.actiontoken.idpverifyemail.IdpVerifyAccountLinkActionTokenHandler org.keycloak.authentication.actiontoken.updateemail.UpdateEmailActionTokenHandler +org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionTokenHandler diff --git a/themes/src/main/resources/theme/base/email/html/org-invite.ftl b/themes/src/main/resources/theme/base/email/html/org-invite.ftl new file mode 100644 index 0000000000..d7f72e3e20 --- /dev/null +++ b/themes/src/main/resources/theme/base/email/html/org-invite.ftl @@ -0,0 +1,4 @@ +<#import "template.ftl" as layout> +<@layout.emailLayout> +${kcSanitize(msg("orgInviteBodyHtml", link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration)))?no_esc} + diff --git a/themes/src/main/resources/theme/base/email/messages/messages_en.properties b/themes/src/main/resources/theme/base/email/messages/messages_en.properties index bd7a1935d7..dc593deada 100755 --- a/themes/src/main/resources/theme/base/email/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/email/messages/messages_en.properties @@ -1,6 +1,7 @@ emailVerificationSubject=Verify email emailVerificationBody=Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address\n\n{0}\n\nThis link will expire within {3}.\n\nIf you didn''t create this account, just ignore this message. emailVerificationBodyHtml=

Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address

Link to e-mail address verification

This link will expire within {3}.

If you didn''t create this account, just ignore this message.

+orgInviteBodyHtml=

Someone has invited your account {2} account to join their keycloak organization! Click the link below to join.

Link to join the organization

This link will expire within {3}.

If you don't want to join the organization, just ignore this message.

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=

To update your {2} account with email address {1}, click the link below

{0}

This link will expire within {3}.

If you don''t want to proceed with this modification, just ignore this message.