mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-24 10:48:52 -05:00
Keycloak user enumeration via identity-first login
Closes #47619 Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
@@ -2,10 +2,12 @@ import { expect, test } from "@playwright/test";
|
||||
import groupsRealm from "./realms/groups-realm.json" with { type: "json" };
|
||||
import { login } from "./support/actions.ts";
|
||||
import { createTestBed } from "./support/testbed.ts";
|
||||
import { waitForRealmReady } from "./support/test-utils.ts";
|
||||
|
||||
test.describe("Groups", () => {
|
||||
test("lists groups", async ({ page }) => {
|
||||
await using testBed = await createTestBed(groupsRealm);
|
||||
await waitForRealmReady();
|
||||
|
||||
await login(page, testBed.realm);
|
||||
await page.getByTestId("groups").click();
|
||||
@@ -14,6 +16,7 @@ test.describe("Groups", () => {
|
||||
|
||||
test("lists direct and indirect groups", async ({ page }) => {
|
||||
await using testBed = await createTestBed(groupsRealm);
|
||||
await waitForRealmReady();
|
||||
|
||||
await login(page, testBed.realm, "alice", "alice");
|
||||
await page.getByTestId("groups").click();
|
||||
|
||||
+1
-5
@@ -261,11 +261,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
|
||||
}
|
||||
|
||||
protected String getDefaultChallengeMessage(AuthenticationFlowContext context) {
|
||||
if (isUserAlreadySetBeforeUsernamePasswordAuth(context)) {
|
||||
return Messages.INVALID_PASSWORD;
|
||||
} else {
|
||||
return Messages.INVALID_USER;
|
||||
}
|
||||
return Messages.INVALID_USER;
|
||||
}
|
||||
|
||||
protected boolean isUserAlreadySetBeforeUsernamePasswordAuth(AuthenticationFlowContext context) {
|
||||
|
||||
@@ -48,6 +48,14 @@ public class PasswordPage extends AbstractLoginPage {
|
||||
return passwordInput.getAttribute("value");
|
||||
}
|
||||
|
||||
public String getPasswordError() {
|
||||
try {
|
||||
return passwordError.getText();
|
||||
} catch (NoSuchElementException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
try {
|
||||
return loginErrorMessage.getText();
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.tests.login;
|
||||
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.testframework.annotations.InjectRealm;
|
||||
import org.keycloak.testframework.annotations.InjectUser;
|
||||
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
|
||||
import org.keycloak.testframework.injection.LifeCycle;
|
||||
import org.keycloak.testframework.oauth.OAuthClient;
|
||||
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
|
||||
import org.keycloak.testframework.realm.ManagedRealm;
|
||||
import org.keycloak.testframework.realm.ManagedUser;
|
||||
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
|
||||
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
|
||||
import org.keycloak.testframework.ui.annotations.InjectPage;
|
||||
import org.keycloak.testframework.ui.page.LoginUsernamePage;
|
||||
import org.keycloak.testframework.ui.page.PasswordPage;
|
||||
import org.keycloak.tests.common.BasicUserConfig;
|
||||
import org.keycloak.testsuite.util.FlowUtil;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Verifies that login error messages do not leak whether a username is valid,
|
||||
* preventing user enumeration attacks.
|
||||
*
|
||||
* In an identity-first flow (UsernameForm → UsernamePasswordForm), after the
|
||||
* username step resolves the user, UsernamePasswordForm runs with
|
||||
* USER_SET_BEFORE_USERNAME_PASSWORD_AUTH=true. A wrong password must show
|
||||
* a generic "Invalid username or password." error, not "Invalid password."
|
||||
* which would confirm the user exists.
|
||||
*/
|
||||
@KeycloakIntegrationTest
|
||||
public class LoginErrorMessageTest {
|
||||
|
||||
private static final String IDENTITY_FIRST_FLOW = "identity-first-browser";
|
||||
|
||||
@InjectRealm(lifecycle = LifeCycle.METHOD)
|
||||
ManagedRealm realm;
|
||||
|
||||
@InjectUser(config = BasicUserConfig.class)
|
||||
ManagedUser user;
|
||||
|
||||
@InjectOAuthClient
|
||||
OAuthClient oauth;
|
||||
|
||||
@InjectPage
|
||||
LoginUsernamePage usernamePage;
|
||||
|
||||
@InjectPage
|
||||
PasswordPage passwordPage;
|
||||
|
||||
@InjectRunOnServer
|
||||
RunOnServerClient runOnServer;
|
||||
|
||||
@Test
|
||||
public void testWrongPasswordShowsGenericErrorWhenUserPreEstablished() {
|
||||
configureIdentityFirstFlow();
|
||||
|
||||
// Step 1: enter valid username on the username-only page
|
||||
oauth.openLoginForm();
|
||||
usernamePage.assertCurrent();
|
||||
usernamePage.fillLoginWithUsernameOnly("basic-user");
|
||||
usernamePage.submit();
|
||||
|
||||
// Step 2: UsernamePasswordForm renders with username hidden (user was pre-set).
|
||||
// Use PasswordPage to interact with the password-only form.
|
||||
passwordPage.fillPassword("wrong-password");
|
||||
passwordPage.submit();
|
||||
|
||||
// The error must be generic "Invalid username or password." — not
|
||||
// "Invalid password." which would confirm the username is valid.
|
||||
assertEquals("Invalid username or password.", passwordPage.getPasswordError());
|
||||
}
|
||||
|
||||
private void configureIdentityFirstFlow() {
|
||||
runOnServer.run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(IDENTITY_FIRST_FLOW));
|
||||
runOnServer.run(session -> FlowUtil.inCurrentRealm(session)
|
||||
.selectFlow(IDENTITY_FIRST_FLOW)
|
||||
.inForms(forms -> forms
|
||||
.clear()
|
||||
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, "auth-username-form")
|
||||
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, "auth-username-password-form")
|
||||
)
|
||||
.defineAsBrowserFlow()
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -149,7 +149,7 @@ public class ReAuthenticationTest extends AbstractChangeImportedUserPasswordsTes
|
||||
// Try bad password and assert things still hidden
|
||||
loginPage.login("bad-password");
|
||||
loginPage.assertCurrent();
|
||||
Assert.assertEquals("Invalid password.", loginPage.getInputError());
|
||||
Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
|
||||
assertUsernameFieldAndOtherFields(false);
|
||||
assertInfoMessageAboutReAuthenticate(false);
|
||||
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@ public class OrganizationAuthenticationTest extends AbstractOrganizationTest {
|
||||
loginPage.loginUsername(duplicatedUser.getEmail());
|
||||
loginPage.clickSignIn();
|
||||
loginPage.login(duplicatedUser.getEmail());
|
||||
assertThat(loginPage.getInputError(), is("Invalid password."));
|
||||
assertThat(loginPage.getInputError(), is("Invalid username or password."));
|
||||
|
||||
// trying to authenticate to the account that has the email as username is ok
|
||||
oauth.loginForm().open();
|
||||
|
||||
+1
-1
@@ -215,7 +215,7 @@ public class PasskeysOrganizationAuthenticationTest extends AbstractWebAuthnVirt
|
||||
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
|
||||
loginPage.login("invalid-password");
|
||||
loginPage.assertCurrent();
|
||||
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
|
||||
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid username or password."));
|
||||
events.expect(EventType.LOGIN_ERROR)
|
||||
.error(Errors.INVALID_USER_CREDENTIALS)
|
||||
.user(user.getId())
|
||||
|
||||
+2
-2
@@ -363,7 +363,7 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
|
||||
|
||||
// incorrect password (password of different user)
|
||||
loginPage.login(getPassword("test-user@localhost"));
|
||||
Assert.assertEquals("Invalid password.", loginPage.getInputError());
|
||||
Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
|
||||
|
||||
// Check that passkeys elements still available for this user
|
||||
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
|
||||
@@ -390,7 +390,7 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
|
||||
|
||||
// incorrect password (password of different user)
|
||||
loginPage.login(getPassword("test-user@localhost"));
|
||||
Assert.assertEquals("Invalid password.", loginPage.getInputError());
|
||||
Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
|
||||
|
||||
events.clear();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user