Do not allow verifying email from a different account

Closes #14776

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2023-12-19 20:38:50 -03:00 committed by Marek Posolda
parent f476a42d66
commit 8ff9e71eae
11 changed files with 209 additions and 93 deletions

View file

@ -95,3 +95,17 @@ In this release, the server will render the update profile page when the user is
first time using the `idp-review-user-profile.ftl` template. first time using the `idp-review-user-profile.ftl` template.
For more details, see link:{upgradingguide_link}[{upgradingguide_name}]. For more details, see link:{upgradingguide_link}[{upgradingguide_name}].
= Performing actions on behalf of another user is not longer possible when the user is already authenticated
In this release, you can no longer perform actions such as email verification if the user is already authenticated
and the action is bound to another user. For instance, a user can not complete the verification email flow if the email link
is bound to a different account.
= Changes to the email verification flow
In this release, if a user tries to follow the link to verify the email and the email was previously verified, a proper message
will be shown.
In addition to that, a new error (`EMAIL_ALREADY_VERIFIED`) event will be fired to indicate an attempt to verify an already verified email. You can
use this event to track possible attempts to hijack user accounts in case the link has leaked or to alert users if they do not recognize the action.

View file

@ -45,6 +45,7 @@ public interface Errors {
String USERNAME_MISSING = "username_missing"; String USERNAME_MISSING = "username_missing";
String USERNAME_IN_USE = "username_in_use"; String USERNAME_IN_USE = "username_in_use";
String EMAIL_IN_USE = "email_in_use"; String EMAIL_IN_USE = "email_in_use";
String EMAIL_ALREADY_VERIFIED = "email_already_verified";
String INVALID_REDIRECT_URI = "invalid_redirect_uri"; String INVALID_REDIRECT_URI = "invalid_redirect_uri";
String INVALID_CODE = "invalid_code"; String INVALID_CODE = "invalid_code";

View file

@ -28,13 +28,17 @@ import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.services.Urls; import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionCompoundId; import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import java.util.Collections; import java.util.Collections;
import java.util.stream.Stream;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
@ -73,10 +77,20 @@ public class IdpVerifyAccountLinkActionTokenHandler extends AbstractActionTokenH
event.event(EventType.IDENTITY_PROVIDER_LINK_ACCOUNT) event.event(EventType.IDENTITY_PROVIDER_LINK_ACCOUNT)
.detail(Details.EMAIL, user.getEmail()) .detail(Details.EMAIL, user.getEmail())
.detail(Details.IDENTITY_PROVIDER, token.getIdentityProviderAlias()) .detail(Details.IDENTITY_PROVIDER, token.getIdentityProviderAlias())
.detail(Details.IDENTITY_PROVIDER_USERNAME, token.getIdentityProviderUsername()) .detail(Details.IDENTITY_PROVIDER_USERNAME, token.getIdentityProviderUsername());
.success();
AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession();
if (user.isEmailVerified() && !isVerifyEmailActionSet(user, authSession)) {
event.user(user).error(Errors.EMAIL_ALREADY_VERIFIED);
return session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(session.getContext().getAuthenticationSession())
.setInfo(Messages.EMAIL_VERIFIED_ALREADY, user.getEmail())
.createInfoPage();
}
event.success();
if (tokenContext.isAuthenticationSessionFresh()) { if (tokenContext.isAuthenticationSessionFresh()) {
token.setOriginalCompoundAuthenticationSessionId(token.getCompoundAuthenticationSessionId()); token.setOriginalCompoundAuthenticationSessionId(token.getCompoundAuthenticationSessionId());
@ -126,4 +140,8 @@ public class IdpVerifyAccountLinkActionTokenHandler extends AbstractActionTokenH
return tokenContext.brokerFlow(null, null, authSession.getAuthNote(AuthenticationProcessor.CURRENT_FLOW_PATH)); return tokenContext.brokerFlow(null, null, authSession.getAuthNote(AuthenticationProcessor.CURRENT_FLOW_PATH));
} }
private boolean isVerifyEmailActionSet(UserModel user, AuthenticationSessionModel authSession) {
return Stream.concat(user.getRequiredActionsStream(), authSession.getRequiredActions().stream())
.anyMatch(RequiredAction.VERIFY_EMAIL.name()::equals);
}
} }

View file

