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:
parent
0e9b42a0af
commit
584e92aaba
17 changed files with 361 additions and 7 deletions
|
@ -139,6 +139,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
||||||
|
|
||||||
protected AuthenticationFlowModel browserFlow;
|
protected AuthenticationFlowModel browserFlow;
|
||||||
protected AuthenticationFlowModel registrationFlow;
|
protected AuthenticationFlowModel registrationFlow;
|
||||||
|
protected AuthenticationFlowModel orgRegistrationFlow;
|
||||||
protected AuthenticationFlowModel directGrantFlow;
|
protected AuthenticationFlowModel directGrantFlow;
|
||||||
protected AuthenticationFlowModel resetCredentialsFlow;
|
protected AuthenticationFlowModel resetCredentialsFlow;
|
||||||
protected AuthenticationFlowModel clientAuthenticationFlow;
|
protected AuthenticationFlowModel clientAuthenticationFlow;
|
||||||
|
|
|
@ -77,6 +77,8 @@ public interface EmailTemplateProvider extends Provider {
|
||||||
|
|
||||||
void sendVerifyEmail(String link, long expirationInMinutes) throws EmailException;
|
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;
|
void sendEmailUpdateConfirmation(String link, long expirationInMinutes, String address) throws EmailException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -49,6 +49,7 @@ public interface Details {
|
||||||
String UPDATED_LAST_NAME = PREF_UPDATED + "last_name";
|
String UPDATED_LAST_NAME = PREF_UPDATED + "last_name";
|
||||||
String REMEMBER_ME = "remember_me";
|
String REMEMBER_ME = "remember_me";
|
||||||
String TOKEN_ID = "token_id";
|
String TOKEN_ID = "token_id";
|
||||||
|
String ORG_ID = "org_id";
|
||||||
String REFRESH_TOKEN_ID = "refresh_token_id";
|
String REFRESH_TOKEN_ID = "refresh_token_id";
|
||||||
String REFRESH_TOKEN_TYPE = "refresh_token_type";
|
String REFRESH_TOKEN_TYPE = "refresh_token_type";
|
||||||
String REFRESH_TOKEN_SUB = "refresh_token_sub";
|
String REFRESH_TOKEN_SUB = "refresh_token_sub";
|
||||||
|
|
|
@ -47,6 +47,8 @@ public interface Errors {
|
||||||
String USERNAME_IN_USE = "username_in_use";
|
String USERNAME_IN_USE = "username_in_use";
|
||||||
String EMAIL_IN_USE = "email_in_use";
|
String EMAIL_IN_USE = "email_in_use";
|
||||||
String EMAIL_ALREADY_VERIFIED = "email_already_verified";
|
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_REDIRECT_URI = "invalid_redirect_uri";
|
||||||
String INVALID_CODE = "invalid_code";
|
String INVALID_CODE = "invalid_code";
|
||||||
|
|
|
@ -57,6 +57,7 @@ public enum EventType implements EnumWithStableIndex {
|
||||||
REMOVE_FEDERATED_IDENTITY(9, true),
|
REMOVE_FEDERATED_IDENTITY(9, true),
|
||||||
REMOVE_FEDERATED_IDENTITY_ERROR(0x10000 + REMOVE_FEDERATED_IDENTITY.getStableIndex(), true),
|
REMOVE_FEDERATED_IDENTITY_ERROR(0x10000 + REMOVE_FEDERATED_IDENTITY.getStableIndex(), true),
|
||||||
|
|
||||||
|
|
||||||
UPDATE_EMAIL(10, true),
|
UPDATE_EMAIL(10, true),
|
||||||
UPDATE_EMAIL_ERROR(0x10000 + UPDATE_EMAIL.getStableIndex(), true),
|
UPDATE_EMAIL_ERROR(0x10000 + UPDATE_EMAIL.getStableIndex(), true),
|
||||||
UPDATE_PROFILE(11, true),
|
UPDATE_PROFILE(11, true),
|
||||||
|
@ -167,7 +168,9 @@ public enum EventType implements EnumWithStableIndex {
|
||||||
OAUTH2_EXTENSION_GRANT_ERROR(0x10000 + OAUTH2_EXTENSION_GRANT.getStableIndex(), true),
|
OAUTH2_EXTENSION_GRANT_ERROR(0x10000 + OAUTH2_EXTENSION_GRANT.getStableIndex(), true),
|
||||||
|
|
||||||
FEDERATED_IDENTITY_OVERRIDE_LINK(55, 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 int stableIndex;
|
||||||
private final boolean saveByDefault;
|
private final boolean saveByDefault;
|
||||||
|
|
|
@ -79,6 +79,8 @@ public final class Constants {
|
||||||
public static final String VERIFY_EMAIL_KEY = "VERIFY_EMAIL_KEY";
|
public static final String VERIFY_EMAIL_KEY = "VERIFY_EMAIL_KEY";
|
||||||
public static final String EXECUTION = "execution";
|
public static final String EXECUTION = "execution";
|
||||||
public static final String CLIENT_ID = "client_id";
|
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 TAB_ID = "tab_id";
|
||||||
public static final String CLIENT_DATA = "client_data";
|
public static final String CLIENT_DATA = "client_data";
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,25 +19,25 @@ package org.keycloak.authentication.forms;
|
||||||
|
|
||||||
import jakarta.ws.rs.core.MultivaluedHashMap;
|
import jakarta.ws.rs.core.MultivaluedHashMap;
|
||||||
import org.keycloak.Config;
|
import org.keycloak.Config;
|
||||||
|
import org.keycloak.TokenVerifier;
|
||||||
import org.keycloak.authentication.AuthenticationFlowError;
|
import org.keycloak.authentication.AuthenticationFlowError;
|
||||||
import org.keycloak.authentication.AuthenticationFlowException;
|
import org.keycloak.authentication.AuthenticationFlowException;
|
||||||
import org.keycloak.authentication.FormAction;
|
import org.keycloak.authentication.FormAction;
|
||||||
import org.keycloak.authentication.FormActionFactory;
|
import org.keycloak.authentication.FormActionFactory;
|
||||||
import org.keycloak.authentication.FormContext;
|
import org.keycloak.authentication.FormContext;
|
||||||
import org.keycloak.authentication.ValidationContext;
|
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.authentication.requiredactions.TermsAndConditions;
|
||||||
|
import org.keycloak.common.VerificationException;
|
||||||
import org.keycloak.common.util.Time;
|
import org.keycloak.common.util.Time;
|
||||||
import org.keycloak.events.Details;
|
import org.keycloak.events.Details;
|
||||||
import org.keycloak.events.Errors;
|
import org.keycloak.events.Errors;
|
||||||
import org.keycloak.events.EventType;
|
import org.keycloak.events.EventType;
|
||||||
import org.keycloak.forms.login.LoginFormsProvider;
|
import org.keycloak.forms.login.LoginFormsProvider;
|
||||||
import org.keycloak.models.AuthenticationExecutionModel;
|
import org.keycloak.models.*;
|
||||||
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.utils.FormMessage;
|
import org.keycloak.models.utils.FormMessage;
|
||||||
|
import org.keycloak.organization.OrganizationProvider;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.provider.ProviderConfigProperty;
|
import org.keycloak.provider.ProviderConfigProperty;
|
||||||
import org.keycloak.services.messages.Messages;
|
import org.keycloak.services.messages.Messages;
|
||||||
|
@ -72,6 +72,8 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
|
||||||
@Override
|
@Override
|
||||||
public void validate(ValidationContext context) {
|
public void validate(ValidationContext context) {
|
||||||
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
|
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
|
||||||
|
MultivaluedMap<String, String> queryParameters = context.getHttpRequest().getUri().getQueryParameters();
|
||||||
|
|
||||||
context.getEvent().detail(Details.REGISTER_METHOD, "form");
|
context.getEvent().detail(Details.REGISTER_METHOD, "form");
|
||||||
|
|
||||||
UserProfile profile = getOrCreateUserProfile(context, formData);
|
UserProfile profile = getOrCreateUserProfile(context, formData);
|
||||||
|
@ -110,6 +112,27 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
|
||||||
context.validationError(formData, errors);
|
context.validationError(formData, errors);
|
||||||
return;
|
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();
|
context.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,6 +146,19 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
|
||||||
checkNotOtherUserAuthenticating(context);
|
checkNotOtherUserAuthenticating(context);
|
||||||
|
|
||||||
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
|
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 email = formData.getFirst(UserModel.EMAIL);
|
||||||
String username = formData.getFirst(UserModel.USERNAME);
|
String username = formData.getFirst(UserModel.USERNAME);
|
||||||
|
@ -138,6 +174,16 @@ public class RegistrationUserCreation implements FormAction, FormActionFactory {
|
||||||
UserProfile profile = getOrCreateUserProfile(context, formData);
|
UserProfile profile = getOrCreateUserProfile(context, formData);
|
||||||
UserModel user = profile.create();
|
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);
|
user.setEnabled(true);
|
||||||
|
|
||||||
if ("on".equals(formData.getFirst(RegistrationTermsAndConditions.FIELD))) {
|
if ("on".equals(formData.getFirst(RegistrationTermsAndConditions.FIELD))) {
|
||||||
|
|
|
@ -162,6 +162,13 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
|
||||||
send("emailVerificationSubject", "email-verification.ftl", attributes);
|
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
|
@Override
|
||||||
public void sendEmailUpdateConfirmation(String link, long expirationInMinutes, String newEmail) throws EmailException {
|
public void sendEmailUpdateConfirmation(String link, long expirationInMinutes, String newEmail) throws EmailException {
|
||||||
if (newEmail == null) {
|
if (newEmail == null) {
|
||||||
|
|
|
@ -131,6 +131,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
||||||
protected RealmModel realm;
|
protected RealmModel realm;
|
||||||
protected ClientModel client;
|
protected ClientModel client;
|
||||||
protected UriInfo uriInfo;
|
protected UriInfo uriInfo;
|
||||||
|
protected String tokenString;
|
||||||
|
|
||||||
protected FreeMarkerProvider freeMarker;
|
protected FreeMarkerProvider freeMarker;
|
||||||
|
|
||||||
|
@ -146,6 +147,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
||||||
this.realm = session.getContext().getRealm();
|
this.realm = session.getContext().getRealm();
|
||||||
this.client = session.getContext().getClient();
|
this.client = session.getContext().getClient();
|
||||||
this.uriInfo = session.getContext().getUri();
|
this.uriInfo = session.getContext().getUri();
|
||||||
|
this.tokenString = uriInfo.getQueryParameters().getFirst(Constants.ORG_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
@ -287,6 +289,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
||||||
case LOGIN_RESET_OTP:
|
case LOGIN_RESET_OTP:
|
||||||
attributes.put("configuredOtpCredentials", new TotpLoginBean(session, realm, user, (String) this.attributes.get(OTPFormAuthenticator.SELECTED_OTP_CREDENTIAL_ID)));
|
attributes.put("configuredOtpCredentials", new TotpLoginBean(session, realm, user, (String) this.attributes.get(OTPFormAuthenticator.SELECTED_OTP_CREDENTIAL_ID)));
|
||||||
break;
|
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:
|
case REGISTER:
|
||||||
RegisterBean rb = new RegisterBean(formData, session);
|
RegisterBean rb = new RegisterBean(formData, session);
|
||||||
//legacy bean for static template
|
//legacy bean for static template
|
||||||
|
@ -370,6 +375,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
||||||
uriBuilder.replaceQuery(null);
|
uriBuilder.replaceQuery(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tokenString != null) {
|
||||||
|
uriBuilder.queryParam(Constants.ORG_TOKEN, tokenString);
|
||||||
|
}
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
uriBuilder.queryParam(Constants.CLIENT_ID, client.getClientId());
|
uriBuilder.queryParam(Constants.CLIENT_ID, client.getClientId());
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
|
|
||||||
package org.keycloak.organization.admin.resource;
|
package org.keycloak.organization.admin.resource;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
|
@ -43,11 +44,20 @@ import org.keycloak.models.ModelException;
|
||||||
import org.keycloak.models.OrganizationModel;
|
import org.keycloak.models.OrganizationModel;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserModel;
|
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.models.utils.ModelToRepresentation;
|
||||||
import org.keycloak.organization.OrganizationProvider;
|
import org.keycloak.organization.OrganizationProvider;
|
||||||
import org.keycloak.representations.idm.OrganizationRepresentation;
|
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||||
import org.keycloak.representations.idm.UserRepresentation;
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
import org.keycloak.services.ErrorResponse;
|
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.AdminEventBuilder;
|
||||||
import org.keycloak.services.resources.admin.UserResource;
|
import org.keycloak.services.resources.admin.UserResource;
|
||||||
import org.keycloak.services.resources.admin.UsersResource;
|
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);
|
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
|
@GET
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Operation( summary = "Return a paginated list of organization members filtered according to the specified parameters")
|
@Operation( summary = "Return a paginated list of organization members filtered according to the specified parameters")
|
||||||
|
|
|
@ -37,6 +37,12 @@ public class Messages {
|
||||||
|
|
||||||
public static final String INVALID_EMAIL = "invalidEmailMessage";
|
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_DISABLED = "accountDisabledMessage";
|
||||||
|
|
||||||
public static final String ACCOUNT_TEMPORARILY_DISABLED = "accountTemporarilyDisabledMessage";
|
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_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_UNEXPECTED_ERROR = "identityProviderUnexpectedErrorMessage";
|
||||||
|
|
||||||
public static final String IDENTITY_PROVIDER_UNMATCHED_ESSENTIAL_CLAIM_ERROR = "federatedIdentityUnmatchedEssentialClaimMessage";
|
public static final String IDENTITY_PROVIDER_UNMATCHED_ESSENTIAL_CLAIM_ERROR = "federatedIdentityUnmatchedEssentialClaimMessage";
|
||||||
|
|
|
@ -746,10 +746,14 @@ public class LoginActionsService {
|
||||||
@GET
|
@GET
|
||||||
public Response registerPage(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead
|
public Response registerPage(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead
|
||||||
@QueryParam(SESSION_CODE) String code,
|
@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.EXECUTION) String execution,
|
||||||
@QueryParam(Constants.CLIENT_ID) String clientId,
|
@QueryParam(Constants.CLIENT_ID) String clientId,
|
||||||
@QueryParam(Constants.CLIENT_DATA) String clientData,
|
@QueryParam(Constants.CLIENT_DATA) String clientData,
|
||||||
@QueryParam(Constants.TAB_ID) String tabId) {
|
@QueryParam(Constants.TAB_ID) String tabId) {
|
||||||
|
|
||||||
return registerRequest(authSessionId, code, execution, clientId, tabId,clientData);
|
return registerRequest(authSessionId, code, execution, clientId, tabId,clientData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -764,6 +768,7 @@ public class LoginActionsService {
|
||||||
@POST
|
@POST
|
||||||
public Response processRegister(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead
|
public Response processRegister(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead
|
||||||
@QueryParam(SESSION_CODE) String code,
|
@QueryParam(SESSION_CODE) String code,
|
||||||
|
@QueryParam(Constants.ORG_TOKEN) String orgToken,
|
||||||
@QueryParam(Constants.EXECUTION) String execution,
|
@QueryParam(Constants.EXECUTION) String execution,
|
||||||
@QueryParam(Constants.CLIENT_ID) String clientId,
|
@QueryParam(Constants.CLIENT_ID) String clientId,
|
||||||
@QueryParam(Constants.CLIENT_DATA) String clientData,
|
@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) {
|
private Response registerRequest(String authSessionId, String code, String execution, String clientId, String tabId, String clientData) {
|
||||||
event.event(EventType.REGISTER);
|
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()) {
|
if (!realm.isRegistrationAllowed()) {
|
||||||
event.error(Errors.REGISTRATION_DISABLED);
|
event.error(Errors.REGISTRATION_DISABLED);
|
||||||
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.REGISTRATION_NOT_ALLOWED);
|
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.REGISTRATION_NOT_ALLOWED);
|
||||||
|
|
|
@ -3,3 +3,4 @@ org.keycloak.authentication.actiontoken.execactions.ExecuteActionsActionTokenHan
|
||||||
org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionTokenHandler
|
org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionTokenHandler
|
||||||
org.keycloak.authentication.actiontoken.idpverifyemail.IdpVerifyAccountLinkActionTokenHandler
|
org.keycloak.authentication.actiontoken.idpverifyemail.IdpVerifyAccountLinkActionTokenHandler
|
||||||
org.keycloak.authentication.actiontoken.updateemail.UpdateEmailActionTokenHandler
|
org.keycloak.authentication.actiontoken.updateemail.UpdateEmailActionTokenHandler
|
||||||
|
org.keycloak.authentication.actiontoken.inviteorg.InviteOrgActionTokenHandler
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<#import "template.ftl" as layout>
|
||||||
|
<@layout.emailLayout>
|
||||||
|
${kcSanitize(msg("orgInviteBodyHtml", link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration)))?no_esc}
|
||||||
|
</@layout.emailLayout>
|
|
@ -1,6 +1,7 @@
|
||||||
emailVerificationSubject=Verify email
|
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.
|
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>
|
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
|
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.
|
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>
|
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>
|
||||||
|
|
Loading…
Reference in a new issue