diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/events/AuthenticationSessionAuthNoteUpdateEvent.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/events/AuthenticationSessionAuthNoteUpdateEvent.java new file mode 100644 index 0000000000..d7bdcdfcce --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/events/AuthenticationSessionAuthNoteUpdateEvent.java @@ -0,0 +1,53 @@ +/* + * 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.models.cache.infinispan.events; + +import org.keycloak.cluster.ClusterEvent; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * + * @author hmlnarik + */ +public class AuthenticationSessionAuthNoteUpdateEvent implements ClusterEvent { + + private String authSessionId; + + private Map authNotesFragment; + + public static AuthenticationSessionAuthNoteUpdateEvent create(String authSessionId, Map authNotesFragment) { + AuthenticationSessionAuthNoteUpdateEvent event = new AuthenticationSessionAuthNoteUpdateEvent(); + event.authSessionId = authSessionId; + event.authNotesFragment = new LinkedHashMap<>(authNotesFragment); + return event; + } + + public String getAuthSessionId() { + return authSessionId; + } + + public Map getAuthNotesFragment() { + return authNotesFragment; + } + + @Override + public String toString() { + return String.format("AuthenticationSessionAuthNoteUpdateEvent [ authSessionId=%s, authNotesFragment=%s ]", authSessionId, authNotesFragment); + } + +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticationSessionAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticationSessionAdapter.java index 202fe5c99a..05a762b54f 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticationSessionAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/AuthenticationSessionAdapter.java @@ -18,7 +18,7 @@ package org.keycloak.models.sessions.infinispan; import java.util.Collections; -import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -144,21 +144,27 @@ public class AuthenticationSessionAdapter implements AuthenticationSessionModel @Override public String getClientNote(String name) { - return entity.getClientNotes() != null ? entity.getClientNotes().get(name) : null; + return (entity.getClientNotes() != null && name != null) ? entity.getClientNotes().get(name) : null; } @Override public void setClientNote(String name, String value) { if (entity.getClientNotes() == null) { - entity.setClientNotes(new HashMap<>()); + entity.setClientNotes(new ConcurrentHashMap<>()); + } + if (name != null) { + if (value == null) { + entity.getClientNotes().remove(name); + } else { + entity.getClientNotes().put(name, value); + } } - entity.getClientNotes().put(name, value); update(); } @Override public void removeClientNote(String name) { - if (entity.getClientNotes() != null) { + if (entity.getClientNotes() != null && name != null) { entity.getClientNotes().remove(name); } update(); @@ -167,34 +173,40 @@ public class AuthenticationSessionAdapter implements AuthenticationSessionModel @Override public Map getClientNotes() { if (entity.getClientNotes() == null || entity.getClientNotes().isEmpty()) return Collections.emptyMap(); - Map copy = new HashMap<>(); + Map copy = new ConcurrentHashMap<>(); copy.putAll(entity.getClientNotes()); return copy; } @Override public void clearClientNotes() { - entity.setClientNotes(new HashMap<>()); + entity.setClientNotes(new ConcurrentHashMap<>()); update(); } @Override public String getAuthNote(String name) { - return entity.getAuthNotes() != null ? entity.getAuthNotes().get(name) : null; + return (entity.getAuthNotes() != null && name != null) ? entity.getAuthNotes().get(name) : null; } @Override public void setAuthNote(String name, String value) { if (entity.getAuthNotes() == null) { - entity.setAuthNotes(new HashMap()); + entity.setAuthNotes(new ConcurrentHashMap<>()); + } + if (name != null) { + if (value == null) { + entity.getAuthNotes().remove(name); + } else { + entity.getAuthNotes().put(name, value); + } } - entity.getAuthNotes().put(name, value); update(); } @Override public void removeAuthNote(String name) { - if (entity.getAuthNotes() != null) { + if (entity.getAuthNotes() != null && name != null) { entity.getAuthNotes().remove(name); } update(); @@ -202,16 +214,22 @@ public class AuthenticationSessionAdapter implements AuthenticationSessionModel @Override public void clearAuthNotes() { - entity.setAuthNotes(new HashMap<>()); + entity.setAuthNotes(new ConcurrentHashMap<>()); update(); } @Override public void setUserSessionNote(String name, String value) { if (entity.getUserSessionNotes() == null) { - entity.setUserSessionNotes(new HashMap()); + entity.setUserSessionNotes(new ConcurrentHashMap<>()); + } + if (name != null) { + if (value == null) { + entity.getUserSessionNotes().remove(name); + } else { + entity.getUserSessionNotes().put(name, value); + } } - entity.getUserSessionNotes().put(name, value); update(); } @@ -221,14 +239,14 @@ public class AuthenticationSessionAdapter implements AuthenticationSessionModel if (entity.getUserSessionNotes() == null) { return Collections.EMPTY_MAP; } - HashMap copy = new HashMap<>(); + ConcurrentHashMap copy = new ConcurrentHashMap<>(); copy.putAll(entity.getUserSessionNotes()); return copy; } @Override public void clearUserSessionNotes() { - entity.setUserSessionNotes(new HashMap()); + entity.setUserSessionNotes(new ConcurrentHashMap<>()); update(); } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java index a802544cf7..5991f98944 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProvider.java @@ -17,6 +17,7 @@ package org.keycloak.models.sessions.infinispan; +import org.keycloak.cluster.ClusterProvider; import java.util.Iterator; import java.util.Map; @@ -27,6 +28,7 @@ import org.keycloak.common.util.Time; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.cache.infinispan.events.AuthenticationSessionAuthNoteUpdateEvent; import org.keycloak.models.sessions.infinispan.entities.AuthenticationSessionEntity; import org.keycloak.models.sessions.infinispan.stream.AuthenticationSessionPredicate; import org.keycloak.models.utils.KeycloakModelUtils; @@ -138,6 +140,20 @@ public class InfinispanAuthenticationSessionProvider implements AuthenticationSe } } + @Override + public void updateNonlocalSessionAuthNotes(String authSessionId, Map authNotesFragment) { + if (authSessionId == null) { + return; + } + + ClusterProvider cluster = session.getProvider(ClusterProvider.class); + cluster.notify( + InfinispanAuthenticationSessionProviderFactory.AUTHENTICATION_SESSION_EVENTS, + AuthenticationSessionAuthNoteUpdateEvent.create(authSessionId, authNotesFragment), + true + ); + } + @Override public void close() { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java index e6da14ef0c..aa6ede3e9a 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java @@ -19,18 +19,28 @@ package org.keycloak.models.sessions.infinispan; import org.infinispan.Cache; import org.keycloak.Config; +import org.keycloak.cluster.ClusterEvent; +import org.keycloak.cluster.ClusterProvider; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.cache.infinispan.events.AuthenticationSessionAuthNoteUpdateEvent; import org.keycloak.models.sessions.infinispan.entities.AuthenticationSessionEntity; import org.keycloak.sessions.AuthenticationSessionProvider; import org.keycloak.sessions.AuthenticationSessionProviderFactory; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.ConcurrentHashMap; +import org.jboss.logging.Logger; /** * @author Marek Posolda */ public class InfinispanAuthenticationSessionProviderFactory implements AuthenticationSessionProviderFactory { + private static final Logger log = Logger.getLogger(InfinispanAuthenticationSessionProviderFactory.class); + + private volatile Cache authSessionsCache; @Override public void init(Config.Scope config) { @@ -39,12 +49,53 @@ public class InfinispanAuthenticationSessionProviderFactory implements Authentic @Override public AuthenticationSessionProvider create(KeycloakSession session) { - InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class); - Cache authSessionsCache = connections.getCache(InfinispanConnectionProvider.AUTHENTICATION_SESSIONS_CACHE_NAME); - + lazyInit(session); return new InfinispanAuthenticationSessionProvider(session, authSessionsCache); } + private void updateAuthNotes(ClusterEvent clEvent) { + if (! (clEvent instanceof AuthenticationSessionAuthNoteUpdateEvent)) { + return; + } + + AuthenticationSessionAuthNoteUpdateEvent event = (AuthenticationSessionAuthNoteUpdateEvent) clEvent; + AuthenticationSessionEntity authSession = this.authSessionsCache.get(event.getAuthSessionId()); + updateAuthSession(authSession, event.getAuthNotesFragment()); + } + + private static void updateAuthSession(AuthenticationSessionEntity authSession, Map authNotesFragment) { + if (authSession != null) { + if (authSession.getAuthNotes() == null) { + authSession.setAuthNotes(new ConcurrentHashMap<>()); + } + + for (Entry me : authNotesFragment.entrySet()) { + String value = me.getValue(); + if (value == null) { + authSession.getAuthNotes().remove(me.getKey()); + } else { + authSession.getAuthNotes().put(me.getKey(), value); + } + } + } + } + + private void lazyInit(KeycloakSession session) { + if (authSessionsCache == null) { + synchronized (this) { + if (authSessionsCache == null) { + InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class); + authSessionsCache = connections.getCache(InfinispanConnectionProvider.AUTHENTICATION_SESSIONS_CACHE_NAME); + + ClusterProvider cluster = session.getProvider(ClusterProvider.class); + cluster.registerListener(AUTHENTICATION_SESSION_EVENTS, this::updateAuthNotes); + + log.debug("Registered cluster listeners"); + } + } + } + } + @Override public void postInit(KeycloakSessionFactory factory) { } diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java index 583b0d4061..920646fa58 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java @@ -92,6 +92,8 @@ public enum EventType { USER_INFO_REQUEST(false), USER_INFO_REQUEST_ERROR(false), + IDENTITY_PROVIDER_LINK_ACCOUNT(true), + IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR(true), IDENTITY_PROVIDER_LOGIN(false), IDENTITY_PROVIDER_LOGIN_ERROR(false), IDENTITY_PROVIDER_FIRST_LOGIN(true), @@ -129,6 +131,10 @@ public enum EventType { this.saveByDefault = saveByDefault; } + /** + * Determines whether this event is stored when the admin has not set a specific set of event types to save. + * @return + */ public boolean isSaveByDefault() { return saveByDefault; } diff --git a/server-spi-private/src/main/java/org/keycloak/sessions/AuthenticationSessionProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/sessions/AuthenticationSessionProviderFactory.java index b182458b5e..c8758cafdd 100644 --- a/server-spi-private/src/main/java/org/keycloak/sessions/AuthenticationSessionProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/sessions/AuthenticationSessionProviderFactory.java @@ -23,4 +23,6 @@ import org.keycloak.provider.ProviderFactory; * @author Marek Posolda */ public interface AuthenticationSessionProviderFactory extends ProviderFactory { + // TODO:hmlnarik: move this constant out of an interface into a more appropriate class + public static final String AUTHENTICATION_SESSION_EVENTS = "AUTHENTICATION_SESSION_EVENTS"; } diff --git a/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java b/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java index f284d934ff..99806d45b2 100644 --- a/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java +++ b/server-spi/src/main/java/org/keycloak/sessions/AuthenticationSessionProvider.java @@ -20,6 +20,7 @@ package org.keycloak.sessions; import org.keycloak.models.ClientModel; import org.keycloak.models.RealmModel; import org.keycloak.provider.Provider; +import java.util.Map; /** * @author Marek Posolda @@ -39,5 +40,14 @@ public interface AuthenticationSessionProvider extends Provider { void onRealmRemoved(RealmModel realm); void onClientRemoved(RealmModel realm, ClientModel client); + /** + * Requests update of authNotes of an authentication session that is not owned + * by this instance but might exist somewhere in the cluster. + * + * @param authSessionId + * @param authNotesFragment Map with authNote values. Auth note is removed if the corresponding value in the map is {@code null}. + */ + void updateNonlocalSessionAuthNotes(String authSessionId, Map authNotesFragment); + } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenContext.java b/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenContext.java index 26598c1cee..ce45deb1b8 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenContext.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenContext.java @@ -17,6 +17,7 @@ package org.keycloak.authentication.actiontoken; import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.common.ClientConnection; import org.keycloak.events.EventBuilder; import org.keycloak.models.*; @@ -25,6 +26,8 @@ import org.keycloak.representations.JsonWebToken; import org.keycloak.services.Urls; import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.sessions.AuthenticationSessionModel; +import java.util.function.Function; +import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilderException; import javax.ws.rs.core.UriInfo; import org.jboss.resteasy.spi.HttpRequest; @@ -35,6 +38,16 @@ import org.jboss.resteasy.spi.HttpRequest; */ public class ActionTokenContext { + @FunctionalInterface + public interface ProcessAuthenticateFlow { + Response processFlow(boolean action, String execution, AuthenticationSessionModel authSession, String flowPath, AuthenticationFlowModel flow, String errorMessage, AuthenticationProcessor processor); + }; + + @FunctionalInterface + public interface ProcessBrokerFlow { + Response brokerLoginFlow(String code, String execution, String flowPath); + }; + private final KeycloakSession session; private final RealmModel realm; private final UriInfo uriInfo; @@ -45,8 +58,13 @@ public class ActionTokenContext { private AuthenticationSessionModel authenticationSession; private boolean authenticationSessionFresh; private String executionId; + private final ProcessAuthenticateFlow processAuthenticateFlow; + private final ProcessBrokerFlow processBrokerFlow; - public ActionTokenContext(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection clientConnection, HttpRequest request, EventBuilder event, ActionTokenHandler handler) { + public ActionTokenContext(KeycloakSession session, RealmModel realm, UriInfo uriInfo, + ClientConnection clientConnection, HttpRequest request, + EventBuilder event, ActionTokenHandler handler, String executionId, + ProcessAuthenticateFlow processFlow, ProcessBrokerFlow processBrokerFlow) { this.session = session; this.realm = realm; this.uriInfo = uriInfo; @@ -54,6 +72,9 @@ public class ActionTokenContext { this.request = request; this.event = event; this.handler = handler; + this.executionId = executionId; + this.processAuthenticateFlow = processFlow; + this.processBrokerFlow = processBrokerFlow; } public EventBuilder getEvent() { @@ -131,4 +152,12 @@ public class ActionTokenContext { public void setExecutionId(String executionId) { this.executionId = executionId; } + + public Response processFlow(boolean action, String flowPath, AuthenticationFlowModel flow, String errorMessage, AuthenticationProcessor processor) { + return processAuthenticateFlow.processFlow(action, getExecutionId(), getAuthenticationSession(), flowPath, flow, errorMessage, processor); + } + + public Response brokerFlow(String code, String flowPath) { + return processBrokerFlow.brokerLoginFlow(code, getExecutionId(), flowPath); + } } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenHandler.java index e573df4c74..4368a747ff 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenHandler.java @@ -35,21 +35,6 @@ import javax.ws.rs.core.Response; */ public interface ActionTokenHandler extends Provider { - @FunctionalInterface - public interface ProcessFlow { - Response processFlow(boolean action, String execution, AuthenticationSessionModel authSession, String flowPath, AuthenticationFlowModel flow, String errorMessage, AuthenticationProcessor processor); - }; - - /** - * Returns an array of verifiers that are tested prior to handling the token. All verifiers have to pass successfully - * for token to be handled. The returned array must not be {@code null}. - * @param tokenContext - * @return Verifiers or an empty array - */ - default Predicate[] getVerifiers(ActionTokenContext tokenContext) { - return new Predicate[] {}; - } - /** * Performs the action as per the token details. This method is only called if all verifiers * returned in {@link #handleToken} succeed. @@ -59,7 +44,7 @@ public interface ActionTokenHandler extends Provider { * @return * @throws VerificationException */ - Response handleToken(T token, ActionTokenContext tokenContext, ProcessFlow processFlow); + Response handleToken(T token, ActionTokenContext tokenContext); /** * Returns the Java token class for use with deserialization. @@ -67,6 +52,16 @@ public interface ActionTokenHandler extends Provider { */ Class getTokenClass(); + /** + * Returns an array of verifiers that are tested prior to handling the token. All verifiers have to pass successfully + * for token to be handled. The returned array must not be {@code null}. + * @param tokenContext + * @return Verifiers or an empty array. The returned array must not be {@code null}. + */ + default Predicate[] getVerifiers(ActionTokenContext tokenContext) { + return new Predicate[] {}; + } + /** * Returns an authentication session ID requested from within the given token * @param token Token. Can be {@code null} @@ -95,17 +90,8 @@ public interface ActionTokenHandler extends Provider { String getDefaultErrorMessage(); /** - * Returns a response that restarts a flow that this action token initiates, or {@code null} if - * no special handling is requested. - * - * @return - */ - default Response handleRestartRequest(T token, ActionTokenContext tokenContext, ProcessFlow processFlow) { - return null; - } - - /** - * Creates a fresh authentication session according to the information from the token. + * Creates a fresh authentication session according to the information from the token. The default + * implementation creates a new authentication session that requests termination after required actions. * @param token * @param tokenContext * @return diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionTokenKey.java b/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionTokenKey.java index 117c4659f9..a5440a9b87 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionTokenKey.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionTokenKey.java @@ -25,7 +25,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; */ public class DefaultActionTokenKey extends JsonWebToken { - // The authenticationSession note with ID of the user authenticated via the action token + /** The authenticationSession note with ID of the user authenticated via the action token */ public static final String ACTION_TOKEN_USER_ID = "ACTION_TOKEN_USER"; public DefaultActionTokenKey(String userId, String actionId) { diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionTokenHandler.java index 691fff527d..010c5174a9 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionTokenHandler.java @@ -25,9 +25,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; -import org.keycloak.services.resources.LoginActionsServiceChecks; import org.keycloak.sessions.AuthenticationSessionModel; -import org.keycloak.sessions.CommonClientSessionModel.Action; import javax.ws.rs.core.Response; /** @@ -61,7 +59,7 @@ public class ExecuteActionsActionTokenHandler extends AbstractActionTokenHander< } @Override - public Response handleToken(ExecuteActionsActionToken token, ActionTokenContext tokenContext, ProcessFlow processFlow) { + public Response handleToken(ExecuteActionsActionToken token, ActionTokenContext tokenContext) { AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); String redirectUri = RedirectUtils.verifyRedirectUri(tokenContext.getUriInfo(), token.getRedirectUri(), diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionToken.java b/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionToken.java new file mode 100644 index 0000000000..ea705edd66 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionToken.java @@ -0,0 +1,69 @@ +/* + * 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.idpverifyemail; + +import org.keycloak.authentication.actiontoken.verifyemail.*; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.UUID; +import org.keycloak.authentication.actiontoken.DefaultActionToken; + +/** + * Representation of a token that represents a time-limited verify e-mail action. + * + * @author hmlnarik + */ +public class IdpVerifyAccountLinkActionToken extends DefaultActionToken { + + public static final String TOKEN_TYPE = "idp-verify-account-via-email"; + + private static final String JSON_FIELD_IDENTITY_PROVIDER_USERNAME = "idpu"; + private static final String JSON_FIELD_IDENTITY_PROVIDER_ALIAS = "idpa"; + + @JsonProperty(value = JSON_FIELD_IDENTITY_PROVIDER_USERNAME) + private String identityProviderUsername; + + @JsonProperty(value = JSON_FIELD_IDENTITY_PROVIDER_ALIAS) + private String identityProviderAlias; + + public IdpVerifyAccountLinkActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, String authenticationSessionId, + String identityProviderUsername, String identityProviderAlias) { + super(userId, TOKEN_TYPE, absoluteExpirationInSecs, actionVerificationNonce); + setAuthenticationSessionId(authenticationSessionId); + this.identityProviderUsername = identityProviderUsername; + this.identityProviderAlias = identityProviderAlias; + } + + private IdpVerifyAccountLinkActionToken() { + super(null, TOKEN_TYPE, -1, null); + } + + public String getIdentityProviderUsername() { + return identityProviderUsername; + } + + public void setIdentityProviderUsername(String identityProviderUsername) { + this.identityProviderUsername = identityProviderUsername; + } + + public String getIdentityProviderAlias() { + return identityProviderAlias; + } + + public void setIdentityProviderAlias(String identityProviderAlias) { + this.identityProviderAlias = identityProviderAlias; + } +} diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionTokenHandler.java new file mode 100644 index 0000000000..3aec118260 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionTokenHandler.java @@ -0,0 +1,97 @@ +/* + * 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.idpverifyemail; + +import org.keycloak.authentication.actiontoken.AbstractActionTokenHander; +import org.keycloak.TokenVerifier.Predicate; +import org.keycloak.authentication.AuthenticationProcessor; +import org.keycloak.authentication.actiontoken.*; +import org.keycloak.authentication.authenticators.broker.IdpEmailVerificationAuthenticator; +import org.keycloak.events.*; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.services.messages.Messages; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.AuthenticationSessionProvider; +import java.util.Collections; +import javax.ws.rs.core.Response; + +/** + * Action token handler for verification of e-mail address. + * @author hmlnarik + */ +public class IdpVerifyAccountLinkActionTokenHandler extends AbstractActionTokenHander { + + public IdpVerifyAccountLinkActionTokenHandler() { + super( + IdpVerifyAccountLinkActionToken.TOKEN_TYPE, + IdpVerifyAccountLinkActionToken.class, + Messages.STALE_CODE, + EventType.IDENTITY_PROVIDER_LINK_ACCOUNT, + Errors.INVALID_TOKEN + ); + } + + @Override + public Predicate[] getVerifiers(ActionTokenContext tokenContext) { + return TokenUtils.predicates( + ); + } + + @Override + public Response handleToken(IdpVerifyAccountLinkActionToken token, ActionTokenContext tokenContext) { + UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser(); + EventBuilder event = tokenContext.getEvent(); + + event.event(EventType.IDENTITY_PROVIDER_LINK_ACCOUNT) + .detail(Details.EMAIL, user.getEmail()) + .detail(Details.IDENTITY_PROVIDER, token.getIdentityProviderAlias()) + .detail(Details.IDENTITY_PROVIDER_USERNAME, token.getIdentityProviderUsername()) + .success(); + + // verify user email as we know it is valid as this entry point would never have gotten here. + user.setEmailVerified(true); + + AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); + if (tokenContext.isAuthenticationSessionFresh()) { + AuthenticationSessionManager asm = new AuthenticationSessionManager(tokenContext.getSession()); + asm.removeAuthenticationSession(tokenContext.getRealm(), authSession, true); + + AuthenticationSessionProvider authSessProvider = tokenContext.getSession().authenticationSessions(); + authSession = authSessProvider.getAuthenticationSession(tokenContext.getRealm(), token.getAuthenticationSessionId()); + + if (authSession != null) { + authSession.setAuthNote(IdpEmailVerificationAuthenticator.VERIFY_ACCOUNT_IDP_USERNAME, token.getIdentityProviderUsername()); + } else { + authSessProvider.updateNonlocalSessionAuthNotes( + token.getAuthenticationSessionId(), + Collections.singletonMap(IdpEmailVerificationAuthenticator.VERIFY_ACCOUNT_IDP_USERNAME, token.getIdentityProviderUsername()) + ); + } + + return tokenContext.getSession().getProvider(LoginFormsProvider.class) + .setSuccess(Messages.IDENTITY_PROVIDER_LINK_SUCCESS, token.getIdentityProviderAlias(), token.getIdentityProviderUsername()) + .createInfoPage(); + } + + authSession.setAuthNote(IdpEmailVerificationAuthenticator.VERIFY_ACCOUNT_IDP_USERNAME, token.getIdentityProviderUsername()); + + return tokenContext.brokerFlow(null, authSession.getAuthNote(AuthenticationProcessor.CURRENT_FLOW_PATH)); + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionTokenHandler.java index c6f834b347..34174311e7 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionTokenHandler.java @@ -62,13 +62,11 @@ public class ResetCredentialsActionTokenHandler extends AbstractActionTokenHande } @Override - public Response handleToken(ResetCredentialsActionToken token, ActionTokenContext tokenContext, ProcessFlow processFlow) { + public Response handleToken(ResetCredentialsActionToken token, ActionTokenContext tokenContext) { AuthenticationProcessor authProcessor = new ResetCredsAuthenticationProcessor(); - return processFlow.processFlow( + return tokenContext.processFlow( false, - tokenContext.getExecutionId(), - tokenContext.getAuthenticationSession(), RESET_CREDENTIALS_PATH, tokenContext.getRealm().getResetCredentialsFlow(), null, @@ -76,17 +74,6 @@ public class ResetCredentialsActionTokenHandler extends AbstractActionTokenHande ); } - @Override - public Response handleRestartRequest(ResetCredentialsActionToken token, ActionTokenContext tokenContext, ProcessFlow processFlow) { - // In the case restart is requested, the handling is exactly the same as if a token had been - // handled correctly but with a fresh authentication session - AuthenticationSessionManager asm = new AuthenticationSessionManager(tokenContext.getSession()); - asm.removeAuthenticationSession(tokenContext.getRealm(), tokenContext.getAuthenticationSession(), false); - - tokenContext.setAuthenticationSession(tokenContext.createAuthenticationSessionForClient(null), true); - return handleToken(token, tokenContext, processFlow); - } - public static class ResetCredsAuthenticationProcessor extends AuthenticationProcessor { @Override diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionTokenHandler.java index 1d324c2550..abe2127098 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionTokenHandler.java @@ -57,7 +57,7 @@ public class VerifyEmailActionTokenHandler extends AbstractActionTokenHander tokenContext, ProcessFlow processFlow) { + public Response handleToken(VerifyEmailActionToken token, ActionTokenContext tokenContext) { UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser(); EventBuilder event = tokenContext.getEvent(); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java index b70f69b78d..d8b9b30689 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java @@ -20,9 +20,10 @@ package org.keycloak.authentication.authenticators.broker; import org.jboss.logging.Logger; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.actiontoken.idpverifyemail.IdpVerifyAccountLinkActionToken; import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; -import org.keycloak.authentication.requiredactions.VerifyEmail; import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.common.util.Time; import org.keycloak.email.EmailException; import org.keycloak.email.EmailTemplateProvider; import org.keycloak.events.Details; @@ -30,20 +31,21 @@ 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.ClientSessionModel; -import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.services.ServicesLogger; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.messages.Messages; -import org.keycloak.services.resources.LoginActionsService; import org.keycloak.sessions.AuthenticationSessionModel; -import javax.ws.rs.core.MultivaluedMap; +import java.net.URI; +import java.util.Objects; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import java.util.concurrent.TimeUnit; +import javax.ws.rs.core.*; /** * @author Marek Posolda @@ -52,42 +54,85 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator private static Logger logger = Logger.getLogger(IdpEmailVerificationAuthenticator.class); + public static final String VERIFY_ACCOUNT_IDP_USERNAME = "VERIFY_ACCOUNT_IDP_USERNAME"; + @Override protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) { KeycloakSession session = context.getSession(); RealmModel realm = context.getRealm(); AuthenticationSessionModel authSession = context.getAuthenticationSession(); - if (realm.getSmtpConfig().size() == 0) { + if (realm.getSmtpConfig().isEmpty()) { ServicesLogger.LOGGER.smtpNotConfigured(); context.attempted(); return; } -/* - VerifyEmail.setupKey(clientSession); - UserModel existingUser = getExistingUser(session, realm, clientSession); + if (Objects.equals(authSession.getAuthNote(VERIFY_ACCOUNT_IDP_USERNAME), brokerContext.getUsername())) { + UserModel existingUser = getExistingUser(session, realm, authSession); - String link = UriBuilder.fromUri(context.getActionUrl()) - .queryParam(Constants.KEY, clientSession.getNote(Constants.VERIFY_EMAIL_KEY)) - .build().toString(); + logger.debugf("User '%s' confirmed that wants to link with identity provider '%s' . Identity provider username is '%s' ", existingUser.getUsername(), + brokerContext.getIdpConfig().getAlias(), brokerContext.getUsername()); + + context.setUser(existingUser); + context.success(); + return; + } + + UserModel existingUser = getExistingUser(session, realm, authSession); + + sendVerifyEmail(session, context, existingUser, brokerContext); + } + + @Override + protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) { + logger.debugf("Re-sending email requested for user, details follow"); + + // This will allow user to re-send email again + context.getAuthenticationSession().removeAuthNote(VERIFY_ACCOUNT_IDP_USERNAME); + authenticateImpl(context, serializedCtx, brokerContext); + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return false; + } + + private void sendVerifyEmail(KeycloakSession session, AuthenticationFlowContext context, UserModel existingUser, BrokeredIdentityContext brokerContext) throws UriBuilderException, IllegalArgumentException { + RealmModel realm = session.getContext().getRealm(); + UriInfo uriInfo = session.getContext().getUri(); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + + int validityInSecs = realm.getAccessCodeLifespanUserAction(); + int absoluteExpirationInSecs = Time.currentTime() + validityInSecs; EventBuilder event = context.getEvent().clone().event(EventType.SEND_IDENTITY_PROVIDER_LINK) .user(existingUser) .detail(Details.USERNAME, existingUser.getUsername()) .detail(Details.EMAIL, existingUser.getEmail()) - .detail(Details.CODE_ID, clientSession.getId()) + .detail(Details.CODE_ID, authSession.getId()) .removeDetail(Details.AUTH_METHOD) .removeDetail(Details.AUTH_TYPE); - long expiration = TimeUnit.SECONDS.toMinutes(context.getRealm().getAccessCodeLifespanUserAction()); - try { + IdpVerifyAccountLinkActionToken token = new IdpVerifyAccountLinkActionToken( + existingUser.getId(), absoluteExpirationInSecs, null, authSession.getId(), + brokerContext.getUsername(), brokerContext.getIdpConfig().getAlias() + ); + UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo)); + String link = builder.queryParam("execution", context.getExecution().getId()).build(realm.getName()).toString(); + long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs); + try { context.getSession().getProvider(EmailTemplateProvider.class) .setRealm(realm) .setUser(existingUser) .setAttribute(EmailTemplateProvider.IDENTITY_PROVIDER_BROKER_CONTEXT, brokerContext) - .sendConfirmIdentityBrokerLink(link, expiration); + .sendConfirmIdentityBrokerLink(link, expirationInMinutes); event.success(); } catch (EmailException e) { @@ -101,62 +146,14 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator return; } + String accessCode = context.generateAccessCode(); + URI action = context.getActionUrl(accessCode); + Response challenge = context.form() .setStatus(Response.Status.OK) .setAttribute(LoginFormsProvider.IDENTITY_PROVIDER_BROKER_CONTEXT, brokerContext) + .setActionUri(action) .createIdpLinkEmailPage(); - context.forceChallenge(challenge);*/ - } - - @Override - protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) { - /*MultivaluedMap queryParams = context.getSession().getContext().getUri().getQueryParameters(); - String key = queryParams.getFirst(Constants.KEY); - ClientSessionModel clientSession = context.getClientSession(); - RealmModel realm = context.getRealm(); - KeycloakSession session = context.getSession(); - - if (key != null) { - String keyFromSession = clientSession.getNote(Constants.VERIFY_EMAIL_KEY); - clientSession.removeNote(Constants.VERIFY_EMAIL_KEY); - if (key.equals(keyFromSession)) { - UserModel existingUser = getExistingUser(session, realm, clientSession); - - logger.debugf("User '%s' confirmed that wants to link with identity provider '%s' . Identity provider username is '%s' ", existingUser.getUsername(), - brokerContext.getIdpConfig().getAlias(), brokerContext.getUsername()); - - String actionCookieValue = LoginActionsService.getActionCookie(session.getContext().getRequestHeaders(), realm, session.getContext().getUri(), context.getConnection()); - if (actionCookieValue == null || !actionCookieValue.equals(clientSession.getId())) { - clientSession.setNote(IS_DIFFERENT_BROWSER, "true"); - } - - // User successfully confirmed linking by email verification. His email was defacto verified - existingUser.setEmailVerified(true); - - context.setUser(existingUser); - context.success(); - } else { - ServicesLogger.LOGGER.keyParamDoesNotMatch(); - Response challengeResponse = context.form() - .setError(Messages.INVALID_ACCESS_CODE) - .createErrorPage(); - context.failureChallenge(AuthenticationFlowError.IDENTITY_PROVIDER_ERROR, challengeResponse); - } - } else { - Response challengeResponse = context.form() - .setError(Messages.MISSING_PARAMETER, Constants.KEY) - .createErrorPage(); - context.failureChallenge(AuthenticationFlowError.IDENTITY_PROVIDER_ERROR, challengeResponse); - }*/ - } - - @Override - public boolean requiresUser() { - return false; - } - - @Override - public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { - return false; + context.forceChallenge(challenge); } } diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java index f3ea22fd8d..ad84f9d3b8 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java @@ -31,7 +31,6 @@ import org.keycloak.events.EventType; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.models.*; import org.keycloak.models.UserModel.RequiredAction; -import org.keycloak.models.utils.HmacOTP; import org.keycloak.services.Urls; import org.keycloak.services.validation.Validation; @@ -87,7 +86,7 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor @Override public void processAction(RequiredActionContext context) { - logger.infof("Re-sending email requested for user: %s", context.getUser().getUsername()); + logger.debugf("Re-sending email requested for user: %s", context.getUser().getUsername()); // This will allow user to re-send email again context.getAuthenticationSession().removeAuthNote(Constants.VERIFY_EMAIL_KEY); @@ -152,9 +151,4 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor return forms.createResponse(UserModel.RequiredAction.VERIFY_EMAIL); } - - public static void setupKey(AuthenticationSessionModel session) { - String secret = HmacOTP.generateSecret(10); - session.setAuthNote(Constants.VERIFY_EMAIL_KEY, secret); - } } diff --git a/services/src/main/java/org/keycloak/services/Urls.java b/services/src/main/java/org/keycloak/services/Urls.java index edeac7692a..e92aa05a74 100755 --- a/services/src/main/java/org/keycloak/services/Urls.java +++ b/services/src/main/java/org/keycloak/services/Urls.java @@ -178,10 +178,6 @@ public class Urls { return loginResetCredentialsBuilder(baseUri).build(realmName); } - public static UriBuilder executeActionsBuilder(URI baseUri) { - return loginActionsBase(baseUri).path(LoginActionsService.class, "executeActions"); - } - public static UriBuilder actionTokenBuilder(URI baseUri, String tokenString) { return loginActionsBase(baseUri).path(LoginActionsService.class, "executeActionToken") .queryParam("key", tokenString); diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 5dcb621b66..758b7a1c02 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -67,7 +67,6 @@ import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.messages.Messages; -import org.keycloak.services.resources.LoginActionsServiceChecks.RestartFlowException; import org.keycloak.services.util.CacheControlUtil; import org.keycloak.services.util.AuthenticationFlowURLHelper; import org.keycloak.services.util.BrowserHistoryHelper; @@ -448,7 +447,6 @@ public class LoginActionsService { .secretKey(session.keys().getActiveHmacKey(realm).getSecretKey()) .verify(); - // TODO:hmlnarik Optimize token = TokenVerifier.create(tokenString, handler.getTokenClass()).getToken(); } catch (TokenNotActiveException ex) { if (authSession != null) { @@ -469,40 +467,32 @@ public class LoginActionsService { } // Now proceed with the verification and handle the token - tokenContext = new ActionTokenContext(session, realm, uriInfo, clientConnection, request, event, handler); + tokenContext = new ActionTokenContext(session, realm, uriInfo, clientConnection, request, event, handler, execution, this::processFlow, this::brokerLoginFlow); try { - tokenContext.setExecutionId(execution); - String tokenAuthSessionId = handler.getAuthenticationSessionIdFromToken(token); + + if (tokenAuthSessionId != null) { + // This can happen if the token contains ID but user opens the link in a new browser + LoginActionsServiceChecks.checkNotLoggedInYet(tokenContext, tokenAuthSessionId); + } + if (authSession == null) { - if (tokenAuthSessionId != null) { - // This can happen if the token contains ID but user opens the link in a new browser - LoginActionsServiceChecks.checkNotLoggedInYet(tokenContext, tokenAuthSessionId); - } + authSession = handler.startFreshAuthenticationSession(token, tokenContext); + tokenContext.setAuthenticationSession(authSession, true); + } else if (tokenAuthSessionId == null || + ! LoginActionsServiceChecks.doesAuthenticationSessionFromCookieMatchOneFromToken(tokenContext, tokenAuthSessionId)) { + // There exists an authentication session but no auth session ID was received in the action token + logger.debugf("Authentication session in progress but no authentication session ID was found in action token %s, restarting.", token.getId()); + new AuthenticationSessionManager(session).removeAuthenticationSession(realm, authSession, false); authSession = handler.startFreshAuthenticationSession(token, tokenContext); tokenContext.setAuthenticationSession(authSession, true); - - initLoginEvent(authSession); - event.event(handler.eventType()); - } else { - initLoginEvent(authSession); - event.event(handler.eventType()); - - if (tokenAuthSessionId == null) { - // There exists an authentication session but no auth session ID was received in the action token - logger.debugf("Authentication session exists while reauthentication was requested by using action token %s, restarting.", token.getId()); - new AuthenticationSessionManager(session).removeAuthenticationSession(realm, authSession, false); - - authSession = handler.startFreshAuthenticationSession(token, tokenContext); - tokenContext.setAuthenticationSession(authSession, true); - } else { - LoginActionsServiceChecks.checkNotLoggedInYet(tokenContext, tokenAuthSessionId); - LoginActionsServiceChecks.checkAuthenticationSessionFromCookieMatchesOneFromToken(tokenContext, tokenAuthSessionId); - } } + initLoginEvent(authSession); + event.event(handler.eventType()); + LoginActionsServiceChecks.checkIsUserValid(token, tokenContext); LoginActionsServiceChecks.checkIsClientValid(token, tokenContext); @@ -519,14 +509,9 @@ public class LoginActionsService { authSession.setAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID, token.getUserId()); - return handler.handleToken(token, tokenContext, this::processFlow); + return handler.handleToken(token, tokenContext); } catch (ExplainedTokenVerificationException ex) { return handleActionTokenVerificationException(tokenContext, ex, ex.getErrorEvent(), ex.getMessage()); - } catch (RestartFlowException ex) { - Response response = handler.handleRestartRequest(token, tokenContext, this::processFlow); - return response == null - ? handleActionTokenVerificationException(tokenContext, ex, eventError, defaultErrorMessage) - : response; } catch (LoginActionsServiceException ex) { Response response = ex.getResponse(); return response == null @@ -779,37 +764,6 @@ public class LoginActionsService { return AuthenticationManager.redirectAfterSuccessfulFlow(session, realm, clientSession.getUserSession(), clientSession, request, uriInfo, clientConnection, event, authSession.getProtocol()); } - - /** - * Initiated by admin, not the user on login - * - * @param key - * @return - */ - @Path("execute-actions") - @GET - public Response executeActions(@QueryParam("key") String key) { - // TODO:mposolda - /* - event.event(EventType.EXECUTE_ACTIONS); - if (key != null) { - SessionCodeChecks checks = checksForCode(key); - if (!checks.verifyCode(ClientSessionModel.Action.EXECUTE_ACTIONS.name(), ClientSessionCode.ActionType.USER)) { - return checks.response; - } - ClientSessionModel clientSession = checks.getClientSession(); - // verify user email as we know it is valid as this entry point would never have gotten here. - clientSession.getUserSession().getUser().setEmailVerified(true); - clientSession.setNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true"); - clientSession.setNote(ClientSessionModel.Action.EXECUTE_ACTIONS.name(), "true"); - return AuthenticationProcessor.redirectToRequiredActions(session, realm, clientSession, uriInfo); - } else { - event.error(Errors.INVALID_CODE); - return ErrorPage.error(session, Messages.INVALID_CODE); - }*/ - return null; - } - private void initLoginEvent(AuthenticationSessionModel authSession) { String responseType = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM); if (responseType == null) { diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceChecks.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceChecks.java index cabb1b65f4..6d42d255ff 100644 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceChecks.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceChecks.java @@ -44,11 +44,6 @@ public class LoginActionsServiceChecks { private static final Logger LOG = Logger.getLogger(LoginActionsServiceChecks.class.getName()); - /** - * Exception signalling that flow needs to be restarted because authentication session IDs from cookie and token do not match. - */ - public static class RestartFlowException extends VerificationException { } - /** * This check verifies that user ID (subject) from the token matches * the one from the authentication session. @@ -264,32 +259,32 @@ public class LoginActionsServiceChecks { * * @param */ - public static void checkAuthenticationSessionFromCookieMatchesOneFromToken(ActionTokenContext context, String authSessionIdFromToken) throws VerificationException { + public static boolean doesAuthenticationSessionFromCookieMatchOneFromToken(ActionTokenContext context, String authSessionIdFromToken) throws VerificationException { if (authSessionIdFromToken == null) { - throw new RestartFlowException(); + return false; } AuthenticationSessionManager asm = new AuthenticationSessionManager(context.getSession()); String authSessionIdFromCookie = asm.getCurrentAuthenticationSessionId(context.getRealm()); if (authSessionIdFromCookie == null) { - throw new RestartFlowException(); + return false; } AuthenticationSessionModel authSessionFromCookie = context.getSession() .authenticationSessions().getAuthenticationSession(context.getRealm(), authSessionIdFromCookie); if (authSessionFromCookie == null) { // Cookie contains ID of expired auth session - throw new RestartFlowException(); + return false; } if (Objects.equals(authSessionIdFromCookie, authSessionIdFromToken)) { context.setAuthenticationSession(authSessionFromCookie, false); - return; + return true; } String parentSessionId = authSessionFromCookie.getAuthNote(AuthenticationProcessor.FORKED_FROM); if (parentSessionId == null || ! Objects.equals(authSessionIdFromToken, parentSessionId)) { - throw new RestartFlowException(); + return false; } AuthenticationSessionModel authSessionFromParent = context.getSession() @@ -299,12 +294,14 @@ public class LoginActionsServiceChecks { // from the login form (browser flow) but from the token's flow // Don't expire KC_RESTART cookie at this point asm.removeAuthenticationSession(context.getRealm(), authSessionFromCookie, false); - LOG.infof("Removed forked session: %s", authSessionFromCookie.getId()); + LOG.debugf("Removed forked session: %s", authSessionFromCookie.getId()); // Refresh browser cookie asm.setAuthSessionCookie(parentSessionId, context.getRealm()); context.setAuthenticationSession(authSessionFromParent, false); context.setExecutionId(authSessionFromParent.getAuthNote(AuthenticationProcessor.LAST_PROCESSED_EXECUTION)); + + return true; } } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory index 246758dfdb..2a5b9ec3e5 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory @@ -1,3 +1,4 @@ org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionTokenHandler org.keycloak.authentication.actiontoken.execactions.ExecuteActionsActionTokenHandler org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionTokenHandler +org.keycloak.authentication.actiontoken.idpverifyemail.IdpVerifyAccountLinkActionTokenHandler \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java index 7f26d742b3..f34c32017f 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java @@ -21,9 +21,7 @@ import org.hamcrest.Matchers; import org.jboss.arquillian.drone.api.annotation.Drone; import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.test.api.ArquillianResource; -import org.junit.After; import org.junit.Assert; -import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.keycloak.admin.client.resource.IdentityProviderResource; @@ -62,7 +60,6 @@ import org.openqa.selenium.WebDriver; import javax.mail.MessagingException; import javax.mail.internet.MimeMessage; import javax.ws.rs.ClientErrorException; -import javax.ws.rs.NotFoundException; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import java.io.IOException; diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractFirstBrokerLoginTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractFirstBrokerLoginTest.java index f2dffaf016..ba7bb65694 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractFirstBrokerLoginTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractFirstBrokerLoginTest.java @@ -52,6 +52,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -298,6 +300,147 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractIdentityProvi Assert.assertTrue(user.isEmailVerified()); } + /** + * Tests that duplication is detected and user wants to link federatedIdentity with existing account. He will confirm link by email + */ + @Test + public void testLinkAccountByEmailVerificationTwice() throws Exception { + setUpdateProfileFirstLogin(IdentityProviderRepresentation.UPFLM_OFF); + + loginIDP("pedroigor"); + + this.idpConfirmLinkPage.assertCurrent(); + Assert.assertEquals("User with email psilva@redhat.com already exists. How do you want to continue?", this.idpConfirmLinkPage.getMessage()); + this.idpConfirmLinkPage.clickLinkAccount(); + + // Confirm linking account by email + this.idpLinkEmailPage.assertCurrent(); + Assert.assertThat( + this.idpLinkEmailPage.getMessage(), + is("An email with instructions to link " + ObjectUtil.capitalize(getProviderId()) + " account pedroigor with your " + APP_REALM_ID + " account has been sent to you.") + ); + + Assert.assertEquals(1, greenMail.getReceivedMessages().length); + MimeMessage message = greenMail.getReceivedMessages()[0]; + String linkFromMail = getVerificationEmailLink(message); + + driver.navigate().to(linkFromMail.trim()); + + // authenticated and redirected to app. User is linked with identity provider + assertFederatedUser("pedroigor", "psilva@redhat.com", "pedroigor"); + + // Assert user's email is verified now + UserModel user = getFederatedUser(); + Assert.assertTrue(user.isEmailVerified()); + + // Attempt to use the link for the second time + driver.navigate().to(linkFromMail.trim()); + + infoPage.assertCurrent(); + Assert.assertThat(infoPage.getInfo(), is("You are already logged in.")); + + // Log out + driver.navigate().to("http://localhost:8081/test-app/logout"); + + // Go to the same link again + driver.navigate().to(linkFromMail.trim()); + + infoPage.assertCurrent(); + Assert.assertThat(infoPage.getInfo(), startsWith("Your account was successfully linked with " + getProviderId() + " account pedroigor")); + } + + /** + * Tests that duplication is detected and user wants to link federatedIdentity with existing account. He will confirm link by email + */ + @Test + public void testLinkAccountByEmailVerificationDifferentBrowser() throws Exception, Throwable { + setUpdateProfileFirstLogin(IdentityProviderRepresentation.UPFLM_OFF); + + loginIDP("pedroigor"); + + this.idpConfirmLinkPage.assertCurrent(); + Assert.assertEquals("User with email psilva@redhat.com already exists. How do you want to continue?", this.idpConfirmLinkPage.getMessage()); + this.idpConfirmLinkPage.clickLinkAccount(); + + // Confirm linking account by email + this.idpLinkEmailPage.assertCurrent(); + Assert.assertThat( + this.idpLinkEmailPage.getMessage(), + is("An email with instructions to link " + ObjectUtil.capitalize(getProviderId()) + " account pedroigor with your " + APP_REALM_ID + " account has been sent to you.") + ); + + Assert.assertEquals(1, greenMail.getReceivedMessages().length); + MimeMessage message = greenMail.getReceivedMessages()[0]; + String linkFromMail = getVerificationEmailLink(message); + + WebRule webRule2 = new WebRule(this); + try { + webRule2.initProperties(); + + WebDriver driver2 = webRule2.getDriver(); + InfoPage infoPage2 = webRule2.getPage(InfoPage.class); + + driver2.navigate().to(linkFromMail.trim()); + + // authenticated, but not redirected to app. Just seeing info page. + infoPage2.assertCurrent(); + Assert.assertThat(infoPage2.getInfo(), startsWith("Your account was successfully linked with " + getProviderId() + " account pedroigor")); + } finally { + // Revert everything + webRule2.after(); + } + + driver.navigate().refresh(); + this.loginExpiredPage.assertCurrent(); + this.loginExpiredPage.clickLoginContinueLink(); + + // authenticated and redirected to app. User is linked with identity provider + assertFederatedUser("pedroigor", "psilva@redhat.com", "pedroigor"); + + // Assert user's email is verified now + UserModel user = getFederatedUser(); + Assert.assertTrue(user.isEmailVerified()); + } + + @Test + public void testLinkAccountByEmailVerificationResendEmail() throws Exception, Throwable { + setUpdateProfileFirstLogin(IdentityProviderRepresentation.UPFLM_OFF); + + loginIDP("pedroigor"); + + this.idpConfirmLinkPage.assertCurrent(); + Assert.assertEquals("User with email psilva@redhat.com already exists. How do you want to continue?", this.idpConfirmLinkPage.getMessage()); + this.idpConfirmLinkPage.clickLinkAccount(); + + // Confirm linking account by email + this.idpLinkEmailPage.assertCurrent(); + Assert.assertThat( + this.idpLinkEmailPage.getMessage(), + is("An email with instructions to link " + ObjectUtil.capitalize(getProviderId()) + " account pedroigor with your " + APP_REALM_ID + " account has been sent to you.") + ); + + this.idpLinkEmailPage.clickResendEmail(); + + this.idpLinkEmailPage.assertCurrent(); + Assert.assertThat( + this.idpLinkEmailPage.getMessage(), + is("An email with instructions to link " + ObjectUtil.capitalize(getProviderId()) + " account pedroigor with your " + APP_REALM_ID + " account has been sent to you.") + ); + + Assert.assertEquals(2, greenMail.getReceivedMessages().length); + MimeMessage message = greenMail.getReceivedMessages()[0]; + String linkFromMail = getVerificationEmailLink(message); + + driver.navigate().to(linkFromMail.trim()); + + // authenticated and redirected to app. User is linked with identity provider + assertFederatedUser("pedroigor", "psilva@redhat.com", "pedroigor"); + + // Assert user's email is verified now + UserModel user = getFederatedUser(); + Assert.assertTrue(user.isEmailVerified()); + } + /** * Tests that duplication is detected and user wants to link federatedIdentity with existing account. He will confirm link by reauthentication (confirm password on login screen) @@ -557,29 +700,35 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractIdentityProvi // Simulate 2nd browser WebRule webRule2 = new WebRule(this); - webRule2.before(); + try { + webRule2.initProperties(); - WebDriver driver2 = webRule2.getDriver(); - LoginPasswordUpdatePage passwordUpdatePage2 = webRule2.getPage(LoginPasswordUpdatePage.class); - InfoPage infoPage2 = webRule2.getPage(InfoPage.class); + WebDriver driver2 = webRule2.getDriver(); + LoginPasswordUpdatePage passwordUpdatePage2 = webRule2.getPage(LoginPasswordUpdatePage.class); + InfoPage infoPage2 = webRule2.getPage(InfoPage.class); - driver2.navigate().to(linkFromMail.trim()); + driver2.navigate().to(linkFromMail.trim()); - // Need to update password now - passwordUpdatePage2.assertCurrent(); - passwordUpdatePage2.changePassword("password", "password"); + // Need to update password now + passwordUpdatePage2.assertCurrent(); + passwordUpdatePage2.changePassword("password", "password"); - // authenticated, but not redirected to app. Just seeing info page. - infoPage2.assertCurrent(); - Assert.assertEquals("Your account has been updated.", infoPage2.getInfo()); + // authenticated, but not redirected to app. Just seeing info page. + infoPage2.assertCurrent(); + Assert.assertEquals("Your account has been updated.", infoPage2.getInfo()); + } finally { + // Revert everything + webRule2.after(); + } // User is not yet linked with identity provider. He needs to authenticate again in 1st browser RealmModel realmWithBroker = getRealm(); Set federatedIdentities = this.session.users().getFederatedIdentities(this.session.users().getUserByUsername("pedroigor", realmWithBroker), realmWithBroker); assertEquals(0, federatedIdentities.size()); - // Continue with 1st browser - loginIDP("pedroigor"); + // Continue with 1st browser. Note that the user has already authenticated with brokered IdP in the beginning of this test + // so entering their credentials there is now skipped. + loginToIDPWhenAlreadyLoggedIntoProviderIdP("pedroigor"); this.idpConfirmLinkPage.assertCurrent(); Assert.assertEquals("User with email psilva@redhat.com already exists. How do you want to continue?", this.idpConfirmLinkPage.getMessage()); @@ -591,9 +740,6 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractIdentityProvi // authenticated and redirected to app. User is linked with identity provider assertFederatedUser("pedroigor", "psilva@redhat.com", "pedroigor"); - // Revert everything - webRule2.after(); - brokerServerRule.update(new KeycloakRule.KeycloakSetup() { @Override diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java index 8efc8c0ba3..297d00a55b 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderTest.java @@ -37,14 +37,7 @@ import org.keycloak.testsuite.MailUtil; import org.keycloak.testsuite.OAuthClient; import org.keycloak.testsuite.broker.util.UserSessionStatusServlet; import org.keycloak.testsuite.broker.util.UserSessionStatusServlet.UserSessionStatus; -import org.keycloak.testsuite.pages.AccountFederatedIdentityPage; -import org.keycloak.testsuite.pages.AccountPasswordPage; -import org.keycloak.testsuite.pages.AccountUpdateProfilePage; -import org.keycloak.testsuite.pages.ErrorPage; -import org.keycloak.testsuite.pages.LoginPage; -import org.keycloak.testsuite.pages.LoginUpdateProfilePage; -import org.keycloak.testsuite.pages.OAuthGrantPage; -import org.keycloak.testsuite.pages.VerifyEmailPage; +import org.keycloak.testsuite.pages.*; import org.keycloak.testsuite.rule.GreenMailRule; import org.keycloak.testsuite.rule.LoggingRule; import org.keycloak.testsuite.rule.WebResource; @@ -61,9 +54,8 @@ import java.net.URI; import java.util.List; import java.util.Set; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.*; /** * @author pedroigor @@ -115,6 +107,9 @@ public abstract class AbstractIdentityProviderTest { @WebResource protected ErrorPage errorPage; + @WebResource + protected InfoPage infoPage; + protected KeycloakSession session; protected int logoutTimeOffset = 0; @@ -210,18 +205,29 @@ public abstract class AbstractIdentityProviderTest { protected void loginIDP(String username) { driver.navigate().to("http://localhost:8081/test-app"); - assertTrue(this.driver.getCurrentUrl().startsWith("http://localhost:8081/auth/realms/realm-with-broker/protocol/openid-connect/auth")); + assertThat(this.driver.getCurrentUrl(), startsWith("http://localhost:8081/auth/realms/realm-with-broker/protocol/openid-connect/auth")); // choose the identity provider this.loginPage.clickSocial(getProviderId()); String currentUrl = this.driver.getCurrentUrl(); - assertTrue(currentUrl.startsWith("http://localhost:8082/auth/")); + assertThat(currentUrl, startsWith("http://localhost:8082/auth/")); // log in to identity provider this.loginPage.login(username, "password"); doAfterProviderAuthentication(); } + protected void loginToIDPWhenAlreadyLoggedIntoProviderIdP(String username) { + driver.navigate().to("http://localhost:8081/test-app"); + + assertThat(this.driver.getCurrentUrl(), startsWith("http://localhost:8081/auth/realms/realm-with-broker/protocol/openid-connect/auth")); + + // choose the identity provider + this.loginPage.clickSocial(getProviderId()); + + doAfterProviderAuthentication(); + } + protected UserModel getFederatedUser() { UserSessionStatus userSessionStatus = retrieveSessionStatus(); IDToken idToken = userSessionStatus.getIdToken(); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/SAMLFirstBrokerLoginTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/SAMLFirstBrokerLoginTest.java index 200b0a7c60..234617b482 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/SAMLFirstBrokerLoginTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/SAMLFirstBrokerLoginTest.java @@ -23,6 +23,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.services.managers.RealmManager; import org.keycloak.testsuite.KeycloakServer; import org.keycloak.testsuite.rule.AbstractKeycloakRule; +import org.junit.Test; /** * @author Marek Posolda diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/IdpLinkEmailPage.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/IdpLinkEmailPage.java index 8ed8461070..22eb1560ba 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/IdpLinkEmailPage.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/pages/IdpLinkEmailPage.java @@ -28,11 +28,22 @@ public class IdpLinkEmailPage extends AbstractPage { @FindBy(id = "instruction1") private WebElement message; + @FindBy(linkText = "Click here") + private WebElement resendEmailLink; + @Override public boolean isCurrent() { return driver.getTitle().startsWith("Link "); } + public void clickResendEmail() { + resendEmailLink.click(); + } + + public String getResendEmailLink() { + return resendEmailLink.getAttribute("href"); + } + @Override public void open() throws Exception { throw new UnsupportedOperationException(); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/rule/WebRule.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/rule/WebRule.java index 2cea40a7cd..0d93d19a21 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/rule/WebRule.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/rule/WebRule.java @@ -40,10 +40,14 @@ public class WebRule extends ExternalResource { this.test = test; } - @Override - public void before() throws Throwable { + public void initProperties() { driver = createWebDriver(); oauth = new OAuthClient(driver); + } + + @Override + public void before() throws Throwable { + initProperties(); initWebResources(test); } @@ -58,6 +62,7 @@ public class WebRule extends ExternalResource { HtmlUnitDriver d = new HtmlUnitDriver(); d.getWebClient().getOptions().setJavaScriptEnabled(true); d.getWebClient().getOptions().setCssEnabled(false); + d.getWebClient().getOptions().setTimeout(1000000); driver = d; } else if (browser.equals("chrome")) { driver = new ChromeDriver(); diff --git a/themes/src/main/resources/theme/base/login/login-idp-link-email.ftl b/themes/src/main/resources/theme/base/login/login-idp-link-email.ftl index 5dc29f1c11..9cca544ae7 100644 --- a/themes/src/main/resources/theme/base/login/login-idp-link-email.ftl +++ b/themes/src/main/resources/theme/base/login/login-idp-link-email.ftl @@ -9,7 +9,7 @@ ${msg("emailLinkIdp1", idpAlias, brokerContext.username, realm.displayName)}

- ${msg("emailLinkIdp2")} ${msg("doClickHere")} ${msg("emailLinkIdp3")} + ${msg("emailLinkIdp2")} ${msg("doClickHere")} ${msg("emailLinkIdp3")}

\ No newline at end of file