@ -33,6 +33,8 @@ import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionCompoundId; import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Stream;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
@ -66,14 +68,22 @@ public class VerifyEmailActionTokenHandler extends AbstractActionTokenHandler<Ve
@Override @Override
public Response handleToken(VerifyEmailActionToken token, ActionTokenContext<VerifyEmailActionToken> tokenContext) { public Response handleToken(VerifyEmailActionToken token, ActionTokenContext<VerifyEmailActionToken> tokenContext) {
UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser(); UserModel user = tokenContext.getAuthenticationSession().getAuthenticatedUser();
KeycloakSession session = tokenContext.getSession();
AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession();
EventBuilder event = tokenContext.getEvent(); EventBuilder event = tokenContext.getEvent();
event.event(EventType.VERIFY_EMAIL).detail(Details.EMAIL, user.getEmail()); event.event(EventType.VERIFY_EMAIL).detail(Details.EMAIL, user.getEmail());
AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession(); if (user.isEmailVerified() && !isVerifyEmailActionSet(user, authSession)) {
event.user(user).error(Errors.EMAIL_ALREADY_VERIFIED);
return session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authSession)
.setInfo(Messages.EMAIL_VERIFIED_ALREADY, user.getEmail())
.createInfoPage();
}
final UriInfo uriInfo = tokenContext.getUriInfo(); final UriInfo uriInfo = tokenContext.getUriInfo();
final RealmModel realm = tokenContext.getRealm(); final RealmModel realm = tokenContext.getRealm();
final KeycloakSession session = tokenContext.getSession();
if (tokenContext.isAuthenticationSessionFresh()) { if (tokenContext.isAuthenticationSessionFresh()) {
// Update the authentication session in the token // Update the authentication session in the token
@ -100,10 +110,10 @@ public class VerifyEmailActionTokenHandler extends AbstractActionTokenHandler<Ve
event.success(); event.success();
if (token.getCompoundOriginalAuthenticationSessionId() != null) { if (token.getCompoundOriginalAuthenticationSessionId() != null) {
AuthenticationSessionManager asm = new AuthenticationSessionManager(tokenContext.getSession()); AuthenticationSessionManager asm = new AuthenticationSessionManager(session);
asm.removeAuthenticationSession(tokenContext.getRealm(), authSession, true); asm.removeAuthenticationSession(tokenContext.getRealm(), authSession, true);
return tokenContext.getSession().getProvider(LoginFormsProvider.class) return session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authSession) .setAuthenticationSession(authSession)
.setSuccess(Messages.EMAIL_VERIFIED) .setSuccess(Messages.EMAIL_VERIFIED)
.createInfoPage(); .createInfoPage();
@ -115,4 +125,8 @@ public class VerifyEmailActionTokenHandler extends AbstractActionTokenHandler<Ve
return AuthenticationManager.redirectToRequiredActions(session, realm, authSession, uriInfo, nextAction); return AuthenticationManager.redirectToRequiredActions(session, realm, authSession, uriInfo, nextAction);
} }
private boolean isVerifyEmailActionSet(UserModel user, AuthenticationSessionModel authSession) {
return Stream.concat(user.getRequiredActionsStream(), authSession.getRequiredActions().stream())
.anyMatch(RequiredAction.VERIFY_EMAIL.name()::equals);
}
} }

View file

