+
Skip to content

Disable email verification when email manually changed by idp review (26.2) #40733

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_2_6.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,15 @@ The docs also stated to update the `cache-ispn.xml` configuration file for volat
The current version will always use safe settings for the number of owners and maximum cache size for the affected user and client session caches, and will log only an INFO message.
With this behavior, there is no need any more to update the `cache-ispn.xml` configuration file.
If you previously used a custom `cache-ispn.xml` in order to use volatile user sessions, we recommend reverting those changes and use the standard configuration file.

=== Verify existing account by Email is only executed for the email and username sent by the identity provider

The execution *Verify Existing Account By Email* is one of the alternatives that the *First Login Flow* has to allow a brokering account to be linked to an existing {project_name} user. This step is executed when the user logs in into {project_name} through the broker for the first time, and the identity provider account is already present in {project_name}. The execution sends an email to the current {project_name} address in order to confirm the user controls that account.

Since this release, the *Verify Existing Account By Email* execution is only attempted in the *First Login Flow* if the linking attributes (email and username) sent by the external identity provider are not modified by the user during the review process. This new behavior avoids sending verification emails to an existing {project_name} account that can inadvertently accept the linking.

In case the provider needs to modify the information sent by the identity provider (because emails or usernames are different in the broker), only the other alternative *Verify Existing Account By Re-authentication* is available to link the new account to the existing {project_name} user.

If the data received from the identity provider is mandatory and cannot be modified, then the *Review Profile* step in the *First Login Flow* can be disabled to avoid any user intervention.

For more information, see link:{adminguide_link}#_identity_broker_first_login[the First login flow section of the {adminguide_name}].
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
*/
public abstract class AbstractIdentityProvider<C extends IdentityProviderModel> implements IdentityProvider<C> {

// The clientSession note flag to indicate that email or username provided by identityProvider was changed on updateProfile page
public static final String UPDATE_PROFILE_EMAIL_CHANGED = "UPDATE_PROFILE_EMAIL_CHANGED";
public static final String UPDATE_PROFILE_USERNAME_CHANGED = "UPDATE_PROFILE_USERNAME_CHANGED";

public static final String ACCOUNT_LINK_URL = "account-link-url";
protected final KeycloakSession session;
private final C config;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public interface LoginFormsProvider extends Provider {

Response createForm(String form);

String getMessage(String message);
String getMessage(String message, Object... parameters);

Response createLoginUsernamePassword();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,10 @@ public Response handleToken(IdpVerifyAccountLinkActionToken token, ActionTokenCo
authSession.getClient().getClientId(), authSession.getTabId(), AuthenticationProcessor.getClientData(session, authSession));
String confirmUri = builder.build(realm.getName()).toString();

return session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authSession)
.setSuccess(Messages.CONFIRM_ACCOUNT_LINKING, token.getIdentityProviderUsername(), token.getIdentityProviderAlias())
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
return forms.setAuthenticationSession(authSession)
.setAttribute("messageHeader", forms.getMessage(Messages.CONFIRM_ACCOUNT_LINKING, token.getIdentityProviderUsername(), token.getIdentityProviderAlias()))
.setSuccess(Messages.CONFIRM_ACCOUNT_LINKING_BODY, token.getIdentityProviderUsername(), token.getIdentityProviderAlias())
.setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, confirmUri)
.createInfoPage();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.CommonClientSessionModel;

import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
Expand All @@ -50,10 +51,19 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro
return;
}

// hide the review button if the idp review execution was not successfully executed before
boolean hideReviewButton = authSession.getExecutionStatus().entrySet().stream()
.filter(entry -> CommonClientSessionModel.ExecutionStatus.SUCCESS.equals(entry.getValue()))
.map(entry -> context.getRealm().getAuthenticationExecutionById(entry.getKey()))
.filter(exec -> IdpReviewProfileAuthenticatorFactory.PROVIDER_ID.equals(exec.getAuthenticator()))
.findAny()
.isEmpty();

ExistingUserInfo duplicationInfo = ExistingUserInfo.deserialize(existingUserInfo);
Response challenge = context.form()
.setStatus(Response.Status.OK)
.setAttribute(LoginFormsProvider.IDENTITY_PROVIDER_BROKER_CONTEXT, brokerContext)
.setAttribute("hideReviewButton", hideReviewButton ? Boolean.TRUE : null)
.setError(Messages.FEDERATED_IDENTITY_CONFIRM_LINK_MESSAGE, duplicationInfo.getDuplicateAttributeName(), duplicationInfo.getDuplicateAttributeValue())
.createIdpLinkConfirmLinkPage();
context.challenge(challenge);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.actiontoken.idpverifyemail.IdpVerifyAccountLinkActionToken;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.broker.provider.AbstractIdentityProvider;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.common.util.Time;
import org.keycloak.email.EmailException;
Expand Down Expand Up @@ -70,6 +71,13 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro
return;
}

