diff --git a/services/src/main/java/org/keycloak/WebAuthnConstants.java b/services/src/main/java/org/keycloak/WebAuthnConstants.java index d0ac2da721..138b329f25 100644 --- a/services/src/main/java/org/keycloak/WebAuthnConstants.java +++ b/services/src/main/java/org/keycloak/WebAuthnConstants.java @@ -19,51 +19,51 @@ package org.keycloak; public interface WebAuthnConstants { // Interface binded by FreeMarker template between UA and RP - final String USER_ID = "userid"; - final String USER_NAME = "username"; - final String CHALLENGE = "challenge"; - final String ORIGIN = "origin"; - final String ERROR = "error"; - final String PUBLIC_KEY_CREDENTIAL_ID= "publicKeyCredentialId"; - final String CREDENTIAL_ID = "credentialId"; - final String CLIENT_DATA_JSON = "clientDataJSON"; - final String AUTHENTICATOR_DATA = "authenticatorData"; - final String SIGNATURE = "signature"; - final String USER_HANDLE = "userHandle"; - final String ATTESTATION_OBJECT= "attestationObject"; - final String AUTHENTICATOR_LABEL = "authenticatorLabel"; - final String RP_ENTITY_NAME = "rpEntityName"; - final String SIGNATURE_ALGORITHMS = "signatureAlgorithms"; - final String RP_ID = "rpId"; - final String ATTESTATION_CONVEYANCE_PREFERENCE = "attestationConveyancePreference"; - final String AUTHENTICATOR_ATTACHMENT = "authenticatorAttachment"; - final String REQUIRE_RESIDENT_KEY = "requireResidentKey"; - final String USER_VERIFICATION_REQUIREMENT = "userVerificationRequirement"; - final String CREATE_TIMEOUT = "createTimeout"; - final String EXCLUDE_CREDENTIAL_IDS = "excludeCredentialIds"; - final String ALLOWED_AUTHENTICATORS = "authenticators"; - final String IS_USER_IDENTIFIED = "isUserIdentified"; - final String USER_VERIFICATION = "userVerification"; - final String IS_SET_RETRY = "isSetRetry"; - + String USER_ID = "userid"; + String USER_NAME = "username"; + String CHALLENGE = "challenge"; + String ORIGIN = "origin"; + String ERROR = "error"; + String PUBLIC_KEY_CREDENTIAL_ID = "publicKeyCredentialId"; + String CREDENTIAL_ID = "credentialId"; + String CLIENT_DATA_JSON = "clientDataJSON"; + String AUTHENTICATOR_DATA = "authenticatorData"; + String SIGNATURE = "signature"; + String USER_HANDLE = "userHandle"; + String ATTESTATION_OBJECT = "attestationObject"; + String AUTHENTICATOR_LABEL = "authenticatorLabel"; + String RP_ENTITY_NAME = "rpEntityName"; + String SIGNATURE_ALGORITHMS = "signatureAlgorithms"; + String RP_ID = "rpId"; + String ATTESTATION_CONVEYANCE_PREFERENCE = "attestationConveyancePreference"; + String AUTHENTICATOR_ATTACHMENT = "authenticatorAttachment"; + String REQUIRE_RESIDENT_KEY = "requireResidentKey"; + String USER_VERIFICATION_REQUIREMENT = "userVerificationRequirement"; + String CREATE_TIMEOUT = "createTimeout"; + String EXCLUDE_CREDENTIAL_IDS = "excludeCredentialIds"; + String ALLOWED_AUTHENTICATORS = "authenticators"; + String IS_USER_IDENTIFIED = "isUserIdentified"; + String USER_VERIFICATION = "userVerification"; + String IS_SET_RETRY = "isSetRetry"; + String SHOULD_DISPLAY_AUTHENTICATORS = "shouldDisplayAuthenticators"; // Event key for credential id generated by navigator.credentials.create() - final String PUBKEY_CRED_ID_ATTR = "public_key_credential_id"; + String PUBKEY_CRED_ID_ATTR = "public_key_credential_id"; // Event key for Public Key Credential's user-editable metadata - final String PUBKEY_CRED_LABEL_ATTR = "public_key_credential_label"; + String PUBKEY_CRED_LABEL_ATTR = "public_key_credential_label"; // Event key for Public Key Credential's AAGUID - final String PUBKEY_CRED_AAGUID_ATTR = "public_key_credential_aaguid"; + String PUBKEY_CRED_AAGUID_ATTR = "public_key_credential_aaguid"; // key for storing onto AuthenticationSessionModel's Attribute challenge generated by RP(keycloak) - final String AUTH_CHALLENGE_NOTE = "WEBAUTH_CHALLENGE"; + String AUTH_CHALLENGE_NOTE = "WEBAUTH_CHALLENGE"; // option values on WebAuth API - final String OPTION_REQUIRED = "required"; - final String OPTION_PREFERED = "preferred"; - final String OPTION_DISCOURAGED = "discouraged"; - final String OPTION_NOT_SPECIFIED = ""; - + String OPTION_REQUIRED = "required"; + String OPTION_PREFERED = "preferred"; + String OPTION_DISCOURAGED = "discouraged"; + String OPTION_NOT_SPECIFIED = ""; + } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java index a7512c0733..e0e1f043e3 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticator.java @@ -104,6 +104,7 @@ public class WebAuthnAuthenticator implements Authenticator, CredentialValidator // read options from policy String userVerificationRequirement = policy.getUserVerificationRequirement(); form.setAttribute(WebAuthnConstants.USER_VERIFICATION, userVerificationRequirement); + form.setAttribute(WebAuthnConstants.SHOULD_DISPLAY_AUTHENTICATORS, shouldDisplayAuthenticators(context)); context.challenge(form.createLoginWebAuthn()); } @@ -123,6 +124,9 @@ public class WebAuthnAuthenticator implements Authenticator, CredentialValidator return WebAuthnCredentialModel.TYPE_TWOFACTOR; } + protected boolean shouldDisplayAuthenticators(AuthenticationFlowContext context) { + return context.getUser() != null; + } public void action(AuthenticationFlowContext context) { MultivaluedMap params = context.getHttpRequest().getDecodedFormParameters(); @@ -309,7 +313,7 @@ public class WebAuthnAuthenticator implements Authenticator, CredentialValidator } private Response createErrorResponse(AuthenticationFlowContext context, final String errorCase) { - LoginFormsProvider provider = context.form().setError(errorCase); + LoginFormsProvider provider = context.form().setError(errorCase, ""); UserModel user = context.getUser(); if (user != null) { WebAuthnAuthenticatorsBean authenticators = new WebAuthnAuthenticatorsBean(context.getSession(), context.getRealm(), user, getCredentialType()); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticator.java index 9780ffcecd..4d60fa862d 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticator.java @@ -55,6 +55,11 @@ public class WebAuthnPasswordlessAuthenticator extends WebAuthnAuthenticator { return WebAuthnCredentialModel.TYPE_PASSWORDLESS; } + @Override + protected boolean shouldDisplayAuthenticators(AuthenticationFlowContext context){ + return false; + } + @Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { // ask the user to do required action to register webauthn authenticator diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/WebAuthnAuthenticatorsBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/WebAuthnAuthenticatorsBean.java index eda8ff6f57..c6e385462b 100644 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/WebAuthnAuthenticatorsBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/WebAuthnAuthenticatorsBean.java @@ -24,8 +24,10 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.theme.DateTimeFormatterUtil; public class WebAuthnAuthenticatorsBean { + private List authenticators = new LinkedList(); public WebAuthnAuthenticatorsBean(KeycloakSession session, RealmModel realm, UserModel user, String credentialType) { @@ -34,8 +36,9 @@ public class WebAuthnAuthenticatorsBean { .map(WebAuthnCredentialModel::createFromCredentialModel) .map(webAuthnCredential -> { String credentialId = Base64Url.encodeBase64ToBase64Url(webAuthnCredential.getWebAuthnCredentialData().getCredentialId()); - String label = (webAuthnCredential.getUserLabel()==null || webAuthnCredential.getUserLabel().isEmpty()) ? "label missing" : webAuthnCredential.getUserLabel(); - return new WebAuthnAuthenticatorBean(credentialId, label); + String label = (webAuthnCredential.getUserLabel() == null || webAuthnCredential.getUserLabel().isEmpty()) ? "label missing" : webAuthnCredential.getUserLabel(); + String createdAt = DateTimeFormatterUtil.getDateTimeFromMillis(webAuthnCredential.getCreatedDate(), session.getContext().resolveLocale(user)); + return new WebAuthnAuthenticatorBean(credentialId, label, createdAt); }).collect(Collectors.toList()); } @@ -46,10 +49,12 @@ public class WebAuthnAuthenticatorsBean { public static class WebAuthnAuthenticatorBean { private final String credentialId; private final String label; + private final String createdAt; - public WebAuthnAuthenticatorBean(String credentialId, String label) { + public WebAuthnAuthenticatorBean(String credentialId, String label, String createdAt) { this.credentialId = credentialId; this.label = label; + this.createdAt = createdAt; } public String getCredentialId() { @@ -59,5 +64,9 @@ public class WebAuthnAuthenticatorsBean { public String getLabel() { return this.label; } + + public String getCreatedAt() { + return this.createdAt; + } } } diff --git a/services/src/main/java/org/keycloak/theme/DateTimeFormatterUtil.java b/services/src/main/java/org/keycloak/theme/DateTimeFormatterUtil.java new file mode 100644 index 0000000000..57790aec61 --- /dev/null +++ b/services/src/main/java/org/keycloak/theme/DateTimeFormatterUtil.java @@ -0,0 +1,88 @@ +/* + * 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.theme; + +import org.jboss.logging.Logger; + +import java.text.DateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Optional; + +/** + * Util class for localized date and time representation + * + * @author Martin Bartos + */ +public class DateTimeFormatterUtil { + private static final Logger log = Logger.getLogger(DateTimeFormatterUtil.class); + + public static String getDateTimeFromMillis(long millis) { + return getDateTimeFromMillis(millis, Locale.ENGLISH); + } + + public static String getDateTimeFromMillis(long millis, String locale) { + return getDateTimeFromMillis(millis, getLocaleFromString(locale)); + } + + public static String getDateTimeFromMillis(long millis, Locale locale) { + return getDateTimeFromMillis(millis, getDefaultDateFormat(locale)); + } + + /** + * Get string representation of localized date and time + * + * @param millis number of milliseconds passed since January 1, 1970, 00:00:00 GMT + * @param dateFormat format of date and time. See {@link DateFormat} + * @return string representation + */ + public static String getDateTimeFromMillis(long millis, DateFormat dateFormat) { + if (dateFormat == null) return null; + return dateFormat.format(new Date(millis)); + } + + public static Locale getLocaleFromString(String locale) { + return getLocaleFromString(locale, Locale.ENGLISH); + } + + /** + * Parse {@link Locale} from string + * + * @param locale required locale + * @param defaultValue default value if the locale parameter is invalid + * @return Locale + */ + public static Locale getLocaleFromString(String locale, Locale defaultValue) { + try { + return Optional.ofNullable(locale) + .map(Locale::new) + .orElse(defaultValue); + } catch (Exception e) { + log.debugf("Invalid locale '%s'", locale); + } + return defaultValue; + } + + public static DateFormat getDefaultDateFormat() { + return getDefaultDateFormat(Locale.ENGLISH); + } + + public static DateFormat getDefaultDateFormat(Locale locale) { + return DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, locale); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/RealmAttributeUpdater.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/RealmAttributeUpdater.java index fa871ff902..7dc86bff2e 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/RealmAttributeUpdater.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/RealmAttributeUpdater.java @@ -97,4 +97,19 @@ public class RealmAttributeUpdater extends ServerResourceUpdaterMartin Bartos + */ +public class WebAuthnAuthenticatorsList { + + @FindBy(id = "kc-webauthn-authenticator") + private List authenticators; + + public List getItems() { + try { + List items = new ArrayList<>(); + for (WebElement auth : authenticators) { + String name = getTextFromElement(auth.findElement(By.id("kc-webauthn-authenticator-label"))); + String createdAt = getTextFromElement(auth.findElement(By.id("kc-webauthn-authenticator-created"))); + String createdAtLabel = getTextFromElement(auth.findElement(By.id("kc-webauthn-authenticator-created-label"))); + items.add(new WebAuthnAuthenticatorItem(name, createdAt, createdAtLabel)); + } + return items; + } catch (NoSuchElementException e) { + return Collections.emptyList(); + } + } + + public int getCount() { + try { + return authenticators.size(); + } catch (NoSuchElementException e) { + return 0; + } + } + + public List getLabels() { + try { + return getItems().stream() + .filter(Objects::nonNull) + .map(WebAuthnAuthenticatorItem::getName) + .collect(Collectors.toList()); + } catch (NoSuchElementException e) { + return Collections.emptyList(); + } + } + + public static class WebAuthnAuthenticatorItem { + private final String name; + private final String createdAt; + private final String createdAtLabel; + + public WebAuthnAuthenticatorItem(String name, String createdAt, String createdAtLabel) { + this.name = name; + this.createdAt = createdAt; + this.createdAtLabel = createdAtLabel; + } + + public String getName() { + return name; + } + + public String getCreatedDate() { + return createdAt; + } + + public String getCreatedLabel() { + return createdAtLabel; + } + } +} diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnErrorPage.java b/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnErrorPage.java index 27ab5bc993..6300174552 100644 --- a/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnErrorPage.java +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnErrorPage.java @@ -29,9 +29,6 @@ public class WebAuthnErrorPage extends LanguageComboboxAwarePage { @FindBy(className = "alert-error") private WebElement errorMessage; - @FindBy(id = "kc-webauthn-authenticator") - private List authenticators; - public void clickTryAgain() { WaitUtils.waitUntilElement(tryAgainButton).is().clickable(); tryAgainButton.click(); @@ -54,25 +51,6 @@ public class WebAuthnErrorPage extends LanguageComboboxAwarePage { } } - public int getAuthenticatorsCount() { - try { - return authenticators.size(); - } catch (NoSuchElementException e) { - return 0; - } - } - - public List getAuthenticators() { - try { - return authenticators.stream() - .filter(Objects::nonNull) - .map(UIUtils::getTextFromElement) - .collect(Collectors.toList()); - } catch (NoSuchElementException e) { - return Collections.emptyList(); - } - } - @Override public boolean isCurrent() { try { diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnLoginPage.java b/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnLoginPage.java index adc620a281..d8590fa799 100644 --- a/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnLoginPage.java +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnLoginPage.java @@ -17,11 +17,13 @@ package org.keycloak.testsuite.webauthn.pages; +import org.jboss.arquillian.graphene.page.Page; import org.keycloak.testsuite.pages.LanguageComboboxAwarePage; import org.keycloak.testsuite.util.WaitUtils; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.FindBy; +import java.util.List; import java.util.NoSuchElementException; /** @@ -32,6 +34,12 @@ public class WebAuthnLoginPage extends LanguageComboboxAwarePage { @FindBy(id = "authenticateWebAuthnButton") private WebElement authenticateButton; + @FindBy(id = "kc-webauthn-authenticator-label") + private List authenticatorsLabels; + + @Page + private WebAuthnAuthenticatorsList authenticators; + public void clickAuthenticate() { WaitUtils.waitUntilElement(authenticateButton).is().clickable(); authenticateButton.click(); @@ -46,6 +54,10 @@ public class WebAuthnLoginPage extends LanguageComboboxAwarePage { } } + public WebAuthnAuthenticatorsList getAuthenticators() { + return authenticators; + } + @Override public void open() { throw new UnsupportedOperationException(); diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnRegisterAndLoginTest.java b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnRegisterAndLoginTest.java index b12bcebe72..2b8ce5e296 100644 --- a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnRegisterAndLoginTest.java +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnRegisterAndLoginTest.java @@ -16,6 +16,7 @@ */ package org.keycloak.testsuite.webauthn; +import org.hamcrest.Matchers; import org.jboss.arquillian.graphene.page.Page; import org.junit.Assert; import org.junit.Rule; @@ -51,6 +52,7 @@ import org.keycloak.testsuite.pages.RegisterPage; import org.keycloak.testsuite.pages.SelectAuthenticatorPage; import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.FlowUtil; +import org.keycloak.testsuite.webauthn.pages.WebAuthnAuthenticatorsList; import org.keycloak.testsuite.webauthn.pages.WebAuthnLoginPage; import org.keycloak.testsuite.webauthn.pages.WebAuthnRegisterPage; import org.keycloak.testsuite.webauthn.updaters.WebAuthnRealmAttributeUpdater; @@ -178,6 +180,11 @@ public class WebAuthnRegisterAndLoginTest extends AbstractWebAuthnVirtualTest { loginPage.login(username, password); webAuthnLoginPage.assertCurrent(); + + final WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticators.getCount(), is(1)); + assertThat(authenticators.getLabels(), Matchers.contains(authenticatorLabel)); + webAuthnLoginPage.clickAuthenticate(); appPage.assertCurrent(); @@ -204,7 +211,7 @@ public class WebAuthnRegisterAndLoginTest extends AbstractWebAuthnVirtualTest { } @Test - public void testWebAuthnPasswordlessAlternativeWithWebAuthnAndPassword() throws IOException { + public void webAuthnPasswordlessAlternativeWithWebAuthnAndPassword() throws IOException { String userId = null; final String WEBAUTHN_LABEL = "webauthn"; @@ -280,6 +287,11 @@ public class WebAuthnRegisterAndLoginTest extends AbstractWebAuthnVirtualTest { passwordPage.login("password"); webAuthnLoginPage.assertCurrent(); + + final WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticators.getCount(), is(1)); + assertThat(authenticators.getLabels(), Matchers.contains(WEBAUTHN_LABEL)); + webAuthnLoginPage.clickAuthenticate(); appPage.assertCurrent(); @@ -299,6 +311,8 @@ public class WebAuthnRegisterAndLoginTest extends AbstractWebAuthnVirtualTest { selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.SECURITY_KEY); webAuthnLoginPage.assertCurrent(); + assertThat(webAuthnLoginPage.getAuthenticators().getCount(), is(0)); + webAuthnLoginPage.clickAuthenticate(); appPage.assertCurrent(); @@ -310,7 +324,7 @@ public class WebAuthnRegisterAndLoginTest extends AbstractWebAuthnVirtualTest { } @Test - public void testWebAuthnTwoFactorAndWebAuthnPasswordlessTogether() throws IOException { + public void webAuthnTwoFactorAndWebAuthnPasswordlessTogether() throws IOException { // Change binding to browser-webauthn-passwordless. This is flow, which contains both "webauthn" and "webauthn-passwordless" authenticator try (RealmAttributeUpdater rau = new RealmAttributeUpdater(testRealm()).setBrowserFlow("browser-webauthn-passwordless").update()) { // Login as test-user@localhost with password diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/AbstractWebAuthnAccountTest.java b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/AbstractWebAuthnAccountTest.java index f9066d0a3b..ed926f80cc 100644 --- a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/AbstractWebAuthnAccountTest.java +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/AbstractWebAuthnAccountTest.java @@ -20,11 +20,13 @@ package org.keycloak.testsuite.webauthn.account; import org.jboss.arquillian.graphene.page.Page; import org.junit.After; import org.junit.Before; +import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory; import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory; import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; import org.keycloak.common.Profile; +import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.credential.WebAuthnCredentialModel; import org.keycloak.representations.idm.AuthenticationExecutionRepresentation; import org.keycloak.representations.idm.AuthenticationFlowRepresentation; @@ -34,6 +36,7 @@ import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.page.AbstractPatternFlyAlert; import org.keycloak.testsuite.ui.account2.page.SigningInPage; import org.keycloak.testsuite.ui.account2.page.utils.SigningInPageUtils; +import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest; import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; import org.keycloak.testsuite.webauthn.authenticators.UseVirtualAuthenticators; @@ -165,4 +168,23 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple protected SigningInPage.UserCredential getNewestUserCredential(SigningInPage.CredentialType credentialType) { return SigningInPageUtils.getNewestUserCredential(testUserResource(), credentialType); } + + protected void setUpWebAuthnFlow(String newFlowAlias) { + setUpWebAuthnFlow(newFlowAlias, false); + } + + protected void setUpWebAuthnFlow(String newFlowAlias, boolean passwordless) { + final String providerID = passwordless ? WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID : WebAuthnAuthenticatorFactory.PROVIDER_ID; + + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session) + .selectFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernamePasswordFormFactory.PROVIDER_ID) + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, providerID) + ) + .defineAsBrowserFlow() // Activate this new flow + ); + } } diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnErrorTest.java b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnErrorTest.java index 69a86f1bb8..2aef47bb1b 100644 --- a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnErrorTest.java +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnErrorTest.java @@ -20,20 +20,16 @@ package org.keycloak.testsuite.webauthn.account; import org.hamcrest.Matchers; import org.jboss.arquillian.graphene.page.Page; import org.junit.Test; -import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; -import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory; -import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.testsuite.updaters.RealmAttributeUpdater; -import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.util.WaitUtils; +import org.keycloak.testsuite.webauthn.pages.WebAuthnAuthenticatorsList; import org.keycloak.testsuite.webauthn.pages.WebAuthnErrorPage; import org.keycloak.testsuite.webauthn.pages.WebAuthnLoginPage; import org.keycloak.testsuite.webauthn.updaters.WebAuthnRealmAttributeUpdater; import java.io.IOException; -import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; @@ -47,11 +43,10 @@ public class WebAuthnErrorTest extends AbstractWebAuthnAccountTest { protected WebAuthnErrorPage webAuthnErrorPage; @Test - public void errorPageWithPossibleAuthenticators() throws IOException { + public void errorPageWithTimeout() throws IOException { final int timeoutSec = 3; - - addWebAuthnCredential("authenticator#1"); - addWebAuthnCredential("authenticator#2"); + final String authenticatorLabel = "authenticator"; + addWebAuthnCredential(authenticatorLabel); try (RealmAttributeUpdater u = new WebAuthnRealmAttributeUpdater(testRealmResource()) .setWebAuthnPolicyCreateTimeout(timeoutSec) @@ -62,7 +57,7 @@ public class WebAuthnErrorTest extends AbstractWebAuthnAccountTest { assertThat(realm.getWebAuthnPolicyCreateTimeout(), is(timeoutSec)); final int webAuthnCount = webAuthnCredentialType.getUserCredentialsCount(); - assertThat(webAuthnCount, is(2)); + assertThat(webAuthnCount, is(1)); getWebAuthnManager().getCurrent().getAuthenticator().removeAllCredentials(); @@ -73,28 +68,18 @@ public class WebAuthnErrorTest extends AbstractWebAuthnAccountTest { loginToAccount(); webAuthnLoginPage.assertCurrent(); + + final WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticators.getCount(), is(1)); + assertThat(authenticators.getLabels(), Matchers.contains(authenticatorLabel)); + webAuthnLoginPage.clickAuthenticate(); //Should fail after this time WaitUtils.pause((timeoutSec + 1) * 1000); webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), containsString("Failed to authenticate by the Security key.")); - assertThat(webAuthnErrorPage.getAuthenticatorsCount(), is(2)); - assertThat(webAuthnErrorPage.getAuthenticators(), Matchers.contains("authenticator#1", "authenticator#2")); + assertThat(webAuthnErrorPage.getError(), is("Failed to authenticate by the Security key.")); } } - - private void setUpWebAuthnFlow(String newFlowAlias) { - testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); - testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session) - .selectFlow(newFlowAlias) - .inForms(forms -> forms - .clear() - .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernamePasswordFormFactory.PROVIDER_ID) - .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, WebAuthnAuthenticatorFactory.PROVIDER_ID) - ) - .defineAsBrowserFlow() // Activate this new flow - ); - } } diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java index e6e8e6929e..002ab812b4 100644 --- a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnSigningInTest.java @@ -17,23 +17,30 @@ package org.keycloak.testsuite.webauthn.account; +import org.hamcrest.Matchers; +import org.jboss.arquillian.graphene.page.Page; import org.junit.Test; import org.keycloak.admin.client.resource.UserResource; -import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory; -import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory; import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; -import org.keycloak.representations.idm.AuthenticationExecutionRepresentation; -import org.keycloak.representations.idm.AuthenticationFlowRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; -import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation; import org.keycloak.testsuite.ui.account2.page.SigningInPage; -import org.keycloak.testsuite.ui.account2.page.utils.SigningInPageUtils; +import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.webauthn.authenticators.UseVirtualAuthenticators; +import org.keycloak.testsuite.webauthn.pages.WebAuthnAuthenticatorsList; +import org.keycloak.testsuite.webauthn.pages.WebAuthnLoginPage; +import org.keycloak.theme.DateTimeFormatterUtil; +import java.io.Closeable; +import java.io.IOException; +import java.text.DateFormat; +import java.text.ParseException; import java.util.ArrayList; +import java.util.Date; import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; import java.util.stream.Collectors; import static java.util.Collections.emptyList; @@ -43,7 +50,6 @@ import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.hasSize; -import static org.keycloak.models.AuthenticationExecutionModel.Requirement.REQUIRED; import static org.keycloak.testsuite.ui.account2.page.utils.SigningInPageUtils.assertUserCredential; import static org.keycloak.testsuite.ui.account2.page.utils.SigningInPageUtils.testSetUpLink; import static org.keycloak.testsuite.util.UIUtils.refreshPageAndWaitForLoad; @@ -51,6 +57,9 @@ import static org.keycloak.testsuite.util.WaitUtils.waitForPageToLoad; public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest implements UseVirtualAuthenticators { + @Page + protected WebAuthnLoginPage webAuthnLoginPage; + @Test public void categoriesTest() { testContext.setTestRealmReps(emptyList()); // reimport realm after this test @@ -78,7 +87,7 @@ public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest implement } @Test - public void testCreateWebAuthnSameUserLabel() { + public void createWebAuthnSameUserLabel() { final String SAME_LABEL = "key123"; // Do we really allow to have several authenticators with the same user label?? @@ -112,7 +121,7 @@ public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest implement } @Test - public void testMultipleSecurityKeys() { + public void multipleSecurityKeys() { final String LABEL = "SecurityKey#"; List createdCredentials = new ArrayList<>(); @@ -168,16 +177,177 @@ public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest implement } @Test - public void testCancelRegistration() { - cancelRegistration(false); + public void displayAvailableAuthenticators() { + addWebAuthnCredential("authenticator#1"); + addWebAuthnCredential("authenticator#2"); + + final int webAuthnCount = webAuthnCredentialType.getUserCredentialsCount(); + assertThat(webAuthnCount, is(2)); + + setUpWebAuthnFlow("webAuthnFlow"); + logout(); + + signingInPage.navigateTo(); + loginToAccount(); + + webAuthnLoginPage.assertCurrent(); + + WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticators.getCount(), is(2)); + assertThat(authenticators.getLabels(), Matchers.contains("authenticator#1", "authenticator#2")); + + webAuthnLoginPage.clickAuthenticate(); + signingInPage.assertCurrent(); } @Test - public void testCancelPasswordlessRegistration() { - cancelRegistration(true); + public void notDisplayAvailableAuthenticatorsPasswordless() { + addWebAuthnCredential("authenticator#1", true); + addWebAuthnCredential("authenticator#2", true); + + final int passwordlessCount = webAuthnPwdlessCredentialType.getUserCredentialsCount(); + assertThat(passwordlessCount, is(2)); + + setUpWebAuthnFlow("passwordlessFlow", true); + logout(); + + signingInPage.navigateTo(); + loginToAccount(); + + webAuthnLoginPage.assertCurrent(); + assertThat(webAuthnLoginPage.getAuthenticators().getCount(), is(0)); + + webAuthnLoginPage.clickAuthenticate(); + signingInPage.assertCurrent(); } - private void cancelRegistration(boolean passwordless) { + @Test + public void checkAuthenticatorTimeLocale() throws ParseException, IOException { + addWebAuthnCredential("authenticator#1"); + + final int webAuthnCount = webAuthnCredentialType.getUserCredentialsCount(); + assertThat(webAuthnCount, is(1)); + + setUpWebAuthnFlow("webAuthnFlow"); + logout(); + + signingInPage.navigateTo(); + loginToAccount(); + + webAuthnLoginPage.assertCurrent(); + + WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticators.getCount(), is(1)); + assertThat(authenticators.getLabels(), Matchers.contains("authenticator#1")); + + WebAuthnAuthenticatorsList.WebAuthnAuthenticatorItem item = authenticators.getItems().get(0); + assertThat(item, notNullValue()); + assertThat(item.getName(), is("authenticator#1")); + + final String dateEnglishString = item.getCreatedDate(); + assertThat(dateEnglishString, notNullValue()); + + DateFormat format = DateTimeFormatterUtil.getDefaultDateFormat(Locale.ENGLISH); + final Date dateEnglish = format.parse(dateEnglishString); + assertThat(dateEnglish, notNullValue()); + + webAuthnLoginPage.clickAuthenticate(); + signingInPage.assertCurrent(); + + logout(); + + try (Closeable c = setLocalesUpdater(Locale.CHINA.getLanguage()).update()) { + signingInPage.navigateTo(); + loginToAccount(); + + webAuthnLoginPage.assertCurrent(); + + authenticators = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticators.getCount(), is(1)); + item = webAuthnLoginPage.getAuthenticators().getItems().get(0); + + final String dateChineseString = item.getCreatedDate(); + assertThat(dateChineseString, notNullValue()); + + format = DateTimeFormatterUtil.getDefaultDateFormat(Locale.CHINA); + final Date dateChinese = format.parse(dateChineseString); + assertThat(dateChinese, notNullValue()); + + assertThat(dateEnglishString, is(not(dateChineseString))); + assertThat(dateEnglish, is(dateChinese)); + + webAuthnLoginPage.clickAuthenticate(); + signingInPage.assertCurrent(); + + logout(); + } + + try (Closeable c = setLocalesUpdater("xx", Locale.ENGLISH.getLanguage()).update()) { + signingInPage.navigateTo(); + loginToAccount(); + + webAuthnLoginPage.assertCurrent(); + + authenticators = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticators.getCount(), is(1)); + item = webAuthnLoginPage.getAuthenticators().getItems().get(0); + + final String dateInvalidString = item.getCreatedDate(); + assertThat(dateInvalidString, notNullValue()); + + assertThat(dateInvalidString, is(dateEnglishString)); + } + } + + @Test + public void userAuthenticatorTimeLocale() throws IOException { + Consumer checkCreatedAtLabels = (requiredLabel) -> + webAuthnLoginPage.getAuthenticators() + .getItems() + .stream() + .map(WebAuthnAuthenticatorsList.WebAuthnAuthenticatorItem::getCreatedLabel) + .forEach(f -> assertThat(f, is(requiredLabel))); + + try (Closeable c = setLocalesUpdater(Locale.ENGLISH.getLanguage(), "cs").update()) { + addWebAuthnCredential("authenticator#1"); + addWebAuthnCredential("authenticator#2"); + + final int webAuthnCount = webAuthnCredentialType.getUserCredentialsCount(); + assertThat(webAuthnCount, is(2)); + + setUpWebAuthnFlow("webAuthnFlow"); + logout(); + + signingInPage.navigateTo(); + loginToAccount(); + + webAuthnLoginPage.assertCurrent(); + + WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticators.getCount(), is(2)); + assertThat(authenticators.getLabels(), Matchers.contains("authenticator#1", "authenticator#2")); + + checkCreatedAtLabels.accept("Created"); + + webAuthnLoginPage.openLanguage("Čeština"); + checkCreatedAtLabels.accept("Vytvořeno"); + + webAuthnLoginPage.clickAuthenticate(); + signingInPage.assertCurrent(); + } + } + + @Test + public void cancelRegistration() { + checkCancelRegistration(false); + } + + @Test + public void cancelPasswordlessRegistration() { + checkCancelRegistration(true); + } + + private void checkCancelRegistration(boolean passwordless) { SigningInPage.CredentialType credentialType = passwordless ? webAuthnPwdlessCredentialType : webAuthnCredentialType; credentialType.clickSetUpLink(); @@ -239,4 +409,16 @@ public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest implement testRemoveCredential(webAuthn1); } + + private RealmAttributeUpdater setLocalesUpdater(String defaultLocale, String... supportedLocales) { + RealmAttributeUpdater updater = new RealmAttributeUpdater(testRealmResource()) + .setDefaultLocale(defaultLocale) + .setInternationalizationEnabled(true) + .addSupportedLocale(defaultLocale); + + for (String locale : supportedLocales) { + updater.addSupportedLocale(locale); + } + return updater; + } } diff --git a/themes/src/main/resources-community/theme/base/login/messages/messages_cs.properties b/themes/src/main/resources-community/theme/base/login/messages/messages_cs.properties index 7916ba3965..66164e1d97 100644 --- a/themes/src/main/resources-community/theme/base/login/messages/messages_cs.properties +++ b/themes/src/main/resources-community/theme/base/login/messages/messages_cs.properties @@ -383,9 +383,10 @@ webauthn-passwordless-display-name=Bezpečnostní klíč webauthn-passwordless-help-text=Použijte bezpečnostní klíč k přihlášení bez hesla. webauthn-login-title=Přihlášení bezpečnostním klíčem webauthn-registration-title=Registrace bezpečnostního klíče -webauthn-available-authenticators=Dostupné autentikátory +webauthn-available-authenticators=Dostupné bezpečnostní klíče webauthn-unsupported-browser-text=WebAuthn není v tomto prohlížeči podporováno. Zkuste jiný prohlížeč nebo kontaktujte svého administrátora. webauthn-doAuthenticate=Přihlášení bezpečnostním klíčem +webauthn-createdAt-label=Vytvořeno # WebAuthn Error webauthn-error-title=Chyba bezpečnostního klíče diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 9a6e2f7df8..f5a65929ec 100755 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -416,9 +416,10 @@ webauthn-passwordless-display-name=Security Key webauthn-passwordless-help-text=Use your security key for passwordless sign in. webauthn-login-title=Security Key login webauthn-registration-title=Security Key Registration -webauthn-available-authenticators=Available authenticators +webauthn-available-authenticators=Available Security Keys webauthn-unsupported-browser-text=WebAuthn is not supported by this browser. Try another one or contact your administrator. webauthn-doAuthenticate=Sign in with Security Key +webauthn-createdAt-label=Created # WebAuthn Error webauthn-error-title=Security Key Error diff --git a/themes/src/main/resources/theme/base/login/webauthn-authenticate.ftl b/themes/src/main/resources/theme/base/login/webauthn-authenticate.ftl index 82eba8bc57..f35f63283f 100644 --- a/themes/src/main/resources/theme/base/login/webauthn-authenticate.ftl +++ b/themes/src/main/resources/theme/base/login/webauthn-authenticate.ftl @@ -5,34 +5,63 @@ <#elseif section = "header"> ${kcSanitize(msg("webauthn-login-title"))?no_esc} <#elseif section = "form"> +
+
+ + + + + + +
-
-
- - - - - - -
-
+
+ <#if authenticators??> +
+ <#list authenticators.authenticators as authenticator> + + +
- <#if authenticators??> -
- <#list authenticators.authenticators as authenticator> - - -
- + <#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators> + <#if authenticators.authenticators?size gt 1> +

${kcSanitize(msg("webauthn-available-authenticators"))}

+ -
-
-
- +
+ <#list authenticators.authenticators as authenticator> +
+
+ +
+
+
+ ${msg('${authenticator.label}')} +
+
+ + ${msg('webauthn-createdAt-label')} + + + ${authenticator.createdAt} + +
+
+
+
+ +
+ + + +
+ +
-
diff --git a/themes/src/main/resources/theme/base/login/webauthn-error.ftl b/themes/src/main/resources/theme/base/login/webauthn-error.ftl index ed904f9b77..2474a3f6cc 100644 --- a/themes/src/main/resources/theme/base/login/webauthn-error.ftl +++ b/themes/src/main/resources/theme/base/login/webauthn-error.ftl @@ -18,25 +18,6 @@ - <#if authenticators??> - - - - - - - - <#list authenticators.authenticators as authenticator> - - - - - -
${kcSanitize(msg("webauthn-available-authenticators"))?no_esc}
- ${kcSanitize(authenticator.label)?no_esc} -
- - diff --git a/themes/src/main/resources/theme/keycloak/login/resources/css/login.css b/themes/src/main/resources/theme/keycloak/login/resources/css/login.css index 9c082d4132..6ee306bc5a 100644 --- a/themes/src/main/resources/theme/keycloak/login/resources/css/login.css +++ b/themes/src/main/resources/theme/keycloak/login/resources/css/login.css @@ -262,6 +262,18 @@ div.kc-logo-text span { padding-top: 8px; } +#kc-form-webauthn .select-auth-box-parent { + pointer-events: none; +} + +#kc-form-webauthn .select-auth-box-desc { + color: var(--pf-global--palette--black-600); +} + +#kc-form-webauthn .select-auth-box-headline { + color: var(--pf-global--Color--300); +} + #kc-content-wrapper { margin-top: 20px; } @@ -620,6 +632,12 @@ ul#kc-totp-supported-apps { font-size: var(--pf-global--FontSize--sm); } +.select-auth-box-paragraph { + text-align: center; + font-size: var(--pf-global--FontSize--md); + margin-bottom: 5px; +} + .card-pf { margin: 0 auto; box-shadow: var(--pf-global--BoxShadow--lg); diff --git a/themes/src/main/resources/theme/keycloak/login/theme.properties b/themes/src/main/resources/theme/keycloak/login/theme.properties index e894b8257e..23d8621bbc 100644 --- a/themes/src/main/resources/theme/keycloak/login/theme.properties +++ b/themes/src/main/resources/theme/keycloak/login/theme.properties @@ -106,6 +106,7 @@ kcSelectAuthListItemDescriptionClass=pf-l-stack__item select-auth-box-desc kcSelectAuthListItemFillClass=pf-l-split__item pf-m-fill kcSelectAuthListItemArrowClass=pf-l-split__item select-auth-box-arrow kcSelectAuthListItemArrowIconClass=fa fa-angle-right fa-lg +kcSelectAuthListItemTitle=select-auth-box-paragraph ##### css classes for the authenticators kcAuthenticatorDefaultClass=fa list-view-pf-icon-lg