@ -109,6 +109,7 @@ public class Messages {
public static final String LINK_IDP = "linkIdpMessage"; public static final String LINK_IDP = "linkIdpMessage";
public static final String EMAIL_VERIFIED = "emailVerifiedMessage"; public static final String EMAIL_VERIFIED = "emailVerifiedMessage";
public static final String EMAIL_VERIFIED_ALREADY = "emailVerifiedAlreadyMessage";
public static final String EMAIL_SENT = "emailSentMessage"; public static final String EMAIL_SENT = "emailSentMessage";

View file

@ -51,6 +51,7 @@ import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.exceptions.TokenNotActiveException; import org.keycloak.exceptions.TokenNotActiveException;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.SingleUseObjectKeyModel; import org.keycloak.models.SingleUseObjectKeyModel;
import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
@ -524,8 +525,10 @@ public class LoginActionsService {
client = realm.getClientByClientId(clientId); client = realm.getClientByClientId(clientId);
} }
AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session); AuthenticationSessionManager authenticationSessionManager = new AuthenticationSessionManager(session);
KeycloakContext sessionContext = session.getContext();
if (client != null) { if (client != null) {
session.getContext().setClient(client); sessionContext.setClient(client);
authSession = authenticationSessionManager.getCurrentAuthenticationSession(realm, client, tabId); authSession = authenticationSessionManager.getCurrentAuthenticationSession(realm, client, tabId);
} }
@ -560,7 +563,7 @@ public class LoginActionsService {
.withChecks( .withChecks(
// Token introspection checks // Token introspection checks
TokenVerifier.IS_ACTIVE, TokenVerifier.IS_ACTIVE,
new TokenVerifier.RealmUrlCheck(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())), new TokenVerifier.RealmUrlCheck(Urls.realmIssuer(sessionContext.getUri().getBaseUri(), realm.getName())),
ACTION_TOKEN_BASIC_CHECKS ACTION_TOKEN_BASIC_CHECKS
); );
@ -596,22 +599,15 @@ public class LoginActionsService {
} }
// Now proceed with the verification and handle the token // Now proceed with the verification and handle the token
tokenContext = new ActionTokenContext(session, realm, session.getContext().getUri(), clientConnection, request, event, handler, execution, this::processFlow, this::brokerLoginFlow); tokenContext = new ActionTokenContext(session, realm, sessionContext.getUri(), clientConnection, request, event, handler, execution, this::processFlow, this::brokerLoginFlow);
try { try {
String tokenAuthSessionCompoundId = handler.getAuthenticationSessionIdFromToken(token, tokenContext, authSession); String tokenAuthSessionCompoundId = handler.getAuthenticationSessionIdFromToken(token, tokenContext, authSession);
if (tokenAuthSessionCompoundId != null) {
// This can happen if the token contains ID but user opens the link in a new browser
String sessionId = AuthenticationSessionCompoundId.encoded(tokenAuthSessionCompoundId).getRootSessionId();
LoginActionsServiceChecks.checkNotLoggedInYet(tokenContext, authSession, sessionId);
}
if (authSession == null) { if (authSession == null) {
authSession = handler.startFreshAuthenticationSession(token, tokenContext); authSession = handler.startFreshAuthenticationSession(token, tokenContext);
tokenContext.setAuthenticationSession(authSession, true); tokenContext.setAuthenticationSession(authSession, true);
} else if (tokenAuthSessionCompoundId == null || } else if (!LoginActionsServiceChecks.doesAuthenticationSessionFromCookieMatchOneFromToken(tokenContext, authSession, tokenAuthSessionCompoundId)) {
! LoginActionsServiceChecks.doesAuthenticationSessionFromCookieMatchOneFromToken(tokenContext, authSession, tokenAuthSessionCompoundId)) {
// There exists an authentication session but no auth session ID was received in the action token // 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()); logger.debugf("Authentication session in progress but no authentication session ID was found in action token %s, restarting.", token.getId());
authenticationSessionManager.removeAuthenticationSession(realm, authSession, false); authenticationSessionManager.removeAuthenticationSession(realm, authSession, false);
@ -622,13 +618,14 @@ public class LoginActionsService {
processLocaleParam(authSession); processLocaleParam(authSession);
} }
sessionContext.setAuthenticationSession(authSession);
initLoginEvent(authSession); initLoginEvent(authSession);
event.event(handler.eventType()); event.event(handler.eventType());
LoginActionsServiceChecks.checkIsUserValid(token, tokenContext); LoginActionsServiceChecks.checkIsUserValid(token, tokenContext, event);
LoginActionsServiceChecks.checkIsClientValid(token, tokenContext); LoginActionsServiceChecks.checkIsClientValid(token, tokenContext);
session.getContext().setClient(authSession.getClient()); sessionContext.setClient(authSession.getClient());
TokenVerifier.createWithoutSignature(token) TokenVerifier.createWithoutSignature(token)
.withChecks(handler.getVerifiers(tokenContext)) .withChecks(handler.getVerifiers(tokenContext))

View file

@ -16,17 +16,18 @@
*/ */
package org.keycloak.services.resources; package org.keycloak.services.resources;
import jakarta.ws.rs.core.Response;
import org.keycloak.TokenVerifier.Predicate; import org.keycloak.TokenVerifier.Predicate;
import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.ExplainedVerificationException; import org.keycloak.authentication.ExplainedVerificationException;
import org.keycloak.authentication.actiontoken.ActionTokenContext; import org.keycloak.authentication.actiontoken.ActionTokenContext;
import org.keycloak.authentication.actiontoken.ExplainedTokenVerificationException; import org.keycloak.authentication.actiontoken.ExplainedTokenVerificationException;
import org.keycloak.common.VerificationException; import org.keycloak.common.VerificationException;
import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.events.EventBuilder;
import org.keycloak.models.SingleUseObjectKeyModel; import org.keycloak.models.SingleUseObjectKeyModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.SingleUseObjectProvider;
@ -34,7 +35,9 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.ErrorPageException;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationManager.AuthResult;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionCompoundId; import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
@ -112,38 +115,11 @@ public class LoginActionsServiceChecks {
} }
} }
/**
* Verifies that the authentication session has not yet been converted to user session, in other words
* that the user has not yet completed authentication and logged in.
*/
public static <T extends JsonWebToken> void checkNotLoggedInYet(ActionTokenContext<T> context, AuthenticationSessionModel authSessionFromCookie, String authSessionId) throws VerificationException {
if (authSessionId == null) {
return;
}
UserSessionModel userSession = context.getSession().sessions().getUserSession(context.getRealm(), authSessionId);
boolean hasNoRequiredActions =
(userSession == null || userSession.getUser().getRequiredActionsStream().count() == 0)
&&
(authSessionFromCookie == null || authSessionFromCookie.getRequiredActions() == null || authSessionFromCookie.getRequiredActions().isEmpty());
if (userSession != null && hasNoRequiredActions) {
LoginFormsProvider loginForm = context.getSession().getProvider(LoginFormsProvider.class).setAuthenticationSession(context.getAuthenticationSession())
.setSuccess(Messages.ALREADY_LOGGED_IN);
if (context.getSession().getContext().getClient() == null) {
loginForm.setAttribute(Constants.SKIP_LINK, true);
}
throw new LoginActionsServiceException(loginForm.createInfoPage());
}
}
/** /**
* Verifies whether the user given by ID both exists in the current realm. If yes, * Verifies whether the user given by ID both exists in the current realm. If yes,
* it optionally also injects the user using the given function (e.g. into session context). * it optionally also injects the user using the given function (e.g. into session context).
*/ */
public static void checkIsUserValid(KeycloakSession session, RealmModel realm, String userId, Consumer<UserModel> userSetter) throws VerificationException { public static void checkIsUserValid(KeycloakSession session, RealmModel realm, String userId, Consumer<UserModel> userSetter, EventBuilder event) throws VerificationException {
UserModel user = userId == null ? null : session.users().getUserById(realm, userId); UserModel user = userId == null ? null : session.users().getUserById(realm, userId);
if (user == null) { if (user == null) {
@ -154,6 +130,21 @@ public class LoginActionsServiceChecks {
throw new ExplainedVerificationException(Errors.USER_DISABLED, Messages.ACCOUNT_DISABLED); throw new ExplainedVerificationException(Errors.USER_DISABLED, Messages.ACCOUNT_DISABLED);
} }
AuthResult authResult = AuthenticationManager.authenticateIdentityCookie(session, realm, true);
if (authResult != null) {
UserSessionModel userSession = authResult.getSession();
if (!user.equals(userSession.getUser())) {
// do not allow authenticated users performing actions that are bound to other user and fire an event
// it might be an attempt to hijack a user account or perform actions on behalf of others
// we don't support yet multiple accounts within a same browser session
event.detail(Details.EXISTING_USER, userSession.getUser().getId());
event.error(Errors.DIFFERENT_USER_AUTHENTICATED);
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
throw new ErrorPageException(session, authSession, Response.Status.BAD_REQUEST, Messages.DIFFERENT_USER_AUTHENTICATED, userSession.getUser().getUsername());
}
}
if (userSetter != null) { if (userSetter != null) {
userSetter.accept(user); userSetter.accept(user);
} }
@ -163,9 +154,9 @@ public class LoginActionsServiceChecks {
* Verifies whether the user given by ID both exists in the current realm. If yes, * Verifies whether the user given by ID both exists in the current realm. If yes,
* it optionally also injects the user using the given function (e.g. into session context). * it optionally also injects the user using the given function (e.g. into session context).
*/ */
public static <T extends JsonWebToken & SingleUseObjectKeyModel> void checkIsUserValid(T token, ActionTokenContext<T> context) throws VerificationException { public static <T extends JsonWebToken & SingleUseObjectKeyModel> void checkIsUserValid(T token, ActionTokenContext<T> context, EventBuilder event) throws VerificationException {
try { try {
checkIsUserValid(context.getSession(), context.getRealm(), token.getUserId(), context.getAuthenticationSession()::setAuthenticatedUser); checkIsUserValid(context.getSession(), context.getRealm(), token.getUserId(), context.getAuthenticationSession()::setAuthenticatedUser, event);
} catch (ExplainedVerificationException ex) { } catch (ExplainedVerificationException ex) {
throw new ExplainedTokenVerificationException(token, ex); throw new ExplainedTokenVerificationException(token, ex);
} }

View file

@ -32,6 +32,7 @@ import org.keycloak.events.EventType;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel.RequiredAction; import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
@ -81,6 +82,8 @@ import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.core.Is.is; import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -171,7 +174,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message = greenMail.getReceivedMessages()[0]; MimeMessage message = greenMail.getReceivedMessages()[0];
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
AssertEvents.ExpectedEvent emailEvent = events.expectRequiredAction(EventType.SEND_VERIFY_EMAIL).detail("email", "test-user@localhost"); AssertEvents.ExpectedEvent emailEvent = events.expectRequiredAction(EventType.SEND_VERIFY_EMAIL).detail("email", "test-user@localhost");
EventRepresentation sendEvent = emailEvent.assertEvent(); EventRepresentation sendEvent = emailEvent.assertEvent();
@ -209,7 +212,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
EventRepresentation sendEvent = events.expectRequiredAction(EventType.SEND_VERIFY_EMAIL).user(userId).detail(Details.USERNAME, "verifyemail").detail("email", "email@mail.com").assertEvent(); EventRepresentation sendEvent = events.expectRequiredAction(EventType.SEND_VERIFY_EMAIL).user(userId).detail(Details.USERNAME, "verifyemail").detail("email", "email@mail.com").assertEvent();
String mailCodeId = sendEvent.getDetails().get(Details.CODE_ID); String mailCodeId = sendEvent.getDetails().get(Details.CODE_ID);
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
driver.navigate().to(verificationUrl.trim()); driver.navigate().to(verificationUrl.trim());
@ -225,6 +228,60 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
events.expectLogin().user(userId).session(mailCodeId).detail(Details.USERNAME, "verifyemail").assertEvent(); events.expectLogin().user(userId).session(mailCodeId).detail(Details.USERNAME, "verifyemail").assertEvent();
} }
@Test
public void verifyEmailFromAnotherAccountWhenUserIsAuthenticated() throws Exception {
loginPage.open();
loginPage.clickRegister();
String username1 = KeycloakModelUtils.generateId();
registerPage.register("firstName", "lastName", username1 + "@mail.com", username1, "password", "password");
verifyEmailPage.assertCurrent();
Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
String verificationLink1 = getEmailLink(message);
loginPage.open();
loginPage.clickRegister();
String username2 = KeycloakModelUtils.generateId();
registerPage.register("firstName", "lastName", username2 + "@mail.com", username2, "password", "password");
verifyEmailPage.assertCurrent();
Assert.assertEquals(2, greenMail.getReceivedMessages().length);
message = greenMail.getReceivedMessages()[1];
String verificationLink2 = getEmailLink(message);
driver.navigate().to(verificationLink2.trim());
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
driver.navigate().to(verificationLink1.trim());
assertTrue(errorPage.getError().contains("You are already authenticated as different user"));
UserRepresentation user1 = testRealm().users().search(username1).get(0);
UserRepresentation user2 = testRealm().users().search(username2).get(0);
assertFalse(user1.isEmailVerified());
assertTrue(user2.isEmailVerified());
}
@Test
public void verifyEmailFromAnotherAccountAfterEmalIsVerified() throws Exception {
loginPage.open();
loginPage.clickRegister();
String username1 = KeycloakModelUtils.generateId();
registerPage.register("firstName", "lastName", username1 + "@mail.com", username1, "password", "password");
verifyEmailPage.assertCurrent();
Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
String verificationLink1 = getEmailLink(message);
loginPage.open();
loginPage.clickRegister();
String username2 = KeycloakModelUtils.generateId();
registerPage.register("firstName", "lastName", username2 + "@mail.com", username2, "password", "password");
verifyEmailPage.assertCurrent();
Assert.assertEquals(2, greenMail.getReceivedMessages().length);
message = greenMail.getReceivedMessages()[1];
String verificationLink2 = getEmailLink(message);
driver.navigate().to(verificationLink1.trim());
driver.navigate().to(verificationLink2.trim());
assertTrue(errorPage.getError().contains("You are already authenticated as different user"));
}
@Test @Test
public void verifyEmailResend() throws IOException, MessagingException { public void verifyEmailResend() throws IOException, MessagingException {
loginPage.open(); loginPage.open();
@ -250,7 +307,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(2, greenMail.getReceivedMessages().length); Assert.assertEquals(2, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
driver.navigate().to(verificationUrl.trim()); driver.navigate().to(verificationUrl.trim());
@ -294,7 +351,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(2, greenMail.getReceivedMessages().length); Assert.assertEquals(2, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
driver.navigate().to(verificationUrl.trim()); driver.navigate().to(verificationUrl.trim());
@ -324,7 +381,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message1 = greenMail.getReceivedMessages()[0]; MimeMessage message1 = greenMail.getReceivedMessages()[0];
String verificationUrl1 = getPasswordResetEmailLink(message1); String verificationUrl1 = getEmailLink(message1);
driver.navigate().to(verificationUrl1.trim()); driver.navigate().to(verificationUrl1.trim());
@ -333,12 +390,16 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message2 = greenMail.getReceivedMessages()[1]; MimeMessage message2 = greenMail.getReceivedMessages()[1];
String verificationUrl2 = getPasswordResetEmailLink(message2); String verificationUrl2 = getEmailLink(message2);
events.clear();
driver.navigate().to(verificationUrl2.trim()); driver.navigate().to(verificationUrl2.trim());
events.expectRequiredAction(EventType.VERIFY_EMAIL)
.error(Errors.EMAIL_ALREADY_VERIFIED)
.detail(Details.REDIRECT_URI, Matchers.any(String.class))
.assertEvent();
infoPage.assertCurrent(); infoPage.assertCurrent();
Assert.assertEquals("You are already logged in.", infoPage.getInfo()); Assert.assertEquals("Your email address has been verified already.", infoPage.getInfo());
} }
@Test @Test
@ -354,7 +415,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message1 = greenMail.getReceivedMessages()[0]; MimeMessage message1 = greenMail.getReceivedMessages()[0];
String verificationUrl1 = getPasswordResetEmailLink(message1); String verificationUrl1 = getEmailLink(message1);
driver.navigate().to(verificationUrl1.trim()); driver.navigate().to(verificationUrl1.trim());
@ -362,14 +423,31 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message2 = greenMail.getReceivedMessages()[1]; MimeMessage message2 = greenMail.getReceivedMessages()[1];
String verificationUrl2 = getPasswordResetEmailLink(message2); String verificationUrl2 = getEmailLink(message2);
driver.navigate().to(verificationUrl2.trim()); driver.navigate().to(verificationUrl2.trim());
proceedPage.assertCurrent(); assertEquals("Your email address has been verified already.", infoPage.getInfo());
proceedPage.clickProceedLink(); }
infoPage.assertCurrent();
assertEquals("Your email address has been verified.", infoPage.getInfo()); @Test
public void verifyEmailResendAndVerifyWithLatestLink() throws IOException, MessagingException {
// Email verification can be performed any number of times
loginPage.open();
loginPage.login("test-user@localhost", "password");
verifyEmailPage.clickResendEmail();
verifyEmailPage.assertCurrent();
Assert.assertEquals(2, greenMail.getReceivedMessages().length);
MimeMessage message1 = greenMail.getReceivedMessages()[0];
String verificationUrl1 = getEmailLink(message1);
MimeMessage message2 = greenMail.getReceivedMessages()[1];
String verificationUrl2 = getEmailLink(message2);
driver.navigate().to(verificationUrl2.trim());
appPage.assertCurrent();
driver.navigate().to(verificationUrl1.trim());
assertEquals("Your email address has been verified already.", infoPage.getInfo());
} }
@Test @Test
@ -383,7 +461,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
AssertEvents.ExpectedEvent emailEvent = events.expectRequiredAction(EventType.SEND_VERIFY_EMAIL).detail("email", "test-user@localhost"); AssertEvents.ExpectedEvent emailEvent = events.expectRequiredAction(EventType.SEND_VERIFY_EMAIL).detail("email", "test-user@localhost");
EventRepresentation sendEvent = emailEvent.assertEvent(); EventRepresentation sendEvent = emailEvent.assertEvent();
@ -422,7 +500,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
verificationUrl = KeycloakUriBuilder.fromUri(verificationUrl).replaceQueryParam(Constants.KEY, "foo").build().toString(); verificationUrl = KeycloakUriBuilder.fromUri(verificationUrl).replaceQueryParam(Constants.KEY, "foo").build().toString();
@ -453,7 +531,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
events.poll(); events.poll();
@ -495,7 +573,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
events.poll(); events.poll();
@ -540,7 +618,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
events.poll(); events.poll();
@ -578,7 +656,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
events.poll(); events.poll();
@ -606,7 +684,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
} }
public static String getPasswordResetEmailLink(MimeMessage message) throws IOException, MessagingException { public static String getEmailLink(MimeMessage message) throws IOException, MessagingException {
return MailUtils.getPasswordResetEmailLink(message); return MailUtils.getPasswordResetEmailLink(message);
} }
@ -621,7 +699,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(1, greenMail.getReceivedMessages().length); Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
driver.manage().deleteAllCookies(); driver.manage().deleteAllCookies();
@ -650,7 +728,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
// open link in the second browser without the session // open link in the second browser without the session
driver2.navigate().to(verificationUrl.trim()); driver2.navigate().to(verificationUrl.trim());
@ -688,7 +766,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(1, greenMail.getReceivedMessages().length); Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
driver.navigate().to(verificationUrl.trim()); driver.navigate().to(verificationUrl.trim());
@ -707,7 +785,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(1, greenMail.getReceivedMessages().length); Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
driver.manage().deleteAllCookies(); driver.manage().deleteAllCookies();
@ -732,7 +810,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(1, greenMail.getReceivedMessages().length); Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
driver.manage().deleteAllCookies(); driver.manage().deleteAllCookies();
@ -808,7 +886,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(1, greenMail.getReceivedMessages().length); Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
driver2.navigate().to(verificationUrl.trim()); driver2.navigate().to(verificationUrl.trim());
@ -846,7 +924,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(1, greenMail.getReceivedMessages().length); Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
// confirm // confirm
driver.navigate().to(verificationUrl); driver.navigate().to(verificationUrl);
@ -856,7 +934,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
// email should be verified and required actions empty // email should be verified and required actions empty
UserRepresentation user = testRealm().users().get(testUserId).toRepresentation(); UserRepresentation user = testRealm().users().get(testUserId).toRepresentation();
Assert.assertTrue(user.isEmailVerified()); assertTrue(user.isEmailVerified());
assertThat(user.getRequiredActions(), Matchers.empty()); assertThat(user.getRequiredActions(), Matchers.empty());
} }
@ -885,7 +963,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(1, greenMail.getReceivedMessages().length); Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
// confirm // confirm
driver.navigate().to(verificationUrl); driver.navigate().to(verificationUrl);
@ -895,7 +973,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
// email should be verified and required actions empty // email should be verified and required actions empty
UserRepresentation user = testRealm().users().get(testUserId).toRepresentation(); UserRepresentation user = testRealm().users().get(testUserId).toRepresentation();
Assert.assertTrue(user.isEmailVerified()); assertTrue(user.isEmailVerified());
assertThat(user.getRequiredActions(), Matchers.empty()); assertThat(user.getRequiredActions(), Matchers.empty());
} }
@ -917,7 +995,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
Assert.assertEquals(1, greenMail.getReceivedMessages().length); Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
// confirm in the second browser // confirm in the second browser
driver2.navigate().to(verificationUrl); driver2.navigate().to(verificationUrl);
@ -939,7 +1017,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
// email should be verified and required actions empty // email should be verified and required actions empty
UserRepresentation user = testRealm().users().get(testUserId).toRepresentation(); UserRepresentation user = testRealm().users().get(testUserId).toRepresentation();
Assert.assertTrue(user.isEmailVerified()); assertTrue(user.isEmailVerified());
assertThat(user.getRequiredActions(), Matchers.empty()); assertThat(user.getRequiredActions(), Matchers.empty());
// after refresh in the first browser the app should be shown // after refresh in the first browser the app should be shown
@ -964,7 +1042,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
MimeMessage message = greenMail.getLastReceivedMessage(); MimeMessage message = greenMail.getLastReceivedMessage();
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
try { try {
setTimeOffset(360); setTimeOffset(360);
@ -989,7 +1067,7 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
assertEquals(1, greenMail.getReceivedMessages().length); assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0]; MimeMessage message = greenMail.getReceivedMessages()[0];
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
UserResource user = testRealm().users().get(testUserId); UserResource user = testRealm().users().get(testUserId);
UserRepresentation userRep = user.toRepresentation(); UserRepresentation userRep = user.toRepresentation();

View file

@ -909,13 +909,14 @@ public abstract class AbstractFirstBrokerLoginTest extends AbstractInitializedBa
assertTrue(adminClient.realm(bc.consumerRealmName()).users().get(consumerUser.getId()).toRepresentation().isEmailVerified()); assertTrue(adminClient.realm(bc.consumerRealmName()).users().get(consumerUser.getId()).toRepresentation().isEmailVerified());
driver.navigate().to(url); driver.navigate().to(url);
waitForPage(driver, "you are already logged in.", false); waitForPage(driver, "your email address has been verified already.", false);
AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), "consumer"); AccountHelper.logout(adminClient.realm(bc.consumerRealmName()), "consumer");
driver.navigate().to(url); driver.navigate().to(url);
waitForPage(driver, "confirm linking the account testuser of identity provider " + bc.getIDPAlias() + " with your account.", false); waitForPage(driver, "your email address has been verified already.", false);
proceedPage.clickProceedLink();
waitForPage(driver, "you successfully verified your email. please go back to your original browser and continue there with the login.", false); driver2.navigate().to(url);
waitForPage(driver, "your email address has been verified already.", false);
} }