if (Boolean.parseBoolean(context.getAuthenticationSession().getAuthNote(AbstractIdentityProvider.UPDATE_PROFILE_USERNAME_CHANGED))
|| Boolean.parseBoolean(context.getAuthenticationSession().getAuthNote(AbstractIdentityProvider.UPDATE_PROFILE_EMAIL_CHANGED))) {
logger.debug("Email or username changed on review. Ignoring email verification authenticator.");
context.attempted();
return;
}

if (Objects.equals(authSession.getAuthNote(VERIFY_ACCOUNT_IDP_USERNAME), brokerContext.getUsername())) {
UserModel existingUser = getExistingUser(session, realm, authSession);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.broker.provider.AbstractIdentityProvider;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
Expand Down Expand Up @@ -201,12 +202,21 @@ public String getFederationLink() {
UserProfile profile = profileProvider.create(UserProfileContext.IDP_REVIEW, attributes, updatedProfile);

try {
String oldEmail = userCtx.getEmail();

profile.update((attributeName, userModel, oldValue) -> {
if (attributeName.equals(UserModel.EMAIL)) {
context.getAuthenticationSession().setAuthNote(UPDATE_PROFILE_EMAIL_CHANGED, "true");
event.clone().event(EventType.UPDATE_EMAIL).detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name()).detail(Details.PREVIOUS_EMAIL, oldEmail).detail(Details.UPDATED_EMAIL, profile.getAttributes().getFirst(UserModel.EMAIL)).success();
if (attributeName.equals(UserModel.USERNAME)) {
context.getAuthenticationSession().setAuthNote(AbstractIdentityProvider.UPDATE_PROFILE_USERNAME_CHANGED, "true");
event.clone().event(EventType.UPDATE_PROFILE)
.detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name())
.detail(Details.PREF_PREVIOUS + UserModel.USERNAME, oldValue)
.detail(Details.PREF_UPDATED + UserModel.USERNAME, profile.getAttributes().getFirst(UserModel.USERNAME))
.success();
} else if (attributeName.equals(UserModel.EMAIL)) {
context.getAuthenticationSession().setAuthNote(AbstractIdentityProvider.UPDATE_PROFILE_EMAIL_CHANGED, "true");
event.clone().event(EventType.UPDATE_EMAIL)
.detail(Details.CONTEXT, UserProfileContext.IDP_REVIEW.name())
.detail(Details.PREVIOUS_EMAIL, oldValue)
.detail(Details.UPDATED_EMAIL, profile.getAttributes().getFirst(UserModel.EMAIL))
.success();
}
});
} catch (ValidationException pve) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -468,8 +468,8 @@ protected void handleMessages(Locale locale, Properties messagesBundle) {
}

@Override
public String getMessage(String message) {
return formatMessage(new FormMessage(null, message));
public String getMessage(String message, Object... parameters) {
return formatMessage(new FormMessage(null, message, parameters));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ public class Messages {

public static final String CONFIRM_ACCOUNT_LINKING = "confirmAccountLinking";

public static final String CONFIRM_ACCOUNT_LINKING_BODY = "confirmAccountLinkingBody";

public static final String CONFIRM_EMAIL_ADDRESS_VERIFICATION = "confirmEmailAddressVerification";

public static final String CONFIRM_EXECUTION_OF_ACTIONS = "confirmExecutionOfActions";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package org.keycloak.testsuite.pages;

import org.keycloak.testsuite.util.UIUtils;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;

Expand Down Expand Up @@ -47,6 +48,10 @@ public void clickReviewProfile() {
updateProfileButton.click();
}

public boolean isReviewProfileDisplayed() {
return UIUtils.isElementVisible(updateProfileButton);
}

public void clickLinkAccount() {
linkAccountButton.click();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.authenticators.broker.IdpReviewProfileAuthenticatorFactory;
import org.keycloak.broker.provider.HardcodedUserSessionAttributeMapper;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
Expand Down Expand Up @@ -823,6 +826,85 @@ public void testLinkAccountWithEmailVerified() {
assertTrue(realm.users().get(linkedUserId).toRepresentation().isEmailVerified());
}

@Test
public void testLinkAccountModifyingEmailLinkingByEmailNotAllowed() {
RealmResource providerRealm = adminClient.realm(bc.providerRealmName());

configureSMTPServer();

// change provider user email to changed@localhost.com
UserRepresentation userProvider = ApiUtil.findUserByUsername(providerRealm, bc.getUserLogin());
userProvider.setEmail("changed@localhost.com");
providerRealm.users().get(userProvider.getId()).update(userProvider);

//create user on consumer's site with the correct email
final String linkedUserId = createUser(bc.getUserLogin());

//test
oauth.config().clientId("broker-app");
loginPage.open(bc.consumerRealmName());

logInWithBroker(bc);

waitForPage(driver, "update account information", false);
Assert.assertTrue(updateAccountInformationPage.isCurrent());
Assert.assertTrue("We must be on correct realm right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));

// update the email to the correct one
log.debug("Updating info on updateAccount page");
updateAccountInformationPage.updateAccountInformation(USER_EMAIL, "Firstname", "Lastname");

//link account
waitForPage(driver, "account already exists", false);
idpConfirmLinkPage.clickLinkAccount();

//it should start the link using username and password as email has been changed
assertEquals("Authenticate to link your account with " + bc.getIDPAlias(), loginPage.getInfoMessage());

loginPage.login("password");
Assert.assertTrue(appPage.isCurrent());

assertNumFederatedIdentities(linkedUserId, 1);
}

@Test
public void testLinkAccountReviewDisabled() throws Exception {
RealmResource consumerRealm = adminClient.realm(bc.consumerRealmName());

// disable the idp review step
AuthenticationExecutionInfoRepresentation idpReviewProfileExec = consumerRealm.flows().getExecutions("first broker login").stream()
.filter(execution -> IdpReviewProfileAuthenticatorFactory.PROVIDER_ID.equals(execution.getProviderId()))
.findAny()
.orElse(null);
Assert.assertNotNull("IdpReviewProfileAuthenticator execution not found", idpReviewProfileExec);
idpReviewProfileExec.setRequirement(AuthenticationExecutionModel.Requirement.DISABLED.name());
consumerRealm.flows().updateExecutions("first broker login", idpReviewProfileExec);

//create user on consumer's site with the correct email
final String linkedUserId = createUser(bc.getUserLogin());

//test
oauth.config().clientId("broker-app");
loginPage.open(bc.consumerRealmName());

logInWithBroker(bc);

// no review displayed and button in link not available
waitForPage(driver, "account already exists", false);
Assert.assertTrue("We must be on correct realm right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/"));
Assert.assertFalse("Review Profile button is displayed", idpConfirmLinkPage.isReviewProfileDisplayed());
idpConfirmLinkPage.clickLinkAccount();

//linking the account using password as email not configured
assertEquals("Authenticate to link your account with " + bc.getIDPAlias(), loginPage.getInfoMessage());

loginPage.login("password");
Assert.assertTrue(appPage.isCurrent());

assertNumFederatedIdentities(linkedUserId, 1);
}

/**
* Refers to in old test suite: org.keycloak.testsuite.broker.AbstractKeycloakIdentityProviderTest#testSuccessfulAuthenticationWithoutUpdateProfile_emailProvided_emailVerifyEnabled
Expand Down Expand Up @@ -1051,7 +1133,8 @@ public void testLinkAccountByEmailVerificationInAnotherBrowser() {

// in the second browser confirm the mail
driver2.navigate().to(url);
assertThat(driver2.findElement(By.className("instruction")).getText(), startsWith("Confirm linking the account"));
assertThat(driver2.findElement(By.id("kc-page-title")).getText(), startsWith("Confirm linking the account"));
assertThat(driver2.findElement(By.className("instruction")).getText(), startsWith("If you link the account, you will also be able to login using account"));
driver2.findElement(By.linkText("» Click here to proceed")).click();
assertThat(driver2.findElement(By.className("instruction")).getText(), startsWith("You successfully verified your email."));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ emailTestSubject=[KEYCLOAK] - SMTP test message
emailTestBody=This is a test message
emailTestBodyHtml=<p>This is a test message</p>
identityProviderLinkSubject=Link {0}
identityProviderLinkBody=Someone wants to link your "{1}" account with "{0}" account of user {2} . If this was you, click the link below to link accounts\n\n{3}\n\nThis link will expire within {5}.\n\nIf you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.
identityProviderLinkBodyHtml=<p>Someone wants to link your <b>{1}</b> account with <b>{0}</b> account of user {2}. If this was you, click the link below to link accounts</p><p><a href="{3}">Link to confirm account linking</a></p><p>This link will expire within {5}.</p><p>If you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.</p>
identityProviderLinkBody=Someone wants to link your "{1}" account with "{0}" account of user {2} . If this was you, click the link below to link accounts\n\n{3}\n\nThis link will expire within {5}.\n\nIf you didn''t initiate this process or don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.
identityProviderLinkBodyHtml=<p>Someone wants to link your <b>{1}</b> account with <b>{0}</b> account of user {2}. If this was you, click the link below to link accounts</p><p><a href="{3}">Link to confirm account linking</a></p><p>This link will expire within {5}.</p><p>If you didn''t initiate this process or don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.</p>
passwordResetSubject=Reset password
passwordResetBody=Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.\n\n{0}\n\nThis link and code will expire within {3}.\n\nIf you don''t want to reset your credentials, just ignore this message and nothing will be changed.
passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.</p><p><a href="{0}">Link to reset credentials</a></p><p>This link will expire within {3}.</p><p>If you don''t want to reset your credentials, just ignore this message and nothing will be changed.</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
<#elseif section = "form">
<form id="kc-register-form" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="submitAction" id="updateProfile" value="updateProfile">${msg("confirmLinkIdpReviewProfile")}</button>
<#if !hideReviewButton?has_content>
<button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="submitAction" id="updateProfile" value="updateProfile">${msg("confirmLinkIdpReviewProfile")}</button>
</#if>
<button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="submitAction" id="linkAccount" value="linkAccount">${msg("confirmLinkIdpContinue", idpDisplayName)}</button>
</div>
</form>
Expand Down
Loading
Loading
点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载