From d82122b982a5e83d8ca24a96ca60e97af1e4db87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Barto=C5=A1?= Date: Thu, 3 Feb 2022 14:39:51 +0100 Subject: [PATCH] Store information about transport media of WebAuthn authenticator Closes #9800 --- .../credential/WebAuthnCredentialModel.java | 12 +- .../dto/WebAuthnCredentialData.java | 18 ++- .../java/org/keycloak/WebAuthnConstants.java | 2 + .../requiredactions/WebAuthnRegister.java | 26 +++- .../WebAuthnCredentialModelInput.java | 23 +++ .../WebAuthnCredentialProvider.java | 19 ++- .../model/WebAuthnAuthenticatorsBean.java | 139 +++++++++++++++++- .../pages/WebAuthnAuthenticatorsList.java | 11 +- .../webauthn/WebAuthnTransportsTest.java | 79 ++++++++++ .../account/AbstractWebAuthnAccountTest.java | 17 +++ .../account/WebAuthnSigningInTest.java | 16 -- .../account/WebAuthnTransportLocaleTest.java | 112 ++++++++++++++ .../login/messages/messages_cs.properties | 2 + .../login/messages/messages_en.properties | 6 + .../theme/base/login/select-authenticator.ftl | 2 +- .../base/login/webauthn-authenticate.ftl | 22 ++- .../theme/base/login/webauthn-register.ftl | 19 +++ .../keycloak/login/resources/css/login.css | 13 ++ .../theme/keycloak/login/theme.properties | 8 + 19 files changed, 510 insertions(+), 36 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnTransportsTest.java create mode 100644 testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnTransportLocaleTest.java diff --git a/server-spi/src/main/java/org/keycloak/models/credential/WebAuthnCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/credential/WebAuthnCredentialModel.java index e46fcc17b5..8a3436ab48 100644 --- a/server-spi/src/main/java/org/keycloak/models/credential/WebAuthnCredentialModel.java +++ b/server-spi/src/main/java/org/keycloak/models/credential/WebAuthnCredentialModel.java @@ -26,6 +26,9 @@ import org.keycloak.models.credential.dto.WebAuthnCredentialData; import org.keycloak.models.credential.dto.WebAuthnSecretData; import org.keycloak.util.JsonSerialization; +import java.util.Collections; +import java.util.Set; + /** * @author Marek Posolda */ @@ -48,8 +51,13 @@ public class WebAuthnCredentialModel extends CredentialModel { } public static WebAuthnCredentialModel create(String credentialType, String userLabel, String aaguid, String credentialId, - String attestationStatement, String credentialPublicKey, long counter, String attestationStatementFormat) { - WebAuthnCredentialData credentialData = new WebAuthnCredentialData(aaguid, credentialId, counter, attestationStatement, credentialPublicKey, attestationStatementFormat); + String attestationStatement, String credentialPublicKey, long counter, String attestationStatementFormat) { + return create(credentialType, userLabel, aaguid, credentialId, attestationStatement, credentialPublicKey, counter, attestationStatementFormat, Collections.emptySet()); + } + + public static WebAuthnCredentialModel create(String credentialType, String userLabel, String aaguid, String credentialId, + String attestationStatement, String credentialPublicKey, long counter, String attestationStatementFormat, Set transports) { + WebAuthnCredentialData credentialData = new WebAuthnCredentialData(aaguid, credentialId, counter, attestationStatement, credentialPublicKey, attestationStatementFormat, transports); WebAuthnSecretData secretData = new WebAuthnSecretData(); WebAuthnCredentialModel credentialModel = new WebAuthnCredentialModel(credentialType, credentialData, secretData); diff --git a/server-spi/src/main/java/org/keycloak/models/credential/dto/WebAuthnCredentialData.java b/server-spi/src/main/java/org/keycloak/models/credential/dto/WebAuthnCredentialData.java index acbde94c16..8475f2d9d9 100644 --- a/server-spi/src/main/java/org/keycloak/models/credential/dto/WebAuthnCredentialData.java +++ b/server-spi/src/main/java/org/keycloak/models/credential/dto/WebAuthnCredentialData.java @@ -21,6 +21,10 @@ package org.keycloak.models.credential.dto; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; + /** * @author Marek Posolda */ @@ -32,6 +36,7 @@ public class WebAuthnCredentialData { private String attestationStatement; private String credentialPublicKey; private String attestationStatementFormat; + private Set transports; @JsonCreator public WebAuthnCredentialData(@JsonProperty("aaguid") String aaguid, @@ -39,13 +44,15 @@ public class WebAuthnCredentialData { @JsonProperty("counter") long counter, @JsonProperty("attestationStatement") String attestationStatement, @JsonProperty("credentialPublicKey") String credentialPublicKey, - @JsonProperty("attestationStatementFormat") String attestationStatementFormat) { + @JsonProperty("attestationStatementFormat") String attestationStatementFormat, + @JsonProperty("transports") Set transports) { this.aaguid = aaguid; this.credentialId = credentialId; this.counter = counter; this.attestationStatement = attestationStatement; this.credentialPublicKey = credentialPublicKey; this.attestationStatementFormat = attestationStatementFormat; + this.transports = transports; } public String getAaguid() { @@ -80,6 +87,14 @@ public class WebAuthnCredentialData { this.attestationStatementFormat = attestationStatementFormat; } + public Set getTransports() { + return transports != null ? transports : Collections.emptySet(); + } + + public void setTransports(Set transports) { + this.transports = transports; + } + @Override public String toString() { return "WebAuthnCredentialData { " + @@ -90,6 +105,7 @@ public class WebAuthnCredentialData { ", attestationStatement='" + attestationStatement + '\'' + ", credentialPublicKey='" + credentialPublicKey + '\'' + ", attestationStatementFormat='" + attestationStatementFormat + '\'' + + ", transports=" + Arrays.toString(getTransports().toArray()) + " }"; } } diff --git a/services/src/main/java/org/keycloak/WebAuthnConstants.java b/services/src/main/java/org/keycloak/WebAuthnConstants.java index 2b20aa0c71..b0f6846549 100644 --- a/services/src/main/java/org/keycloak/WebAuthnConstants.java +++ b/services/src/main/java/org/keycloak/WebAuthnConstants.java @@ -44,6 +44,8 @@ public interface WebAuthnConstants { String ALLOWED_AUTHENTICATORS = "authenticators"; String IS_USER_IDENTIFIED = "isUserIdentified"; String USER_VERIFICATION = "userVerification"; + String TRANSPORTS = "transports"; + String IS_SET_RETRY = "isSetRetry"; String SHOULD_DISPLAY_AUTHENTICATORS = "shouldDisplayAuthenticators"; diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java index d40617bff6..8ebf1913be 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java @@ -20,7 +20,9 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import javax.ws.rs.core.MediaType; @@ -28,6 +30,8 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import com.webauthn4j.WebAuthnRegistrationManager; +import com.webauthn4j.data.AuthenticatorTransport; +import org.apache.commons.collections4.CollectionUtils; import org.jboss.logging.Logger; import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.WebAuthnConstants; @@ -69,6 +73,7 @@ import com.webauthn4j.validator.attestation.statement.u2f.FIDOU2FAttestationStat import com.webauthn4j.validator.attestation.trustworthiness.certpath.CertPathTrustworthinessValidator; import com.webauthn4j.validator.attestation.trustworthiness.self.DefaultSelfAttestationTrustworthinessValidator; import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.utils.StringUtil; import static org.keycloak.WebAuthnConstants.REG_ERR_DETAIL_LABEL; import static org.keycloak.WebAuthnConstants.REG_ERR_LABEL; @@ -205,7 +210,17 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis // check User Verification by considering a malicious user might modify the result of calling WebAuthn API boolean isUserVerificationRequired = policy.getUserVerificationRequirement().equals(WebAuthnConstants.OPTION_REQUIRED); - RegistrationRequest registrationRequest = new RegistrationRequest(attestationObject, clientDataJSON); + final String transportsParam = params.getFirst(WebAuthnConstants.TRANSPORTS); + + RegistrationRequest registrationRequest; + + if (StringUtil.isNotBlank(transportsParam)) { + final Set transports = new HashSet<>(Arrays.asList(transportsParam.split(","))); + registrationRequest = new RegistrationRequest(attestationObject, clientDataJSON, transports); + } else { + registrationRequest = new RegistrationRequest(attestationObject, clientDataJSON); + } + RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, isUserVerificationRequired); WebAuthnRegistrationManager webAuthnRegistrationManager = createWebAuthnRegistrationManager(); @@ -224,6 +239,7 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis credential.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()); credential.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount()); credential.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat()); + credential.setTransports(registrationData.getTransports()); // Save new webAuthn credential WebAuthnCredentialProvider webAuthnCredProvider = (WebAuthnCredentialProvider) this.session.getProvider(CredentialProvider.class, getCredentialProviderId()); @@ -305,9 +321,17 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis private void showInfoAfterWebAuthnApiCreate(RegistrationData response) { AttestedCredentialData attestedCredentialData = response.getAttestationObject().getAuthenticatorData().getAttestedCredentialData(); AttestationStatement attestationStatement = response.getAttestationObject().getAttestationStatement(); + Set transports = response.getTransports(); + logger.debugv("createad key's algorithm = {0}", String.valueOf(attestedCredentialData.getCOSEKey().getAlgorithm().getValue())); logger.debugv("aaguid = {0}", attestedCredentialData.getAaguid().toString()); logger.debugv("attestation format = {0}", attestationStatement.getFormat()); + + if (CollectionUtils.isNotEmpty(transports)) { + logger.debugv("transports = [{0}]", transports.stream() + .map(AuthenticatorTransport::getValue) + .collect(Collectors.joining(","))); + } } private void checkAcceptedAuthenticator(RegistrationData response, WebAuthnPolicy policy) throws Exception { diff --git a/services/src/main/java/org/keycloak/credential/WebAuthnCredentialModelInput.java b/services/src/main/java/org/keycloak/credential/WebAuthnCredentialModelInput.java index 080e03df2c..a40dcf34c7 100644 --- a/services/src/main/java/org/keycloak/credential/WebAuthnCredentialModelInput.java +++ b/services/src/main/java/org/keycloak/credential/WebAuthnCredentialModelInput.java @@ -16,14 +16,19 @@ package org.keycloak.credential; +import org.apache.commons.collections4.CollectionUtils; import org.keycloak.common.util.Base64; import com.webauthn4j.data.AuthenticationParameters; import com.webauthn4j.data.AuthenticationRequest; +import com.webauthn4j.data.AuthenticatorTransport; import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData; import com.webauthn4j.data.attestation.authenticator.COSEKey; import com.webauthn4j.data.attestation.statement.AttestationStatement; +import java.util.Set; +import java.util.stream.Collectors; + public class WebAuthnCredentialModelInput implements CredentialInput { private AttestedCredentialData attestedCredentialData; @@ -34,6 +39,7 @@ public class WebAuthnCredentialModelInput implements CredentialInput { private String credentialDBId; private final String credentialType; private String attestationStatementFormat; + private Set transports; public WebAuthnCredentialModelInput(String credentialType) { this.credentialType = credentialType; @@ -115,6 +121,14 @@ public class WebAuthnCredentialModelInput implements CredentialInput { this.attestationStatementFormat = attestationStatementFormat; } + public Set getTransports() { + return transports; + } + + public void setTransports(Set transports) { + this.transports = transports; + } + public String toString() { StringBuilder sb = new StringBuilder("Credential Type = " + credentialType + ","); if (credentialDBId != null) @@ -156,6 +170,15 @@ public class WebAuthnCredentialModelInput implements CredentialInput { .append(Base64.encodeBytes(authenticationRequest.getCredentialId())) .append(","); } + if (CollectionUtils.isNotEmpty(transports)) { + final String transportsString = transports.stream() + .map(AuthenticatorTransport::getValue) + .collect(Collectors.joining(",")); + + sb.append("Transports = [") + .append(transportsString) + .append("],"); + } if (sb.length() > 0) sb.deleteCharAt(sb.lastIndexOf(",")); return sb.toString(); diff --git a/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProvider.java b/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProvider.java index f41ab644f7..dae8532c8e 100644 --- a/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProvider.java +++ b/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProvider.java @@ -19,10 +19,12 @@ package org.keycloak.credential; import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import com.webauthn4j.WebAuthnAuthenticationManager; import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.AuthenticatorTransport; import org.jboss.logging.Logger; import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; import org.keycloak.common.util.Base64; @@ -105,7 +107,22 @@ public class WebAuthnCredentialProvider implements CredentialProvider transports = webAuthnModel.getTransports() + .stream() + .map(AuthenticatorTransport::getValue) + .collect(Collectors.toSet()); + + WebAuthnCredentialModel model = WebAuthnCredentialModel.create( + getType(), + userLabel, + aaguid, + credentialId, + null, + credentialPublicKey, + counter, + attestationStatementFormat, + transports + ); model.setId(webAuthnModel.getCredentialDBId()); 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 c6e385462b..5e6847393c 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 @@ -15,20 +15,26 @@ */ package org.keycloak.forms.login.freemarker.model; -import java.util.LinkedList; -import java.util.List; -import java.util.stream.Collectors; - +import com.webauthn4j.data.AuthenticatorTransport; +import org.apache.commons.collections4.CollectionUtils; import org.keycloak.common.util.Base64Url; 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; +import org.keycloak.utils.StringUtil; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; public class WebAuthnAuthenticatorsBean { - private List authenticators = new LinkedList(); + private final List authenticators; public WebAuthnAuthenticatorsBean(KeycloakSession session, RealmModel realm, UserModel user, String credentialType) { // should consider multiple credentials in the future, but only single credential supported now. @@ -38,7 +44,9 @@ public class WebAuthnAuthenticatorsBean { String credentialId = Base64Url.encodeBase64ToBase64Url(webAuthnCredential.getWebAuthnCredentialData().getCredentialId()); 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); + final Set transports = webAuthnCredential.getWebAuthnCredentialData().getTransports(); + + return new WebAuthnAuthenticatorBean(credentialId, label, createdAt, transports); }).collect(Collectors.toList()); } @@ -47,14 +55,18 @@ public class WebAuthnAuthenticatorsBean { } public static class WebAuthnAuthenticatorBean { + public static final String DEFAULT_ICON = "kcWebAuthnDefaultIcon"; + private final String credentialId; private final String label; private final String createdAt; + private final TransportsBean transports; - public WebAuthnAuthenticatorBean(String credentialId, String label, String createdAt) { + public WebAuthnAuthenticatorBean(String credentialId, String label, String createdAt, Set transports) { this.credentialId = credentialId; this.label = label; this.createdAt = createdAt; + this.transports = TransportsBean.convertFromSet(transports); } public String getCredentialId() { @@ -68,5 +80,118 @@ public class WebAuthnAuthenticatorsBean { public String getCreatedAt() { return this.createdAt; } + + public TransportsBean getTransports() { + return transports; + } + + public static class TransportsBean { + private final Set displayNameProperties; + private final String iconClass; + + public TransportsBean(Set displayNameProperties, String iconClass) { + this.displayNameProperties = displayNameProperties; + this.iconClass = iconClass; + } + + public TransportsBean(String displayNameProperty, String iconClass) { + this(Collections.singleton(displayNameProperty), iconClass); + } + + public TransportsBean(Transport transport) { + this(transport.getDisplayNameProperty(), transport.getIconClass()); + } + + public Set getDisplayNameProperties() { + return displayNameProperties; + } + + public String getIconClass() { + return iconClass; + } + + /** + * Converts set of available transport media to TransportsBean + * + * @param transports set of available transport media + * @return TransportBean + */ + public static TransportsBean convertFromSet(Set transports) { + if (CollectionUtils.isEmpty(transports)) { + return new TransportsBean(Transport.UNKNOWN); + } + + final Set trans = transports.stream() + .filter(Objects::nonNull) + .map(Transport::getByMapperName) + .collect(Collectors.toSet()); + + if (trans.size() <= 1) { + final Transport transport = trans.stream() + .findFirst() + .orElse(Transport.UNKNOWN); + + return new TransportsBean(transport); + } else { + final Set displayNameProperties = trans.stream() + .map(Transport::getDisplayNameProperty) + .collect(Collectors.toSet()); + + return new TransportsBean(displayNameProperties, DEFAULT_ICON); + } + } + + protected enum Transport { + USB("usb", AuthenticatorTransport.USB.getValue(), "kcWebAuthnUSB"), + NFC("nfc", AuthenticatorTransport.NFC.getValue(), "kcWebAuthnNFC"), + BLE("bluetooth", AuthenticatorTransport.BLE.getValue(), "kcWebAuthnBLE"), + INTERNAL("internal", AuthenticatorTransport.INTERNAL.getValue(), "kcWebAuthnInternal"), + UNKNOWN("unknown", null, DEFAULT_ICON); + + private final String displayNameProperty; + private final String mapperName; + private final String iconClass; + + /** + * @param displayNameProperty Message property - defined in messages_xx.properties + * @param mapperName used for mapping transport media name + * @param iconClass icon class for particular transport media - defined in theme.properties + */ + Transport(String displayNameProperty, String mapperName, String iconClass) { + this.displayNameProperty = displayNameProperty; + this.mapperName = mapperName; + this.iconClass = iconClass; + } + + public String getDisplayNameProperty() { + return displayNameProperty; + } + + public String getMapperName() { + return mapperName; + } + + public String getIconClass() { + return iconClass; + } + + public static Transport getByDisplayNameProperty(String property) { + return Arrays.stream(Transport.values()) + .filter(f -> f.getDisplayNameProperty().equals(property)) + .findFirst() + .orElse(UNKNOWN); + } + + public static Transport getByMapperName(String mapperName) { + if (StringUtil.isBlank(mapperName)) return UNKNOWN; + + return Arrays.stream(Transport.values()) + .filter(f -> Objects.nonNull(f.getMapperName())) + .filter(f -> f.getMapperName().equals(mapperName)) + .findFirst() + .orElse(UNKNOWN); + } + } + } } } diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnAuthenticatorsList.java b/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnAuthenticatorsList.java index 1ca6165fcd..fd375d9e02 100644 --- a/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnAuthenticatorsList.java +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/main/java/org/keycloak/testsuite/webauthn/pages/WebAuthnAuthenticatorsList.java @@ -47,7 +47,8 @@ public class WebAuthnAuthenticatorsList { 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)); + String transport = getTextFromElement(auth.findElement(By.id("kc-webauthn-authenticator-transport"))); + items.add(new WebAuthnAuthenticatorItem(name, createdAt, createdAtLabel, transport)); } return items; } catch (NoSuchElementException e) { @@ -78,11 +79,13 @@ public class WebAuthnAuthenticatorsList { private final String name; private final String createdAt; private final String createdAtLabel; + private final String transport; - public WebAuthnAuthenticatorItem(String name, String createdAt, String createdAtLabel) { + public WebAuthnAuthenticatorItem(String name, String createdAt, String createdAtLabel, String transport) { this.name = name; this.createdAt = createdAt; this.createdAtLabel = createdAtLabel; + this.transport = transport; } public String getName() { @@ -96,5 +99,9 @@ public class WebAuthnAuthenticatorsList { public String getCreatedLabel() { return createdAtLabel; } + + public String getTransport() { + return transport; + } } } diff --git a/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnTransportsTest.java b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnTransportsTest.java new file mode 100644 index 0000000000..c8b4c11e5e --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnTransportsTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2022 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; + +import org.junit.Test; +import org.keycloak.testsuite.webauthn.pages.WebAuthnAuthenticatorsList; +import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; + +import java.util.List; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions.DEFAULT_BLE; +import static org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions.DEFAULT_INTERNAL; +import static org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions.DEFAULT_NFC; +import static org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions.DEFAULT_USB; + +/** + * @author Martin Bartos + */ +public class WebAuthnTransportsTest extends AbstractWebAuthnVirtualTest { + + @Test + public void usbTransport() { + assertTransport(DEFAULT_USB.getOptions(), "USB"); + } + + @Test + public void nfcTransport() { + assertTransport(DEFAULT_NFC.getOptions(), "NFC"); + } + + @Test + public void bluetoothTransport() { + assertTransport(DEFAULT_BLE.getOptions(), "Bluetooth"); + } + + @Test + public void internalTransport() { + assertTransport(DEFAULT_INTERNAL.getOptions(), "Internal"); + } + + private void assertTransport(VirtualAuthenticatorOptions authenticator, String transportName) { + getVirtualAuthManager().useAuthenticator(authenticator); + registerDefaultUser(); + logout(); + + loginPage.open(); + loginPage.assertCurrent(); + loginPage.login(USERNAME, PASSWORD); + + webAuthnLoginPage.assertCurrent(); + + WebAuthnAuthenticatorsList authenticatorsList = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticatorsList, notNullValue()); + + List items = authenticatorsList.getItems(); + assertThat(items, notNullValue()); + assertThat(items.size(), is(1)); + assertThat(items.get(0).getTransport(), is(transportName)); + } + +} 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 ed926f80cc..c2baa3f7a4 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 @@ -36,11 +36,13 @@ 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.updaters.RealmAttributeUpdater; 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; import org.keycloak.testsuite.webauthn.authenticators.VirtualAuthenticatorManager; +import org.keycloak.testsuite.webauthn.pages.WebAuthnLoginPage; import org.keycloak.testsuite.webauthn.pages.WebAuthnRegisterPage; import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; @@ -56,6 +58,9 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple @Page protected WebAuthnRegisterPage webAuthnRegisterPage; + @Page + protected WebAuthnLoginPage webAuthnLoginPage; + private VirtualAuthenticatorManager webAuthnManager; protected SigningInPage.CredentialType webAuthnCredentialType; protected SigningInPage.CredentialType webAuthnPwdlessCredentialType; @@ -187,4 +192,16 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple .defineAsBrowserFlow() // Activate this new flow ); } + + protected 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/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 d1052c839e..288f2e5325 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 @@ -27,7 +27,6 @@ import org.keycloak.models.credential.WebAuthnCredentialModel; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.testsuite.ui.account2.page.SigningInPage; -import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.webauthn.pages.WebAuthnAuthenticatorsList; import org.keycloak.testsuite.webauthn.pages.WebAuthnLoginPage; import org.keycloak.theme.DateTimeFormatterUtil; @@ -58,9 +57,6 @@ import static org.keycloak.testsuite.util.WaitUtils.waitForPageToLoad; public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest { - @Page - protected WebAuthnLoginPage webAuthnLoginPage; - @Test public void categoriesTest() { testContext.setTestRealmReps(emptyList()); // reimport realm after this test @@ -451,16 +447,4 @@ public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest { 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/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnTransportLocaleTest.java b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnTransportLocaleTest.java new file mode 100644 index 0000000000..933cac1ee2 --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/webauthn/src/test/java/org/keycloak/testsuite/webauthn/account/WebAuthnTransportLocaleTest.java @@ -0,0 +1,112 @@ +/* + * Copyright 2022 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.account; + +import org.hamcrest.Matchers; +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Test; +import org.keycloak.testsuite.webauthn.pages.WebAuthnAuthenticatorsList; +import org.keycloak.testsuite.webauthn.pages.WebAuthnLoginPage; +import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions.DEFAULT_BLE; +import static org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions.DEFAULT_INTERNAL; +import static org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions.DEFAULT_NFC; +import static org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions.DEFAULT_USB; + +/** + * Test for checking localization for authenticator transport media name + * + * @author Martin Bartos + */ +public class WebAuthnTransportLocaleTest extends AbstractWebAuthnAccountTest { + + @Test + public void localizationTransportUSB() { + assertLocalizationIndividual(DEFAULT_USB.getOptions(), "USB", "USB"); + } + + @Test + public void localizationTransportNFC() { + assertLocalizationIndividual(DEFAULT_NFC.getOptions(), "NFC", "NFC"); + } + + @Test + public void localizationTransportBluetooth() { + assertLocalizationIndividual(DEFAULT_BLE.getOptions(), "Bluetooth", "Bluetooth"); + } + + @Test + public void localizationTransportInternal() { + assertLocalizationIndividual(DEFAULT_INTERNAL.getOptions(), "Internal", "Interní"); + } + + private void assertLocalizationIndividual(VirtualAuthenticatorOptions options, String originalName, String localizedText) { + final Consumer checkTransportName = (requiredName) -> { + WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); + assertThat(authenticators, notNullValue()); + assertThat(authenticators.getCount(), is(1)); + assertThat(authenticators.getLabels(), Matchers.contains("authenticator#1")); + + List items = authenticators.getItems(); + assertThat(items, notNullValue()); + assertThat(items.size(), is(1)); + + WebAuthnAuthenticatorsList.WebAuthnAuthenticatorItem item = items.get(0); + assertThat(item, notNullValue()); + assertThat(item.getTransport(), is(requiredName)); + }; + + try (Closeable c = setLocalesUpdater(Locale.ENGLISH.getLanguage(), "cs").update()) { + + getWebAuthnManager().useAuthenticator(options); + addWebAuthnCredential("authenticator#1"); + + final int webAuthnCount = webAuthnCredentialType.getUserCredentialsCount(); + assertThat(webAuthnCount, is(1)); + + setUpWebAuthnFlow("webAuthnFlow"); + logout(); + + signingInPage.navigateTo(); + loginToAccount(); + + webAuthnLoginPage.assertCurrent(); + + checkTransportName.accept(originalName); + + webAuthnLoginPage.openLanguage("Čeština"); + + checkTransportName.accept(localizedText); + + webAuthnLoginPage.clickAuthenticate(); + signingInPage.assertCurrent(); + } catch (IOException e) { + throw new RuntimeException("Cannot update locale.", e); + } + } +} 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 66164e1d97..c4bfcc1bc0 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 @@ -46,6 +46,8 @@ codeErrorTitle=Kód chyby\: {0} displayUnsupported=Požadovaný typ zobrazení není podporovaný browserRequired=Pro přihlášení je vyžadován prohlížeč browserContinue=Pro dokončení přihlášení je vyžadován prohlížeč +internal=Interní +unknown=Neznámé termsTitle=Smluvní podmínky termsTitleHtml=Smluvní podmínky 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 f5a65929ec..81104ded47 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 @@ -52,6 +52,12 @@ browserContinue=Browser required to complete login browserContinuePrompt=Open browser and continue login? [y/n]: browserContinueAnswer=y +# Transports +usb=USB +nfc=NFC +bluetooth=Bluetooth +internal=Internal +unknown=Unknown termsTitle=Terms and Conditions termsText=