View file

@ -92,7 +92,7 @@ import static org.keycloak.storage.UserStorageProviderModel.EVICTION_HOUR;
import static org.keycloak.storage.UserStorageProviderModel.EVICTION_MINUTE; import static org.keycloak.storage.UserStorageProviderModel.EVICTION_MINUTE;
import static org.keycloak.storage.UserStorageProviderModel.IMPORT_ENABLED; import static org.keycloak.storage.UserStorageProviderModel.IMPORT_ENABLED;
import static org.keycloak.storage.UserStorageProviderModel.MAX_LIFESPAN; import static org.keycloak.storage.UserStorageProviderModel.MAX_LIFESPAN;
import static org.keycloak.testsuite.actions.RequiredActionEmailVerificationTest.getPasswordResetEmailLink; import static org.keycloak.testsuite.actions.RequiredActionEmailVerificationTest.getEmailLink;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlDoesntStartWith; import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlDoesntStartWith;
@ -393,7 +393,7 @@ public class UserStorageTest extends AbstractAuthTest {
MimeMessage message = greenMail.getReceivedMessages()[0]; MimeMessage message = greenMail.getReceivedMessages()[0];
String verificationUrl = getPasswordResetEmailLink(message); String verificationUrl = getEmailLink(message);
driver.navigate().to(verificationUrl.trim()); driver.navigate().to(verificationUrl.trim());

View file

@ -353,6 +353,7 @@ realmSupportsNoCredentialsMessage=Realm does not support any credential type.
credentialSetupRequired=Cannot login, credential setup required. credentialSetupRequired=Cannot login, credential setup required.
identityProviderNotUniqueMessage=Realm supports multiple identity providers. Could not determine which identity provider should be used to authenticate with. identityProviderNotUniqueMessage=Realm supports multiple identity providers. Could not determine which identity provider should be used to authenticate with.
emailVerifiedMessage=Your email address has been verified. emailVerifiedMessage=Your email address has been verified.
emailVerifiedAlreadyMessage=Your email address has been verified already.
staleEmailVerificationLink=The link you clicked is an old stale link and is no longer valid. Maybe you have already verified your email. staleEmailVerificationLink=The link you clicked is an old stale link and is no longer valid. Maybe you have already verified your email.
identityProviderAlreadyLinkedMessage=Federated identity returned by {0} is already linked to another user. identityProviderAlreadyLinkedMessage=Federated identity returned by {0} is already linked to another user.
confirmAccountLinking=Confirm linking the account {0} of identity provider {1} with your account. confirmAccountLinking=Confirm linking the account {0} of identity provider {1} with your account.