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>
This commit is contained in:
Alice W 2024-04-25 16:38:59 -04:00 committed by Pedro Igor
parent 0e9b42a0af
commit 584e92aaba
17 changed files with 361 additions and 7 deletions

View file

@ -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;

View file

@ -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;
/**

View file

@ -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";

View file

@ -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";

View file

@ -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;

View file

@ -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";

View file

@ -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;
}
}

View file

@ -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<InviteOrgActionToken> {
public InviteOrgActionTokenHandler() {
super(
InviteOrgActionToken.TOKEN_TYPE,
InviteOrgActionToken.class,
Messages.STALE_INVITE_ORG_LINK,
EventType.INVITE_ORG,
Errors.INVALID_TOKEN
);
}
@Override
public Predicate<? super InviteOrgActionToken>[] getVerifiers(ActionTokenContext<InviteOrgActionToken> 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<InviteOrgActionToken> 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);
}
}

View file

@ -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<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
MultivaluedMap<String, String> 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<InviteOrgActionToken> 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<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String tokenFromQuery = context.getHttpRequest().getUri().getQueryParameters().getFirst(Constants.ORG_TOKEN);
DefaultActionTokenKey aToken = null;
if(tokenFromQuery != null) {
try {
TokenVerifier<DefaultActionTokenKey> 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))) {

View file

@ -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<String, Object> 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) {

View file

@ -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());
}

View file

@ -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")

View file

@ -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";

View file

@ -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);

View file

@ -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

View file

@ -0,0 +1,4 @@
<#import "template.ftl" as layout>
<@layout.emailLayout>
${kcSanitize(msg("orgInviteBodyHtml", link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration)))?no_esc}
</@layout.emailLayout>

View file

@ -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=<p>Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address</p><p><a href="{0}">Link to e-mail address verification</a></p><p>This link will expire within {3}.</p><p>If you didn''t create this account, just ignore this message.</p>
orgInviteBodyHtml=<p>Someone has invited your account {2} account to join their keycloak 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 {3}.</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>