Store information about transport media of WebAuthn authenticator

Closes #9800
This commit is contained in:
Martin Bartoš 2022-02-03 14:39:51 +01:00 committed by Marek Posolda
parent 07d43f31f3
commit d82122b982
19 changed files with 510 additions and 36 deletions

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@ -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<String> transports) {
WebAuthnCredentialData credentialData = new WebAuthnCredentialData(aaguid, credentialId, counter, attestationStatement, credentialPublicKey, attestationStatementFormat, transports);
WebAuthnSecretData secretData = new WebAuthnSecretData();
WebAuthnCredentialModel credentialModel = new WebAuthnCredentialModel(credentialType, credentialData, secretData);

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@ -32,6 +36,7 @@ public class WebAuthnCredentialData {
private String attestationStatement;
private String credentialPublicKey;
private String attestationStatementFormat;
private Set<String> 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<String> 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<String> getTransports() {
return transports != null ? transports : Collections.emptySet();
}
public void setTransports(Set<String> 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()) +
" }";
}
}

View file

@ -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";

View file

@ -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<String> 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<AuthenticatorTransport> 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 {

View file

@ -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<AuthenticatorTransport> transports;
public WebAuthnCredentialModelInput(String credentialType) {
this.credentialType = credentialType;
@ -115,6 +121,14 @@ public class WebAuthnCredentialModelInput implements CredentialInput {
this.attestationStatementFormat = attestationStatementFormat;
}
public Set<AuthenticatorTransport> getTransports() {
return transports;
}
public void setTransports(Set<AuthenticatorTransport> 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();

View file

@ -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<WebAuthnCr
long counter = webAuthnModel.getCount();
String attestationStatementFormat = webAuthnModel.getAttestationStatementFormat();
WebAuthnCredentialModel model = WebAuthnCredentialModel.create(getType(), userLabel, aaguid, credentialId, null, credentialPublicKey, counter, attestationStatementFormat);
final Set<String> 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());

View file

@ -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<WebAuthnAuthenticatorBean> authenticators = new LinkedList<WebAuthnAuthenticatorBean>();
private final List<WebAuthnAuthenticatorBean> 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<String> 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<String> 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<String> displayNameProperties;
private final String iconClass;
public TransportsBean(Set<String> 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<String> 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<String> transports) {
if (CollectionUtils.isEmpty(transports)) {
return new TransportsBean(Transport.UNKNOWN);
}
final Set<Transport> 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<String> 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);
}
}
}
}
}

View file

@ -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;
}
}
}

View file

@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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<WebAuthnAuthenticatorsList.WebAuthnAuthenticatorItem> items = authenticatorsList.getItems();
assertThat(items, notNullValue());
assertThat(items.size(), is(1));
assertThat(items.get(0).getTransport(), is(transportName));
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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<String> checkTransportName = (requiredName) -> {
WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators();
assertThat(authenticators, notNullValue());
assertThat(authenticators.getCount(), is(1));
assertThat(authenticators.getLabels(), Matchers.contains("authenticator#1"));
List<WebAuthnAuthenticatorsList.WebAuthnAuthenticatorItem> 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);
}
}
}

View file

@ -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

View file

@ -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=<p>Terms and conditions to be defined</p>

View file

@ -18,7 +18,7 @@
<div class="${properties.kcSelectAuthListItemClass!}" onclick="fillAndSubmit('${authenticationSelection.authExecId}')">
<div class="${properties.kcSelectAuthListItemIconClass!}">
<i class="${properties['${authenticationSelection.iconCssClass}']!authenticationSelection.iconCssClass} fa-2x"></i>
<i class="${properties['${authenticationSelection.iconCssClass}']!authenticationSelection.iconCssClass} ${properties.kcSelectAuthListItemIconPropertyClass!}"></i>
</div>
<div class="${properties.kcSelectAuthListItemBodyClass!}">
<div class="${properties.kcSelectAuthListItemHeadingClass!}">

View file

