KEYCLOAK-4627 IdP email account verification + code cleanup. Fix for concurrent access to auth session notes

This commit is contained in:
Hynek Mlnarik 2017-05-02 18:18:32 +02:00 committed by mposolda
parent 168153c6e7
commit c431cc1b01
28 changed files with 685 additions and 258 deletions

View file

@ -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<String, String> authNotesFragment;
public static AuthenticationSessionAuthNoteUpdateEvent create(String authSessionId, Map<String, String> authNotesFragment) {
AuthenticationSessionAuthNoteUpdateEvent event = new AuthenticationSessionAuthNoteUpdateEvent();
event.authSessionId = authSessionId;
event.authNotesFragment = new LinkedHashMap<>(authNotesFragment);
return event;
}
public String getAuthSessionId() {
return authSessionId;
}
public Map<String, String> getAuthNotesFragment() {
return authNotesFragment;
}
@Override
public String toString() {
return String.format("AuthenticationSessionAuthNoteUpdateEvent [ authSessionId=%s, authNotesFragment=%s ]", authSessionId, authNotesFragment);
}
}

View file

@ -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<String, String> getClientNotes() {
if (entity.getClientNotes() == null || entity.getClientNotes().isEmpty()) return Collections.emptyMap();
Map<String, String> copy = new HashMap<>();
Map<String, String> 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<String, String>());
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<String, String>());
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<String, String> copy = new HashMap<>();
ConcurrentHashMap<String, String> copy = new ConcurrentHashMap<>();
copy.putAll(entity.getUserSessionNotes());
return copy;
}
@Override
public void clearUserSessionNotes() {
entity.setUserSessionNotes(new HashMap<String, String>());
entity.setUserSessionNotes(new ConcurrentHashMap<>());
update();
}

View file

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

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class InfinispanAuthenticationSessionProviderFactory implements AuthenticationSessionProviderFactory {
private static final Logger log = Logger.getLogger(InfinispanAuthenticationSessionProviderFactory.class);
private volatile Cache<String, AuthenticationSessionEntity> 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<String, AuthenticationSessionEntity> 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<String, String> authNotesFragment) {
if (authSession != null) {
if (authSession.getAuthNotes() == null) {
authSession.setAuthNotes(new ConcurrentHashMap<>());
}
for (Entry<String, String> 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) {
}

View file

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

View file

@ -23,4 +23,6 @@ import org.keycloak.provider.ProviderFactory;
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public interface AuthenticationSessionProviderFactory extends ProviderFactory<AuthenticationSessionProvider> {
// TODO:hmlnarik: move this constant out of an interface into a more appropriate class
public static final String AUTHENTICATION_SESSION_EVENTS = "AUTHENTICATION_SESSION_EVENTS";
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -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<String, String> authNotesFragment);
}

View file

@ -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<T extends JsonWebToken> {
@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<T extends JsonWebToken> {
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<T> handler) {
public ActionTokenContext(KeycloakSession session, RealmModel realm, UriInfo uriInfo,
ClientConnection clientConnection, HttpRequest request,
EventBuilder event, ActionTokenHandler<T> handler, String executionId,
ProcessAuthenticateFlow processFlow, ProcessBrokerFlow processBrokerFlow) {
this.session = session;
this.realm = realm;
this.uriInfo = uriInfo;
@ -54,6 +72,9 @@ public class ActionTokenContext<T extends JsonWebToken> {
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<T extends JsonWebToken> {
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);
}
}

View file

@ -35,21 +35,6 @@ import javax.ws.rs.core.Response;
*/
public interface ActionTokenHandler<T extends JsonWebToken> 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<? super T>[] getVerifiers(ActionTokenContext<T> 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<T extends JsonWebToken> extends Provider {
* @return
* @throws VerificationException
*/
Response handleToken(T token, ActionTokenContext<T> tokenContext, ProcessFlow processFlow);
Response handleToken(T token, ActionTokenContext<T> tokenContext);
/**
* Returns the Java token class for use with deserialization.
@ -67,6 +52,16 @@ public interface ActionTokenHandler<T extends JsonWebToken> extends Provider {
*/
Class<T> 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<? super T>[] getVerifiers(ActionTokenContext<T> 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<T extends JsonWebToken> 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<T> 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

View file

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

View file

@ -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<ExecuteActionsActionToken> tokenContext, ProcessFlow processFlow) {
public Response handleToken(ExecuteActionsActionToken token, ActionTokenContext<ExecuteActionsActionToken> tokenContext) {
AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession();
String redirectUri = RedirectUtils.verifyRedirectUri(tokenContext.getUriInfo(), token.getRedirectUri(),

View file

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

View file

@ -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<IdpVerifyAccountLinkActionToken> {
public IdpVerifyAccountLinkActionTokenHandler() {
super(
IdpVerifyAccountLinkActionToken.TOKEN_TYPE,
IdpVerifyAccountLinkActionToken.class,
Messages.STALE_CODE,
EventType.IDENTITY_PROVIDER_LINK_ACCOUNT,
Errors.INVALID_TOKEN
);
}
@Override
public Predicate<? super IdpVerifyAccountLinkActionToken>[] getVerifiers(ActionTokenContext<IdpVerifyAccountLinkActionToken> tokenContext) {
return TokenUtils.predicates(
);
}
@Override
public Response handleToken(IdpVerifyAccountLinkActionToken token, ActionTokenContext<IdpVerifyAccountLinkActionToken> 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));
}
}

View file

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

View file

@ -57,7 +57,7 @@ public class VerifyEmailActionTokenHandler extends AbstractActionTokenHander<Ver
}
@Override
public Response handleToken(VerifyEmailActionToken token, ActionTokenContext<VerifyEmailActionToken> tokenContext, ProcessFlow processFlow) {
public Response handleToken(VerifyEmailActionToken token, ActionTokenContext<VerifyEmailActionToken> tokenContext) {
UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser();
EventBuilder event = tokenContext.getEvent();

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -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<String, String> 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);
}
}

View file

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

View file

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

View file

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

View file

@ -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 <T>
*/
public static <T extends JsonWebToken> void checkAuthenticationSessionFromCookieMatchesOneFromToken(ActionTokenContext<T> context, String authSessionIdFromToken) throws VerificationException {
public static <T extends JsonWebToken> boolean doesAuthenticationSessionFromCookieMatchOneFromToken(ActionTokenContext<T> 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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>

View file

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

View file

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

View file

@ -9,7 +9,7 @@
${msg("emailLinkIdp1", idpAlias, brokerContext.username, realm.displayName)}
</p>
<p id="instruction2" class="instruction">
${msg("emailLinkIdp2")} <a href="${url.firstBrokerLoginUrl}">${msg("doClickHere")}</a> ${msg("emailLinkIdp3")}
${msg("emailLinkIdp2")} <a href="${url.loginAction}">${msg("doClickHere")}</a> ${msg("emailLinkIdp3")}
</p>
</#if>
</@layout.registrationLayout>