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 registrationFlow;
|
||||
protected AuthenticationFlowModel orgRegistrationFlow;
|
||||
protected AuthenticationFlowModel directGrantFlow;
|
||||
protected AuthenticationFlowModel resetCredentialsFlow;
|
||||
protected AuthenticationFlowModel clientAuthenticationFlow;
|
||||
|
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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 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))) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
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>
|
||||
|
|
Loading…
Reference in a new issue