Terms and conditions to be defined

diff --git a/themes/src/main/resources/theme/base/login/select-authenticator.ftl b/themes/src/main/resources/theme/base/login/select-authenticator.ftl index c4097dbe15..3a73174298 100644 --- a/themes/src/main/resources/theme/base/login/select-authenticator.ftl +++ b/themes/src/main/resources/theme/base/login/select-authenticator.ftl @@ -18,7 +18,7 @@
- +
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 f35f63283f..2266991e31 100644 --- a/themes/src/main/resources/theme/base/login/webauthn-authenticate.ftl +++ b/themes/src/main/resources/theme/base/login/webauthn-authenticate.ftl @@ -25,26 +25,38 @@ <#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators> <#if authenticators.authenticators?size gt 1> -

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

+

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

<#list authenticators.authenticators as authenticator>
- +
- ${msg('${authenticator.label}')} + ${kcSanitize(msg('${authenticator.label}'))?no_esc}
+ + <#if authenticator.transports??> +
+ <#list authenticator.transports.displayNameProperties as nameProperty> + ${kcSanitize(msg('${nameProperty!}'))?no_esc} + <#if nameProperty?has_next> + , + + +
+
- ${msg('webauthn-createdAt-label')} + ${kcSanitize(msg('webauthn-createdAt-label'))?no_esc} - ${authenticator.createdAt} + ${kcSanitize(authenticator.createdAt)?no_esc}
diff --git a/themes/src/main/resources/theme/base/login/webauthn-register.ftl b/themes/src/main/resources/theme/base/login/webauthn-register.ftl index 5002ee72df..3d76241063 100644 --- a/themes/src/main/resources/theme/base/login/webauthn-register.ftl +++ b/themes/src/main/resources/theme/base/login/webauthn-register.ftl @@ -13,6 +13,7 @@ +
@@ -103,6 +104,12 @@ $("#attestationObject").val(base64url.encode(new Uint8Array(attestationObject), {pad: false})); $("#publicKeyCredentialId").val(base64url.encode(new Uint8Array(publicKeyCredentialId), {pad: false})); + + let transports = result.response.getTransports(); + if (transports) { + $("#transports").val(getTransportsAsString(transports)); + } + let initLabel = "WebAuthn Authenticator (Default Label)"; let labelResult = window.prompt("Please input your registered authenticator's label", initLabel); if (labelResult === null) labelResult = initLabel; @@ -150,6 +157,18 @@ } return excludeCredentials; } + + function getTransportsAsString(transportsList) { + if (transportsList === '' || transportsList.constructor !== Array) return ""; + + let transportsString = ""; + + for (let i = 0; i < transportsList.length; i++) { + transportsString += transportsList[i] + ","; + } + + return transportsString.slice(0, -1); + }