Add re-authentication when updating email via UPDATE_EMAIL feature

Closes #39670

Signed-off-by: Martin Kanis <mkanis@redhat.com>
This commit is contained in:
Martin Kanis 2025-05-27 13:12:21 +02:00 committed by Pedro Igor
parent 814e66ef7b
commit f35c413b31
3 changed files with 103 additions and 11 deletions

View File

@ -38,17 +38,21 @@ import java.util.List;
*/
public interface RequiredActionFactory extends ProviderFactory<RequiredActionProvider> {
List<ProviderConfigProperty> MAX_AUTH_AGE_CONFIG_PROPERTIES = ProviderConfigurationBuilder.create()
.property()
.name(Constants.MAX_AUTH_AGE_KEY)
.label("Maximum Age of Authentication")
.helpText("Configures the duration in seconds this action can be used after the last authentication before the user is required to re-authenticate. " +
"This parameter is used just in the context of AIA when the kc_action parameter is available in the request, which is for instance when user " +
"himself updates his password in the account console.")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue(MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE)
.add()
.build();
List<ProviderConfigProperty> MAX_AUTH_AGE_CONFIG_PROPERTIES = getMaxAuthAgePropertyConfig();
static List<ProviderConfigProperty> getMaxAuthAgePropertyConfig() {
return ProviderConfigurationBuilder.create()
.property()
.name(Constants.MAX_AUTH_AGE_KEY)
.label("Maximum Age of Authentication")
.helpText("Configures the duration in seconds this action can be used after the last authentication before the user is required to re-authenticate. " +
"This parameter is used just in the context of AIA when the kc_action parameter is available in the request, which is for instance when user " +
"himself updates his password in the account console.")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue(MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE)
.add()
.build();
}
/**
* Display text used in admin console to reference this required action

View File

@ -17,6 +17,7 @@
package org.keycloak.authentication.requiredactions;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@ -49,6 +50,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.Urls;
import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel;
@ -199,6 +201,17 @@ public class UpdateEmail implements RequiredActionProvider, RequiredActionFactor
return UserModel.RequiredAction.UPDATE_EMAIL.name();
}
@Override
public int getMaxAuthAge(KeycloakSession session) {
// always require re-authentication
return 0;
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return Collections.emptyList();
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL);

View File

@ -17,27 +17,52 @@
package org.keycloak.testsuite.actions;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.keycloak.userprofile.UserProfileConstants.ROLE_USER;
import org.junit.After;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.admin.client.resource.UserProfileResource;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.Constants;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.userprofile.config.UPAttribute;
import org.keycloak.representations.userprofile.config.UPAttributePermissions;
import org.keycloak.representations.userprofile.config.UPAttributeRequired;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.util.UIUtils;
import org.keycloak.validate.validators.LengthValidator;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.support.FindBy;
public class AppInitiatedActionUpdateEmailTest extends AbstractAppInitiatedActionUpdateEmailTest {
@FindBy(id = "update-email-btn")
private WebElement updateEmailBtn;
@After
public void after() {
// update email required action max auth age back to default
Optional<RequiredActionProviderRepresentation> updateEmailAction = testRealm().flows().getRequiredActions()
.stream()
.filter(requiredAction -> requiredAction.getProviderId().equals(UserModel.RequiredAction.UPDATE_EMAIL.name()))
.findFirst();
if (updateEmailAction.isPresent()) {
updateEmailAction.get().getConfig().remove(Constants.MAX_AUTH_AGE_KEY);
testRealm().flows().updateRequiredAction(UserModel.RequiredAction.UPDATE_EMAIL.name(), updateEmailAction.get());
}
}
@Test
public void updateEmail() throws Exception {
changeEmailUsingAIA("new@email.com");
@ -104,4 +129,54 @@ public class AppInitiatedActionUpdateEmailTest extends AbstractAppInitiatedActio
emailUpdatePage.changeEmail(newEmail);
}
@Test
// only for firefox as it needs to go to the account console
@IgnoreBrowserDriver(value={FirefoxDriver.class}, negate=true)
public void updateEmailReAuthentication() {
appPage.open();
appPage.openAccount();
loginPage.login("test-user@localhost", "password");
setTimeOffset(50);
UIUtils.clickLink(updateEmailBtn);
loginPage.assertCurrent();
loginPage.login("password");
emailUpdatePage.assertCurrent();
emailUpdatePage.changeEmail("test-user2@localhost");
}
@Test
// only for firefox as it needs to go to the account console
// chrome doesn't work due to "change password popup"
@IgnoreBrowserDriver(value={FirefoxDriver.class}, negate=true)
public void updateEmailCustomMaxAgeReAuthentication() {
RequiredActionProviderRepresentation updateEmailAction = testRealm().flows().getRequiredActions()
.stream()
.filter(requiredAction -> requiredAction.getProviderId().equals(UserModel.RequiredAction.UPDATE_EMAIL.name()))
.findFirst()
.orElseThrow();
// this custom config should be ignored and re-authentication should be always required
updateEmailAction.getConfig().put(Constants.MAX_AUTH_AGE_KEY, "300");
testRealm().flows().updateRequiredAction(UserModel.RequiredAction.UPDATE_EMAIL.name(), updateEmailAction);
appPage.open();
appPage.openAccount();
loginPage.login("test-user@localhost", "password");
UIUtils.clickLink(updateEmailBtn);
loginPage.assertCurrent();
loginPage.login("password");
emailUpdatePage.assertCurrent();
emailUpdatePage.changeEmail("test-user2@localhost");
}
}