mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Make all required actions one time action by default
Closes CVE-2026-37982 Closes #49112 Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
@@ -9,4 +9,8 @@ The WebAuthn policy presents the option **Acceptable AAGUIDs** to restrict the a
|
||||
|
||||
Since this release, when this option is set up, the attestation is required to be present and signed with a valid certificate for the {project_name} trust-store. The `None` attestation format is explicitly not permitted. Previously, there were some corner cases in which a self attestation was accepted. The change is expected to be harmless, but maybe there are combinations of authenticators and WebAuthn policies that can present issues.
|
||||
|
||||
See chapter link:{adminguide_link}#_webauthn-policy[Managing policy] in the {adminguide_name} for more information.
|
||||
See chapter link:{adminguide_link}#_webauthn-policy[Managing policy] in the {adminguide_name} for more information.
|
||||
|
||||
=== Required actions are one-time actions by default
|
||||
|
||||
Since this release, all _Required Actions_ are now one-time actions by default to ensure they cannot be reused once completed. This change impacts the email action tokens created by the `execute-actions-email` endpoint, which is executed via the **Credential reset** button in the Admin console. Any action token generated through this endpoint (with whatever reset actions) is now strictly one-time use, ensuring that once a user completes the required steps, the token is immediately invalidated.
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ public interface RequiredActionFactory extends ProviderFactory<RequiredActionPro
|
||||
* @return
|
||||
*/
|
||||
default boolean isOneTimeAction() {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -181,11 +181,6 @@ public class DeleteAccount implements RequiredActionProvider, RequiredActionFact
|
||||
return InitiatedActionSupport.SUPPORTED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOneTimeAction() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxAuthAge(KeycloakSession session) {
|
||||
return 0;
|
||||
|
||||
-5
@@ -90,11 +90,6 @@ public class RecoveryAuthnCodesAction implements RequiredActionProvider, Require
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOneTimeAction() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InitiatedActionSupport initiatedActionSupport() {
|
||||
return InitiatedActionSupport.SUPPORTED;
|
||||
|
||||
@@ -198,11 +198,6 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
|
||||
return UserModel.RequiredAction.UPDATE_PASSWORD.name();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOneTimeAction() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxAuthAge(KeycloakSession session) {
|
||||
if (session == null) {
|
||||
|
||||
@@ -277,9 +277,4 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
|
||||
public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
|
||||
return OTPCredentialModel.TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOneTimeAction() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.ErrorRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testframework.annotations.InjectClient;
|
||||
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
|
||||
@@ -50,6 +51,7 @@ import org.keycloak.testframework.ui.page.ErrorPage;
|
||||
import org.keycloak.testframework.ui.page.InfoPage;
|
||||
import org.keycloak.testframework.ui.page.LoginPasswordUpdatePage;
|
||||
import org.keycloak.testframework.ui.page.ProceedPage;
|
||||
import org.keycloak.testframework.ui.page.TermsAndConditionsPage;
|
||||
import org.keycloak.testframework.util.ApiUtil;
|
||||
import org.keycloak.tests.utils.MailUtils;
|
||||
import org.keycloak.tests.utils.admin.AdminEventPaths;
|
||||
@@ -88,6 +90,9 @@ public class UserEmailTest extends AbstractUserTest {
|
||||
@InjectPage
|
||||
LoginPasswordUpdatePage passwordUpdatePage;
|
||||
|
||||
@InjectPage
|
||||
TermsAndConditionsPage termsPage;
|
||||
|
||||
@InjectPage
|
||||
InfoPage infoPage;
|
||||
|
||||
@@ -205,6 +210,57 @@ public class UserEmailTest extends AbstractUserTest {
|
||||
driver.open(link);
|
||||
|
||||
errorPage.assertCurrent();
|
||||
assertEquals("Action expired. Please continue with login now.", errorPage.getError());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sendTermsAndConditionsEmailSuccess() throws IOException {
|
||||
RequiredActionProviderRepresentation termsAndConds = managedRealm.admin().flows().getRequiredAction(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name());
|
||||
termsAndConds.setEnabled(true);
|
||||
managedRealm.admin().flows().updateRequiredAction(termsAndConds.getAlias(), termsAndConds);
|
||||
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.UPDATE,
|
||||
AdminEventPaths.authRequiredActionPath(termsAndConds.getAlias()), termsAndConds, ResourceType.REQUIRED_ACTION);
|
||||
termsAndConds.setEnabled(false);
|
||||
managedRealm.cleanup().add(r -> r.flows().updateRequiredAction(termsAndConds.getAlias(), termsAndConds));
|
||||
|
||||
UserRepresentation userRep = UserBuilder.create()
|
||||
.username("user1").name("User", "One").email("user1@test.com").build();
|
||||
|
||||
String id = createUser(userRep);
|
||||
|
||||
UserResource user = managedRealm.admin().users().get(id);
|
||||
List<String> actions = new LinkedList<>();
|
||||
actions.add(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name());
|
||||
user.executeActionsEmail(actions);
|
||||
AdminEventAssertion.assertEvent(adminEvents.poll(), OperationType.ACTION, AdminEventPaths.userResourcePath(id) + "/execute-actions-email", ResourceType.USER);
|
||||
|
||||
Assertions.assertEquals(1, mailServer.getReceivedMessages().length);
|
||||
|
||||
MimeMessage message = mailServer.getReceivedMessages()[0];
|
||||
|
||||
MailUtils.EmailBody body = MailUtils.getBody(message);
|
||||
|
||||
assertTrue(body.getText().contains("Terms and Conditions"));
|
||||
assertTrue(body.getText().contains("your Default account"));
|
||||
assertTrue(body.getText().contains("This link will expire within 12 hours"));
|
||||
|
||||
String link = MailUtils.getPasswordResetEmailLink(body);
|
||||
|
||||
driver.open(link);
|
||||
|
||||
proceedPage.assertCurrent();
|
||||
assertThat(proceedPage.getInfo(), Matchers.containsString("Terms and Conditions"));
|
||||
proceedPage.clickProceedLink();
|
||||
termsPage.assertCurrent();
|
||||
|
||||
termsPage.acceptTerms();
|
||||
assertThat(driver.getCurrentUrl(), Matchers.containsString("client_id=" + Constants.ACCOUNT_MANAGEMENT_CLIENT_ID));
|
||||
assertEquals("Your account has been updated.", infoPage.getInfo());
|
||||
|
||||
driver.open(link);
|
||||
|
||||
errorPage.assertCurrent();
|
||||
assertEquals("Action expired. Please continue with login now.", errorPage.getError());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user