diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java index 0d4e80a14ce5..d458a8ef47fb 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java @@ -17,7 +17,6 @@ package org.keycloak.authentication.authenticators.browser; -import org.jboss.logging.Logger; import org.keycloak.authentication.AbstractFormAuthenticator; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowError; @@ -37,6 +36,7 @@ import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; +import org.keycloak.sessions.AuthenticationSessionModel; import static org.keycloak.authentication.authenticators.util.AuthenticatorUtils.getDisabledByBruteForceEventError; import static org.keycloak.services.validation.Validation.FIELD_PASSWORD; @@ -48,10 +48,16 @@ */ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuthenticator { - private static final Logger logger = Logger.getLogger(AbstractUsernameFormAuthenticator.class); - - public static final String REGISTRATION_FORM_ACTION = "registration_form"; public static final String ATTEMPTED_USERNAME = "ATTEMPTED_USERNAME"; + + /** + * An authentication session not to indicate that the username field should be hidden. + * This note is usually set together with {@link #ATTEMPTED_USERNAME} to indicated that the + * user can restart the flow by choosing a different username. + * It should be set by authenticators that happen before this authenticator in the flow so that the original intent + * is kept when this authenticator is executed on subsequent requests. + */ + public static final String USERNAME_HIDDEN = "USERNAME_HIDDEN"; public static final String SESSION_INVALID = "SESSION_INVALID"; // Flag is true if user was already set in the authContext before this authenticator was triggered. In this case we skip clearing of the user after unsuccessful password authentication @@ -69,6 +75,14 @@ protected Response challenge(AuthenticationFlowContext context, String error) { protected Response challenge(AuthenticationFlowContext context, String error, String field) { LoginFormsProvider form = context.form() .setExecution(context.getExecution().getId()); + + AuthenticationSessionModel authenticationSession = context.getAuthenticationSession(); + + if (Boolean.parseBoolean(authenticationSession.getAuthNote(USERNAME_HIDDEN))) { + // if username is hidden, shown errors in the password field instead + field = FIELD_PASSWORD; + } + if (error != null) { if (field != null) { form.addError(new FormMessage(field, error)); diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index 179f43aeb43a..5fa9e9abeaec 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -505,6 +505,9 @@ protected void createCommonAttributes(Theme theme, Locale locale, Properties mes attributes.put("url", new UrlBean(realm, theme, baseUri, this.actionUri)); attributes.put("requiredActionUrl", new RequiredActionUrlFormatterMethod(realm, baseUri)); attributes.put("auth", new AuthenticationContextBean(context, page)); + if (authenticationSession != null && Boolean.parseBoolean(authenticationSession.getAuthNote(AbstractUsernameFormAuthenticator.USERNAME_HIDDEN))) { + attributes.put(LoginFormsProvider.USERNAME_HIDDEN, Boolean.TRUE.toString()); + } setAttribute(Constants.EXECUTION, execution); if (realm.isInternationalizationEnabled()) { diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/AuthenticationContextBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/AuthenticationContextBean.java index cd43f3384a9c..2b0fff595e87 100644 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/AuthenticationContextBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/AuthenticationContextBean.java @@ -25,6 +25,7 @@ import org.keycloak.authentication.AuthenticationSelectionOption; import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; import org.keycloak.forms.login.LoginFormsPages; +import org.keycloak.sessions.AuthenticationSessionModel; /** * @author Marek Posolda @@ -49,7 +50,17 @@ public boolean showTryAnotherWayLink() { public boolean showUsername() { - return context != null && context.getUser() != null && context.getAuthenticationSession() != null && page!=LoginFormsPages.ERROR; + if (context == null) { + return false; + } + + AuthenticationSessionModel authenticationSession = context.getAuthenticationSession(); + + if (Boolean.parseBoolean(authenticationSession.getAuthNote(AbstractUsernameFormAuthenticator.USERNAME_HIDDEN))) { + return getAttemptedUsername() != null; + } + + return context.getUser() != null && authenticationSession != null && page!=LoginFormsPages.ERROR; } public boolean showResetCredentials() { diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java index 24a270d73d33..6a93b29b3465 100644 --- a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java +++ b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java @@ -422,18 +422,7 @@ private void attempted(AuthenticationFlowContext context, String username) { if (username != null) { authenticationSession.setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username); - LoginFormsProvider form = context.form(); - - form.setAttributeMapper(attributes -> { - AuthenticationContextBean auth = (AuthenticationContextBean) attributes.get("auth"); - - if (auth != null) { - attributes.put("auth", new OrganizationAwareAuthenticationContextBean(auth, true, username)); - attributes.put(LoginFormsProvider.USERNAME_HIDDEN, true); - } - - return attributes; - }); + authenticationSession.setAuthNote(AbstractUsernameFormAuthenticator.USERNAME_HIDDEN, Boolean.TRUE.toString()); } context.attempted(); diff --git a/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareAuthenticationContextBean.java b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareAuthenticationContextBean.java index 7835302a36f7..352f7dc1a328 100644 --- a/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareAuthenticationContextBean.java +++ b/services/src/main/java/org/keycloak/organization/forms/login/freemarker/model/OrganizationAwareAuthenticationContextBean.java @@ -26,17 +26,11 @@ public class OrganizationAwareAuthenticationContextBean extends AuthenticationCo private final AuthenticationContextBean delegate; private final boolean showTryAnotherWayLink; - private final String username; public OrganizationAwareAuthenticationContextBean(AuthenticationContextBean delegate, boolean showTryAnotherWayLink) { - this(delegate, showTryAnotherWayLink, null); - } - - public OrganizationAwareAuthenticationContextBean(AuthenticationContextBean delegate, boolean showTryAnotherWayLink, String username) { super(null, null); this.delegate = delegate; this.showTryAnotherWayLink = showTryAnotherWayLink; - this.username = username; } @Override @@ -52,7 +46,7 @@ public boolean showTryAnotherWayLink() { } public boolean showUsername() { - return username != null || delegate.showUsername(); + return delegate.showUsername(); } public boolean showResetCredentials() { @@ -60,9 +54,6 @@ public boolean showResetCredentials() { } public String getAttemptedUsername() { - if (username == null) { - return delegate.getAttemptedUsername(); - } - return username; + return delegate.getAttemptedUsername(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java index 61022bfd44ea..d20e8d5d55de 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java @@ -120,6 +120,8 @@ public static void assertAttemptedUsernameAvailability(WebDriver driver, boolean try { driver.findElement(By.id("kc-attempted-username")); Assert.assertTrue(expectedAvailability); + // make sure the username field is not shown if the attempted username field is present + Assert.assertTrue(driver.findElements(By.id("username")).isEmpty()); } catch (NoSuchElementException nse) { Assert.assertFalse(expectedAvailability); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/authentication/OrganizationAuthenticationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/authentication/OrganizationAuthenticationTest.java index 7c6c78c191ff..21cd20e7892a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/authentication/OrganizationAuthenticationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/authentication/OrganizationAuthenticationTest.java @@ -295,6 +295,23 @@ public void testRestartLogin() { Assert.assertFalse(loginPage.isPasswordInputPresent()); } + @Test + public void testAttemptedUsernameKeptAfterPasswordFailures() { + testRealm().organizations().get(createOrganization().getId()); + + openIdentityFirstLoginPage("user@noorg.org", false, null, false, false); + + // check if the login page is shown + loginPage.assertAttemptedUsernameAvailability(true); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + + for (int i = 0; i < 3; i++) { + loginPage.login("wrong-password"); + loginPage.assertAttemptedUsernameAvailability(true); + Assert.assertTrue(loginPage.isPasswordInputPresent()); + } + } + private void runOnServer(RunOnServer function) { testingClient.server(bc.consumerRealmName()).run(function); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/broker/AbstractBrokerSelfRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/broker/AbstractBrokerSelfRegistrationTest.java index 5db1ff163b08..f63b9f46a05c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/broker/AbstractBrokerSelfRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/organization/broker/AbstractBrokerSelfRegistrationTest.java @@ -227,7 +227,7 @@ public void testRealmLevelBrokersAvailableIfEmailDoesNotMatchOrganization() { driver.navigate().refresh(); - Assert.assertTrue(loginPage.isUsernameInputPresent()); + loginPage.assertAttemptedUsernameAvailability(true); Assert.assertTrue(loginPage.isPasswordInputPresent()); Assert.assertTrue(loginPage.isSocialButtonPresent(idp.getAlias())); Assert.assertFalse(loginPage.isSocialButtonPresent(bc.getIDPAlias()));