@ -25,26 +25,38 @@
<#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators>
<#if authenticators.authenticators?size gt 1>
<p class="${properties.kcSelectAuthListItemTitle!}">${kcSanitize(msg("webauthn-available-authenticators"))}</p>
<p class="${properties.kcSelectAuthListItemTitle!}">${kcSanitize(msg("webauthn-available-authenticators"))?no_esc}</p>
</#if>
<div class="${properties.kcFormClass!}">
<#list authenticators.authenticators as authenticator>
<div id="kc-webauthn-authenticator" class="${properties.kcSelectAuthListItemClass!}">
<div class="${properties.kcSelectAuthListItemIconClass!}">
<i class="${properties.kcWebAuthnKeyIcon} fa-2x"></i>
<i class="${(properties['${authenticator.transports.iconClass}'])!'${properties.kcWebAuthnDefaultIcon!}'} ${properties.kcSelectAuthListItemIconPropertyClass!}"></i>
</div>
<div class="${properties.kcSelectAuthListItemBodyClass!}">
<div id="kc-webauthn-authenticator-label"
class="${properties.kcSelectAuthListItemHeadingClass!}">
${msg('${authenticator.label}')}
${kcSanitize(msg('${authenticator.label}'))?no_esc}
</div>
<#if authenticator.transports??>
<div id="kc-webauthn-authenticator-transport"
class="${properties.kcSelectAuthListItemDescriptionClass!}">
<#list authenticator.transports.displayNameProperties as nameProperty>
<span>${kcSanitize(msg('${nameProperty!}'))?no_esc}</span>
<#if nameProperty?has_next>
<span>, </span>
</#if>
</#list>
</div>
</#if>
<div class="${properties.kcSelectAuthListItemDescriptionClass!}">
<span id="kc-webauthn-authenticator-created-label">
${msg('webauthn-createdAt-label')}
${kcSanitize(msg('webauthn-createdAt-label'))?no_esc}
</span>
<span id="kc-webauthn-authenticator-created">
${authenticator.createdAt}
${kcSanitize(authenticator.createdAt)?no_esc}
</span>
</div>
</div>

View file

@ -13,6 +13,7 @@
<input type="hidden" id="attestationObject" name="attestationObject"/>
<input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId"/>
<input type="hidden" id="authenticatorLabel" name="authenticatorLabel"/>
<input type="hidden" id="transports" name="transports"/>
<input type="hidden" id="error" name="error"/>
</div>
</form>
@ -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);
}
</script>
<input type="submit"

View file

@ -274,6 +274,19 @@ div.kc-logo-text span {
color: var(--pf-global--Color--300);
}
#kc-form-webauthn .select-auth-box-icon {
flex: 0 0 3em;
}
#kc-form-webauthn .select-auth-box-icon-properties {
margin-top: 10px;
font-size: 1.8em;
}
#kc-form-webauthn .pf-l-stack__item {
margin: -1px 0;
}
#kc-content-wrapper {
margin-top: 20px;
}

View file

@ -49,7 +49,14 @@ kcFeedbackSuccessIcon=fa fa-fw fa-check-circle
kcFeedbackInfoIcon=fa fa-fw fa-info-circle
kcResetFlowIcon=pficon pficon-arrow fa
# WebAuthn icons
kcWebAuthnKeyIcon=pficon pficon-key
kcWebAuthnDefaultIcon=pficon pficon-key
kcWebAuthnUSB=fa fa-usb
kcWebAuthnNFC=fa fa-wifi
kcWebAuthnBLE=fa fa-bluetooth-b
kcWebAuthnInternal=pficon pficon-key
kcFormClass=form-horizontal
kcFormGroupClass=form-group
@ -100,6 +107,7 @@ kcSrOnlyClass=sr-only
kcSelectAuthListClass=pf-l-stack select-auth-container
kcSelectAuthListItemClass=pf-l-stack__item select-auth-box-parent pf-l-split
kcSelectAuthListItemIconClass=pf-l-split__item select-auth-box-icon
kcSelectAuthListItemIconPropertyClass=fa-2x select-auth-box-icon-properties
kcSelectAuthListItemBodyClass=pf-l-split__item pf-l-stack
kcSelectAuthListItemHeadingClass=pf-l-stack__item select-auth-box-headline pf-c-title
kcSelectAuthListItemDescriptionClass=pf-l-stack__item select-auth-box-desc