mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
Integrate passkeys with the organization authenticator
Closes #40022 Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
parent
5af775db0f
commit
a9202d48e2
@ -90,6 +90,12 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
|
||||
String rememberMeUsername = AuthenticationManager.getRememberMeUsername(context.getSession());
|
||||
|
||||
if (context.getUser() != null) {
|
||||
if (alreadyAuthenticatedUsingPasswordlessCredential(context)) {
|
||||
// if already authenticated using passwordless webauthn just success
|
||||
context.success();
|
||||
return;
|
||||
}
|
||||
|
||||
LoginFormsProvider form = context.form();
|
||||
form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, true);
|
||||
form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);
|
||||
|
||||
@ -35,9 +35,12 @@ import java.util.stream.Stream;
|
||||
import jakarta.ws.rs.core.MultivaluedHashMap;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.keycloak.WebAuthnConstants;
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.AuthenticationFlowError;
|
||||
import org.keycloak.authentication.FlowStatus;
|
||||
import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticator;
|
||||
import org.keycloak.authentication.authenticators.browser.WebAuthnConditionalUIAuthenticator;
|
||||
import org.keycloak.email.freemarker.beans.ProfileBean;
|
||||
import org.keycloak.forms.login.LoginFormsProvider;
|
||||
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;
|
||||
@ -53,7 +56,6 @@ import org.keycloak.models.OrganizationModel.IdentityProviderRedirectMode;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.utils.FormMessage;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.organization.OrganizationProvider;
|
||||
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareAuthenticationContextBean;
|
||||
import org.keycloak.organization.forms.login.freemarker.model.OrganizationAwareIdentityProviderBean;
|
||||
@ -68,9 +70,11 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||
private static final String LOGIN_HINT_ALREADY_HANDLED = "loginHintAlreadyHandled";
|
||||
|
||||
private final KeycloakSession session;
|
||||
private final WebAuthnConditionalUIAuthenticator webauthnAuth;
|
||||
|
||||
public OrganizationAuthenticator(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.webauthnAuth = new WebAuthnConditionalUIAuthenticator(session, (context) -> createLoginForm(context));
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -108,6 +112,17 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||
public void action(AuthenticationFlowContext context) {
|
||||
HttpRequest request = context.getHttpRequest();
|
||||
MultivaluedMap<String, String> parameters = request.getDecodedFormParameters();
|
||||
|
||||
// check if it's a webauthn submission and perform the webauth login
|
||||
if (webauthnAuth.isPasskeysEnabled() && (parameters.containsKey(WebAuthnConstants.AUTHENTICATOR_DATA)
|
||||
|| parameters.containsKey(WebAuthnConstants.ERROR))) {
|
||||
webauthnAuth.action(context);
|
||||
if (FlowStatus.SUCCESS != context.getStatus()) {
|
||||
// if failure doing webauthn authentication return error; continue if success checking organizations
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String username = parameters.getFirst(UserModel.USERNAME);
|
||||
RealmModel realm = context.getRealm();
|
||||
UserModel user = resolveUser(context, username);
|
||||
@ -341,25 +356,12 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||
UserModel user = context.getUser();
|
||||
|
||||
if (user == null) {
|
||||
// the default challenge won't show any broker but just the identity-first login page and the option to try a different authentication mechanism
|
||||
LoginFormsProvider form = context.form()
|
||||
.setAttributeMapper(attributes -> {
|
||||
attributes.computeIfPresent("social",
|
||||
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, false, true)
|
||||
);
|
||||
attributes.computeIfPresent("auth",
|
||||
(key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false)
|
||||
);
|
||||
return attributes;
|
||||
});
|
||||
|
||||
String loginHint = authenticationSession.getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM);
|
||||
|
||||
if (loginHint != null) {
|
||||
form.setFormData(new MultivaluedHashMap<>(Map.of(UserModel.USERNAME, loginHint)));
|
||||
// setup webauthn data when the user is not already selected
|
||||
if (webauthnAuth.isPasskeysEnabled()) {
|
||||
webauthnAuth.fillContextForm(context);
|
||||
}
|
||||
|
||||
context.challenge(form.createLoginUsername());
|
||||
context.challenge(createLoginForm(context));
|
||||
} else if (isSSOAuthentication(authenticationSession)) {
|
||||
if (shouldUserSelectOrganization(context, user)) {
|
||||
return;
|
||||
@ -373,6 +375,28 @@ public class OrganizationAuthenticator extends IdentityProviderAuthenticator {
|
||||
}
|
||||
}
|
||||
|
||||
private Response createLoginForm(AuthenticationFlowContext context) {
|
||||
// the default challenge won't show any broker but just the identity-first login page and the option to try a different authentication mechanism
|
||||
LoginFormsProvider form = context.form()
|
||||
.setAttributeMapper(attributes -> {
|
||||
attributes.computeIfPresent("social",
|
||||
(key, bean) -> new OrganizationAwareIdentityProviderBean((IdentityProviderBean) bean, false, true)
|
||||
);
|
||||
attributes.computeIfPresent("auth",
|
||||
(key, bean) -> new OrganizationAwareAuthenticationContextBean((AuthenticationContextBean) bean, false)
|
||||
);
|
||||
return attributes;
|
||||
});
|
||||
|
||||
String loginHint = context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM);
|
||||
|
||||
if (loginHint != null) {
|
||||
form.setFormData(new MultivaluedHashMap<>(Map.of(UserModel.USERNAME, loginHint)));
|
||||
}
|
||||
|
||||
return form.createLoginUsername();
|
||||
}
|
||||
|
||||
private boolean hasPublicBrokers(OrganizationModel organization) {
|
||||
return organization.getIdentityProviders().anyMatch(Predicate.not(IdentityProviderModel::isHideOnLogin));
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.keycloak.Config.Scope;
|
||||
import org.keycloak.authentication.Authenticator;
|
||||
@ -28,6 +29,7 @@ import org.keycloak.authentication.authenticators.browser.IdentityProviderAuthen
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.Profile.Feature;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.credential.WebAuthnCredentialModel;
|
||||
import org.keycloak.provider.EnvironmentDependentProviderFactory;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
|
||||
@ -68,4 +70,11 @@ public class OrganizationAuthenticatorFactory extends IdentityProviderAuthentica
|
||||
public List<ProviderConfigProperty> getConfigProperties() {
|
||||
return Collections.singletonList(new ProviderConfigProperty(REQUIRES_USER_MEMBERSHIP, "Requires user membership", "Enforces that users authenticating in the scope of an organization are members. If not a member, the user won't be able to proceed authenticating to the realm", BOOLEAN_TYPE, null));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getOptionalReferenceCategories() {
|
||||
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS)
|
||||
? Collections.singleton(WebAuthnCredentialModel.TYPE_PASSWORDLESS)
|
||||
: super.getOptionalReferenceCategories();
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.admin.AbstractAdminTest;
|
||||
import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.pages.AppPage;
|
||||
import org.keycloak.testsuite.pages.ErrorPage;
|
||||
import org.keycloak.testsuite.pages.InfoPage;
|
||||
import org.keycloak.testsuite.pages.LoginPage;
|
||||
import org.keycloak.testsuite.pages.LogoutConfirmPage;
|
||||
@ -91,6 +92,9 @@ public abstract class AbstractWebAuthnVirtualTest extends AbstractChangeImported
|
||||
@Page
|
||||
protected LoginPage loginPage;
|
||||
|
||||
@Page
|
||||
protected ErrorPage errorPage;
|
||||
|
||||
@Page
|
||||
protected RegisterPage registerPage;
|
||||
|
||||
|
||||
@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Copyright 2021 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.testsuite.webauthn.passwordless;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import org.hamcrest.MatcherAssert;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.WebAuthnConstants;
|
||||
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.models.credential.WebAuthnCredentialModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory;
|
||||
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
|
||||
import org.keycloak.representations.idm.AuthenticatorConfigRepresentation;
|
||||
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testsuite.Assert;
|
||||
import org.keycloak.testsuite.admin.AbstractAdminTest;
|
||||
import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
|
||||
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
|
||||
import org.keycloak.testsuite.util.WaitUtils;
|
||||
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
|
||||
import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions;
|
||||
import org.keycloak.testsuite.webauthn.utils.PropertyRequirement;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.NoSuchElementException;
|
||||
import org.openqa.selenium.firefox.FirefoxDriver;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author rmartinc
|
||||
*/
|
||||
@EnableFeature(value = Profile.Feature.PASSKEYS, skipRestart = true)
|
||||
@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368
|
||||
public class PasskeysOrganizationAuthenticationTest extends AbstractWebAuthnVirtualTest {
|
||||
|
||||
@Override
|
||||
public void addTestRealms(List<RealmRepresentation> testRealms) {
|
||||
RealmRepresentation realmRepresentation = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/webauthn/testrealm-webauthn.json"), RealmRepresentation.class);
|
||||
|
||||
makePasswordlessRequiredActionDefault(realmRepresentation);
|
||||
switchExecutionInBrowserFormToProvider(realmRepresentation, UsernamePasswordFormFactory.PROVIDER_ID);
|
||||
realmRepresentation.setOrganizationsEnabled(Boolean.TRUE);
|
||||
|
||||
OrganizationRepresentation emailOrg = new OrganizationRepresentation();
|
||||
emailOrg.setName("email");
|
||||
emailOrg.setAlias("email");
|
||||
OrganizationDomainRepresentation domainRep = new OrganizationDomainRepresentation();
|
||||
domainRep.setName("email");
|
||||
emailOrg.addDomain(domainRep);
|
||||
realmRepresentation.addOrganization(emailOrg);
|
||||
|
||||
testRealms.add(realmRepresentation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPasswordless() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void webauthnLoginWithDiscoverableKey() throws IOException {
|
||||
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
|
||||
|
||||
// set passwordless policy for discoverable keys
|
||||
try (Closeable c = getWebAuthnRealmUpdater()
|
||||
.setWebAuthnPolicyRpEntityName("localhost")
|
||||
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.YES.getValue())
|
||||
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_REQUIRED)
|
||||
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
|
||||
.update()) {
|
||||
|
||||
checkWebAuthnConfiguration(PropertyRequirement.YES.getValue(), WebAuthnConstants.OPTION_REQUIRED);
|
||||
|
||||
registerDefaultUser();
|
||||
|
||||
UserRepresentation user = userResource().toRepresentation();
|
||||
MatcherAssert.assertThat(user, Matchers.notNullValue());
|
||||
|
||||
logout();
|
||||
events.clear();
|
||||
|
||||
// the user should be automatically logged in using the discoverable key
|
||||
oauth.openLoginForm();
|
||||
WaitUtils.waitForPageToLoad();
|
||||
|
||||
appPage.assertCurrent();
|
||||
|
||||
events.expectLogin()
|
||||
.user(user.getId())
|
||||
.detail(Details.USERNAME, user.getUsername())
|
||||
.detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS)
|
||||
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true")
|
||||
.assertEvent();
|
||||
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void webauthnLoginWithDiscoverableKeyRequiresMembership() throws IOException {
|
||||
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
|
||||
|
||||
// enable organization configuration
|
||||
AuthenticationExecutionInfoRepresentation organizationExec = testRealm().flows().getExecutions("browser-webauthn-conditional-organization").stream()
|
||||
.filter(exec -> OrganizationAuthenticatorFactory.ID.equals(exec.getProviderId()))
|
||||
.findAny()
|
||||
.orElse(null);
|
||||
Assert.assertNotNull("Organization execution not found", organizationExec);
|
||||
|
||||
AuthenticatorConfigRepresentation config = new AuthenticatorConfigRepresentation();
|
||||
config.setAlias(KeycloakModelUtils.generateId());
|
||||
config.getConfig().put(OrganizationAuthenticatorFactory.REQUIRES_USER_MEMBERSHIP, Boolean.TRUE.toString());
|
||||
getCleanup().addAuthenticationConfigId(ApiUtil.getCreatedId(testRealm().flows().newExecutionConfig(organizationExec.getId(), config)));
|
||||
|
||||
// set passwordless policy for discoverable keys
|
||||
try (Closeable c = getWebAuthnRealmUpdater()
|
||||
.setWebAuthnPolicyRpEntityName("localhost")
|
||||
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.YES.getValue())
|
||||
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_REQUIRED)
|
||||
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
|
||||
.update()) {
|
||||
|
||||
checkWebAuthnConfiguration(PropertyRequirement.YES.getValue(), WebAuthnConstants.OPTION_REQUIRED);
|
||||
|
||||
registerDefaultUser();
|
||||
|
||||
UserRepresentation user = userResource().toRepresentation();
|
||||
MatcherAssert.assertThat(user, Matchers.notNullValue());
|
||||
|
||||
logout();
|
||||
events.clear();
|
||||
|
||||
// the user should be automatically logged in using the discoverable key but error because no org
|
||||
oauth.openLoginForm();
|
||||
WaitUtils.waitForPageToLoad();
|
||||
|
||||
errorPage.assertCurrent();
|
||||
MatcherAssert.assertThat(errorPage.getError(), Matchers.containsString("User is not a member of the organization"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void passwordLoginWithNonDiscoverableKey() throws Exception {
|
||||
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
|
||||
|
||||
// set passwordless policy not specified, key will not be discoverable
|
||||
try (Closeable c = getWebAuthnRealmUpdater()
|
||||
.setWebAuthnPolicyRpEntityName("localhost")
|
||||
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.NOT_SPECIFIED.getValue())
|
||||
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_NOT_SPECIFIED)
|
||||
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
|
||||
.update()) {
|
||||
|
||||
registerDefaultUser();
|
||||
|
||||
UserRepresentation user = userResource().toRepresentation();
|
||||
MatcherAssert.assertThat(user, Matchers.notNullValue());
|
||||
|
||||
logout();
|
||||
events.clear();
|
||||
|
||||
// access login page, key is not discoverable so webauthn should be enabled but login should be manual
|
||||
oauth.openLoginForm();
|
||||
WaitUtils.waitForPageToLoad();
|
||||
loginPage.assertCurrent();
|
||||
MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn"));
|
||||
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false));
|
||||
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
|
||||
loginPage.loginUsername(USERNAME);
|
||||
|
||||
// now the passkeys username password page should be presented with username selected and passkeys disabled
|
||||
loginPage.assertCurrent();
|
||||
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
|
||||
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
|
||||
loginPage.login("invalid-password");
|
||||
loginPage.assertCurrent();
|
||||
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
|
||||
events.expect(EventType.LOGIN_ERROR)
|
||||
.error(Errors.INVALID_USER_CREDENTIALS)
|
||||
.user(user.getId())
|
||||
.assertEvent();
|
||||
|
||||
// correct login now
|
||||
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
|
||||
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
|
||||
loginPage.login(getPassword(USERNAME));
|
||||
appPage.assertCurrent();
|
||||
events.expectLogin()
|
||||
.user(user.getId())
|
||||
.detail(Details.USERNAME, "userwebauthn")
|
||||
.detail(Details.CREDENTIAL_TYPE, Matchers.nullValue())
|
||||
.assertEvent();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void passwordLoginWithExternalKey() throws Exception {
|
||||
// use a default resident key which is not shown in conditional UI
|
||||
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions());
|
||||
|
||||
// set passwordless policy for discoverable keys
|
||||
try (Closeable c = getWebAuthnRealmUpdater()
|
||||
.setWebAuthnPolicyRpEntityName("localhost")
|
||||
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.YES.getValue())
|
||||
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_REQUIRED)
|
||||
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
|
||||
.update()) {
|
||||
|
||||
checkWebAuthnConfiguration(PropertyRequirement.YES.getValue(), WebAuthnConstants.OPTION_REQUIRED);
|
||||
|
||||
registerDefaultUser();
|
||||
|
||||
UserRepresentation user = userResource().toRepresentation();
|
||||
MatcherAssert.assertThat(user, Matchers.notNullValue());
|
||||
|
||||
logout();
|
||||
events.clear();
|
||||
|
||||
// open login page, the key is not internal so not opened by default
|
||||
oauth.openLoginForm();
|
||||
WaitUtils.waitForPageToLoad();
|
||||
|
||||
loginPage.assertCurrent();
|
||||
MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn"));
|
||||
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false));
|
||||
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
|
||||
|
||||
// force login using webauthn link
|
||||
webAuthnLoginPage.clickAuthenticate();
|
||||
appPage.assertCurrent();
|
||||
|
||||
events.expectLogin()
|
||||
.user(user.getId())
|
||||
.detail(Details.USERNAME, user.getUsername())
|
||||
.detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS)
|
||||
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true")
|
||||
.assertEvent();
|
||||
logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -31,22 +31,17 @@ import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.models.credential.WebAuthnCredentialModel;
|
||||
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testsuite.Assert;
|
||||
import org.keycloak.testsuite.admin.AbstractAdminTest;
|
||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
|
||||
import org.keycloak.testsuite.pages.SelectOrganizationPage;
|
||||
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
|
||||
import org.keycloak.testsuite.util.WaitUtils;
|
||||
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
|
||||
import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions;
|
||||
import org.keycloak.testsuite.webauthn.utils.PropertyRequirement;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.NoSuchElementException;
|
||||
import org.openqa.selenium.firefox.FirefoxDriver;
|
||||
|
||||
/**
|
||||
@ -207,80 +202,4 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void organizationLoginWithDiscoverableKey() throws Exception {
|
||||
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions());
|
||||
|
||||
// set passwordless policy for discoverable keys
|
||||
try (Closeable c = getWebAuthnRealmUpdater()
|
||||
.setWebAuthnPolicyRpEntityName("localhost")
|
||||
.setWebAuthnPolicyRequireResidentKey(PropertyRequirement.YES.getValue())
|
||||
.setWebAuthnPolicyUserVerificationRequirement(WebAuthnConstants.OPTION_REQUIRED)
|
||||
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
|
||||
.update();
|
||||
Closeable realmUpdate = new RealmAttributeUpdater(testRealm())
|
||||
.setOrganizationsEnabled(true)
|
||||
.update()) {
|
||||
|
||||
OrganizationRepresentation orgRep = createRepresentation("testOrg", "email");
|
||||
testRealm().organizations().create(orgRep);
|
||||
|
||||
checkWebAuthnConfiguration(PropertyRequirement.YES.getValue(), WebAuthnConstants.OPTION_REQUIRED);
|
||||
|
||||
registerDefaultUser();
|
||||
|
||||
UserRepresentation user = userResource().toRepresentation();
|
||||
MatcherAssert.assertThat(user, Matchers.notNullValue());
|
||||
|
||||
logout();
|
||||
events.clear();
|
||||
|
||||
// login using the organization
|
||||
oauth.openLoginForm();
|
||||
WaitUtils.waitForPageToLoad();
|
||||
loginPage.assertCurrent();
|
||||
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false));
|
||||
loginPage.loginUsername(USERNAME);
|
||||
|
||||
// now the passkeys username password page should be presented with username selected and passkeys disabled
|
||||
loginPage.assertCurrent();
|
||||
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
|
||||
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
|
||||
loginPage.login("invalid-password");
|
||||
loginPage.assertCurrent();
|
||||
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
|
||||
events.expect(EventType.LOGIN_ERROR)
|
||||
.error(Errors.INVALID_USER_CREDENTIALS)
|
||||
.user(user.getId())
|
||||
.assertEvent();
|
||||
|
||||
// correct login now
|
||||
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is("userwebauthn"));
|
||||
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
|
||||
loginPage.login(getPassword(USERNAME));
|
||||
appPage.assertCurrent();
|
||||
events.expectLogin()
|
||||
.user(user.getId())
|
||||
.detail(Details.USERNAME, "userwebauthn")
|
||||
.detail(Details.CREDENTIAL_TYPE, Matchers.nullValue())
|
||||
.assertEvent();
|
||||
}
|
||||
}
|
||||
|
||||
private OrganizationRepresentation createRepresentation(String name, String... orgDomains) {
|
||||
OrganizationRepresentation org = new OrganizationRepresentation();
|
||||
org.setName(name);
|
||||
org.setAlias(name);
|
||||
org.setDescription(name + " is a test organization!");
|
||||
|
||||
for (String orgDomain : orgDomains) {
|
||||
OrganizationDomainRepresentation domainRep = new OrganizationDomainRepresentation();
|
||||
domainRep.setName(orgDomain);
|
||||
org.addDomain(domainRep);
|
||||
}
|
||||
|
||||
return org;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user