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.models.credential.dto.WebAuthnSecretData;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import java.util.Collections;
import java.util.Set;
/** /**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/ */
@ -49,7 +52,12 @@ public class WebAuthnCredentialModel extends CredentialModel {
public static WebAuthnCredentialModel create(String credentialType, String userLabel, String aaguid, String credentialId, public static WebAuthnCredentialModel create(String credentialType, String userLabel, String aaguid, String credentialId,
String attestationStatement, String credentialPublicKey, long counter, String attestationStatementFormat) { String attestationStatement, String credentialPublicKey, long counter, String attestationStatementFormat) {
WebAuthnCredentialData credentialData = new WebAuthnCredentialData(aaguid, credentialId, counter, attestationStatement, credentialPublicKey, 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(); WebAuthnSecretData secretData = new WebAuthnSecretData();
WebAuthnCredentialModel credentialModel = new WebAuthnCredentialModel(credentialType, credentialData, secretData); 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.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty; 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> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/ */
@ -32,6 +36,7 @@ public class WebAuthnCredentialData {
private String attestationStatement; private String attestationStatement;
private String credentialPublicKey; private String credentialPublicKey;
private String attestationStatementFormat; private String attestationStatementFormat;
private Set<String> transports;
@JsonCreator @JsonCreator
public WebAuthnCredentialData(@JsonProperty("aaguid") String aaguid, public WebAuthnCredentialData(@JsonProperty("aaguid") String aaguid,
@ -39,13 +44,15 @@ public class WebAuthnCredentialData {
@JsonProperty("counter") long counter, @JsonProperty("counter") long counter,
@JsonProperty("attestationStatement") String attestationStatement, @JsonProperty("attestationStatement") String attestationStatement,
@JsonProperty("credentialPublicKey") String credentialPublicKey, @JsonProperty("credentialPublicKey") String credentialPublicKey,
@JsonProperty("attestationStatementFormat") String attestationStatementFormat) { @JsonProperty("attestationStatementFormat") String attestationStatementFormat,
@JsonProperty("transports") Set<String> transports) {
this.aaguid = aaguid; this.aaguid = aaguid;
this.credentialId = credentialId; this.credentialId = credentialId;
this.counter = counter; this.counter = counter;
this.attestationStatement = attestationStatement; this.attestationStatement = attestationStatement;
this.credentialPublicKey = credentialPublicKey; this.credentialPublicKey = credentialPublicKey;
this.attestationStatementFormat = attestationStatementFormat; this.attestationStatementFormat = attestationStatementFormat;
this.transports = transports;
} }
public String getAaguid() { public String getAaguid() {
@ -80,6 +87,14 @@ public class WebAuthnCredentialData {
this.attestationStatementFormat = attestationStatementFormat; this.attestationStatementFormat = attestationStatementFormat;
} }
public Set<String> getTransports() {
return transports != null ? transports : Collections.emptySet();
}
public void setTransports(Set<String> transports) {
this.transports = transports;
}
@Override @Override
public String toString() { public String toString() {
return "WebAuthnCredentialData { " + return "WebAuthnCredentialData { " +
@ -90,6 +105,7 @@ public class WebAuthnCredentialData {
", attestationStatement='" + attestationStatement + '\'' + ", attestationStatement='" + attestationStatement + '\'' +
", credentialPublicKey='" + credentialPublicKey + '\'' + ", credentialPublicKey='" + credentialPublicKey + '\'' +
", attestationStatementFormat='" + attestationStatementFormat + '\'' + ", attestationStatementFormat='" + attestationStatementFormat + '\'' +
", transports=" + Arrays.toString(getTransports().toArray()) +
" }"; " }";
} }
} }

View file

@ -44,6 +44,8 @@ public interface WebAuthnConstants {
String ALLOWED_AUTHENTICATORS = "authenticators"; String ALLOWED_AUTHENTICATORS = "authenticators";
String IS_USER_IDENTIFIED = "isUserIdentified"; String IS_USER_IDENTIFIED = "isUserIdentified";
String USER_VERIFICATION = "userVerification"; String USER_VERIFICATION = "userVerification";
String TRANSPORTS = "transports";
String IS_SET_RETRY = "isSetRetry"; String IS_SET_RETRY = "isSetRetry";
String SHOULD_DISPLAY_AUTHENTICATORS = "shouldDisplayAuthenticators"; String SHOULD_DISPLAY_AUTHENTICATORS = "shouldDisplayAuthenticators";

View file

@ -20,7 +20,9 @@ import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
@ -28,6 +30,8 @@ import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import com.webauthn4j.WebAuthnRegistrationManager; import com.webauthn4j.WebAuthnRegistrationManager;
import com.webauthn4j.data.AuthenticatorTransport;
import org.apache.commons.collections4.CollectionUtils;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.WebAuthnConstants; 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.certpath.CertPathTrustworthinessValidator;
import com.webauthn4j.validator.attestation.trustworthiness.self.DefaultSelfAttestationTrustworthinessValidator; import com.webauthn4j.validator.attestation.trustworthiness.self.DefaultSelfAttestationTrustworthinessValidator;
import org.keycloak.models.credential.WebAuthnCredentialModel; 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_DETAIL_LABEL;
import static org.keycloak.WebAuthnConstants.REG_ERR_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 // check User Verification by considering a malicious user might modify the result of calling WebAuthn API
boolean isUserVerificationRequired = policy.getUserVerificationRequirement().equals(WebAuthnConstants.OPTION_REQUIRED); 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); RegistrationParameters registrationParameters = new RegistrationParameters(serverProperty, isUserVerificationRequired);
WebAuthnRegistrationManager webAuthnRegistrationManager = createWebAuthnRegistrationManager(); WebAuthnRegistrationManager webAuthnRegistrationManager = createWebAuthnRegistrationManager();
@ -224,6 +239,7 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis
credential.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData()); credential.setAttestedCredentialData(registrationData.getAttestationObject().getAuthenticatorData().getAttestedCredentialData());
credential.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount()); credential.setCount(registrationData.getAttestationObject().getAuthenticatorData().getSignCount());
credential.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat()); credential.setAttestationStatementFormat(registrationData.getAttestationObject().getFormat());
credential.setTransports(registrationData.getTransports());
// Save new webAuthn credential // Save new webAuthn credential
WebAuthnCredentialProvider webAuthnCredProvider = (WebAuthnCredentialProvider) this.session.getProvider(CredentialProvider.class, getCredentialProviderId()); WebAuthnCredentialProvider webAuthnCredProvider = (WebAuthnCredentialProvider) this.session.getProvider(CredentialProvider.class, getCredentialProviderId());
@ -305,9 +321,17 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis
private void showInfoAfterWebAuthnApiCreate(RegistrationData response) { private void showInfoAfterWebAuthnApiCreate(RegistrationData response) {
AttestedCredentialData attestedCredentialData = response.getAttestationObject().getAuthenticatorData().getAttestedCredentialData(); AttestedCredentialData attestedCredentialData = response.getAttestationObject().getAuthenticatorData().getAttestedCredentialData();
AttestationStatement attestationStatement = response.getAttestationObject().getAttestationStatement(); 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("createad key's algorithm = {0}", String.valueOf(attestedCredentialData.getCOSEKey().getAlgorithm().getValue()));
logger.debugv("aaguid = {0}", attestedCredentialData.getAaguid().toString()); logger.debugv("aaguid = {0}", attestedCredentialData.getAaguid().toString());
logger.debugv("attestation format = {0}", attestationStatement.getFormat()); 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 { private void checkAcceptedAuthenticator(RegistrationData response, WebAuthnPolicy policy) throws Exception {

View file

@ -16,14 +16,19 @@
package org.keycloak.credential; package org.keycloak.credential;
import org.apache.commons.collections4.CollectionUtils;
import org.keycloak.common.util.Base64; import org.keycloak.common.util.Base64;
import com.webauthn4j.data.AuthenticationParameters; import com.webauthn4j.data.AuthenticationParameters;
import com.webauthn4j.data.AuthenticationRequest; import com.webauthn4j.data.AuthenticationRequest;
import com.webauthn4j.data.AuthenticatorTransport;
import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData; import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData;
import com.webauthn4j.data.attestation.authenticator.COSEKey; import com.webauthn4j.data.attestation.authenticator.COSEKey;
import com.webauthn4j.data.attestation.statement.AttestationStatement; import com.webauthn4j.data.attestation.statement.AttestationStatement;
import java.util.Set;
import java.util.stream.Collectors;
public class WebAuthnCredentialModelInput implements CredentialInput { public class WebAuthnCredentialModelInput implements CredentialInput {
private AttestedCredentialData attestedCredentialData; private AttestedCredentialData attestedCredentialData;
@ -34,6 +39,7 @@ public class WebAuthnCredentialModelInput implements CredentialInput {
private String credentialDBId; private String credentialDBId;
private final String credentialType; private final String credentialType;
private String attestationStatementFormat; private String attestationStatementFormat;
private Set<AuthenticatorTransport> transports;
public WebAuthnCredentialModelInput(String credentialType) { public WebAuthnCredentialModelInput(String credentialType) {
this.credentialType = credentialType; this.credentialType = credentialType;
@ -115,6 +121,14 @@ public class WebAuthnCredentialModelInput implements CredentialInput {
this.attestationStatementFormat = attestationStatementFormat; this.attestationStatementFormat = attestationStatementFormat;
} }
public Set<AuthenticatorTransport> getTransports() {
return transports;
}
public void setTransports(Set<AuthenticatorTransport> transports) {
this.transports = transports;
}
public String toString() { public String toString() {
StringBuilder sb = new StringBuilder("Credential Type = " + credentialType + ","); StringBuilder sb = new StringBuilder("Credential Type = " + credentialType + ",");
if (credentialDBId != null) if (credentialDBId != null)
@ -156,6 +170,15 @@ public class WebAuthnCredentialModelInput implements CredentialInput {
.append(Base64.encodeBytes(authenticationRequest.getCredentialId())) .append(Base64.encodeBytes(authenticationRequest.getCredentialId()))
.append(","); .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) if (sb.length() > 0)
sb.deleteCharAt(sb.lastIndexOf(",")); sb.deleteCharAt(sb.lastIndexOf(","));
return sb.toString(); return sb.toString();

View file

@ -19,10 +19,12 @@ package org.keycloak.credential;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.webauthn4j.WebAuthnAuthenticationManager; import com.webauthn4j.WebAuthnAuthenticationManager;
import com.webauthn4j.converter.util.ObjectConverter; import com.webauthn4j.converter.util.ObjectConverter;
import com.webauthn4j.data.AuthenticatorTransport;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.common.util.Base64; import org.keycloak.common.util.Base64;
@ -105,7 +107,22 @@ public class WebAuthnCredentialProvider implements CredentialProvider<WebAuthnCr
long counter = webAuthnModel.getCount(); long counter = webAuthnModel.getCount();
String attestationStatementFormat = webAuthnModel.getAttestationStatementFormat(); 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()); model.setId(webAuthnModel.getCredentialDBId());

View file

@ -15,20 +15,26 @@
*/ */
package org.keycloak.forms.login.freemarker.model; package org.keycloak.forms.login.freemarker.model;
import java.util.LinkedList; import com.webauthn4j.data.AuthenticatorTransport;
import java.util.List; import org.apache.commons.collections4.CollectionUtils;
import java.util.stream.Collectors;
import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.Base64Url;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.credential.WebAuthnCredentialModel; import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.theme.DateTimeFormatterUtil; 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 { 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) { public WebAuthnAuthenticatorsBean(KeycloakSession session, RealmModel realm, UserModel user, String credentialType) {
// should consider multiple credentials in the future, but only single credential supported now. // 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 credentialId = Base64Url.encodeBase64ToBase64Url(webAuthnCredential.getWebAuthnCredentialData().getCredentialId());
String label = (webAuthnCredential.getUserLabel() == null || webAuthnCredential.getUserLabel().isEmpty()) ? "label missing" : webAuthnCredential.getUserLabel(); String label = (webAuthnCredential.getUserLabel() == null || webAuthnCredential.getUserLabel().isEmpty()) ? "label missing" : webAuthnCredential.getUserLabel();
String createdAt = DateTimeFormatterUtil.getDateTimeFromMillis(webAuthnCredential.getCreatedDate(), session.getContext().resolveLocale(user)); 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()); }).collect(Collectors.toList());
} }
@ -47,14 +55,18 @@ public class WebAuthnAuthenticatorsBean {
} }
public static class WebAuthnAuthenticatorBean { public static class WebAuthnAuthenticatorBean {
public static final String DEFAULT_ICON = "kcWebAuthnDefaultIcon";
private final String credentialId; private final String credentialId;
private final String label; private final String label;
private final String createdAt; 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.credentialId = credentialId;
this.label = label; this.label = label;
this.createdAt = createdAt; this.createdAt = createdAt;
this.transports = TransportsBean.convertFromSet(transports);
} }
public String getCredentialId() { public String getCredentialId() {
@ -68,5 +80,118 @@ public class WebAuthnAuthenticatorsBean {
public String getCreatedAt() { public String getCreatedAt() {
return this.createdAt; 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 name = getTextFromElement(auth.findElement(By.id("kc-webauthn-authenticator-label")));
String createdAt = getTextFromElement(auth.findElement(By.id("kc-webauthn-authenticator-created"))); String createdAt = getTextFromElement(auth.findElement(By.id("kc-webauthn-authenticator-created")));
String createdAtLabel = getTextFromElement(auth.findElement(By.id("kc-webauthn-authenticator-created-label"))); 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; return items;
} catch (NoSuchElementException e) { } catch (NoSuchElementException e) {
@ -78,11 +79,13 @@ public class WebAuthnAuthenticatorsList {
private final String name; private final String name;
private final String createdAt; private final String createdAt;
private final String createdAtLabel; 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.name = name;
this.createdAt = createdAt; this.createdAt = createdAt;
this.createdAtLabel = createdAtLabel; this.createdAtLabel = createdAtLabel;
this.transport = transport;
} }
public String getName() { public String getName() {
@ -96,5 +99,9 @@ public class WebAuthnAuthenticatorsList {
public String getCreatedLabel() { public String getCreatedLabel() {
return createdAtLabel; 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.page.AbstractPatternFlyAlert;
import org.keycloak.testsuite.ui.account2.page.SigningInPage; import org.keycloak.testsuite.ui.account2.page.SigningInPage;
import org.keycloak.testsuite.ui.account2.page.utils.SigningInPageUtils; 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.util.FlowUtil;
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest; import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions;
import org.keycloak.testsuite.webauthn.authenticators.UseVirtualAuthenticators; import org.keycloak.testsuite.webauthn.authenticators.UseVirtualAuthenticators;
import org.keycloak.testsuite.webauthn.authenticators.VirtualAuthenticatorManager; import org.keycloak.testsuite.webauthn.authenticators.VirtualAuthenticatorManager;
import org.keycloak.testsuite.webauthn.pages.WebAuthnLoginPage;
import org.keycloak.testsuite.webauthn.pages.WebAuthnRegisterPage; import org.keycloak.testsuite.webauthn.pages.WebAuthnRegisterPage;
import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions;
@ -56,6 +58,9 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple
@Page @Page
protected WebAuthnRegisterPage webAuthnRegisterPage; protected WebAuthnRegisterPage webAuthnRegisterPage;
@Page
protected WebAuthnLoginPage webAuthnLoginPage;
private VirtualAuthenticatorManager webAuthnManager; private VirtualAuthenticatorManager webAuthnManager;
protected SigningInPage.CredentialType webAuthnCredentialType; protected SigningInPage.CredentialType webAuthnCredentialType;
protected SigningInPage.CredentialType webAuthnPwdlessCredentialType; protected SigningInPage.CredentialType webAuthnPwdlessCredentialType;
@ -187,4 +192,16 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple
.defineAsBrowserFlow() // Activate this new flow .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.CredentialRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.testsuite.ui.account2.page.SigningInPage; 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.WebAuthnAuthenticatorsList;
import org.keycloak.testsuite.webauthn.pages.WebAuthnLoginPage; import org.keycloak.testsuite.webauthn.pages.WebAuthnLoginPage;
import org.keycloak.theme.DateTimeFormatterUtil; import org.keycloak.theme.DateTimeFormatterUtil;
@ -58,9 +57,6 @@ import static org.keycloak.testsuite.util.WaitUtils.waitForPageToLoad;
public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest { public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest {
@Page
protected WebAuthnLoginPage webAuthnLoginPage;
@Test @Test
public void categoriesTest() { public void categoriesTest() {
testContext.setTestRealmReps(emptyList()); // reimport realm after this test testContext.setTestRealmReps(emptyList()); // reimport realm after this test
@ -451,16 +447,4 @@ public class WebAuthnSigningInTest extends AbstractWebAuthnAccountTest {
testRemoveCredential(webAuthn1); 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ý displayUnsupported=Požadovaný typ zobrazení není podporovaný
browserRequired=Pro přihlášení je vyžadován prohlížeč browserRequired=Pro přihlášení je vyžadován prohlížeč
browserContinue=Pro dokončení 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 termsTitle=Smluvní podmínky
termsTitleHtml=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]: browserContinuePrompt=Open browser and continue login? [y/n]:
browserContinueAnswer=y browserContinueAnswer=y
# Transports
usb=USB
nfc=NFC
bluetooth=Bluetooth
internal=Internal
unknown=Unknown
termsTitle=Terms and Conditions termsTitle=Terms and Conditions
termsText=<p>Terms and conditions to be defined</p> 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.kcSelectAuthListItemClass!}" onclick="fillAndSubmit('${authenticationSelection.authExecId}')">
<div class="${properties.kcSelectAuthListItemIconClass!}"> <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>
<div class="${properties.kcSelectAuthListItemBodyClass!}"> <div class="${properties.kcSelectAuthListItemBodyClass!}">
<div class="${properties.kcSelectAuthListItemHeadingClass!}"> <div class="${properties.kcSelectAuthListItemHeadingClass!}">

View file

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

View file

@ -13,6 +13,7 @@
<input type="hidden" id="attestationObject" name="attestationObject"/> <input type="hidden" id="attestationObject" name="attestationObject"/>
<input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId"/> <input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId"/>
<input type="hidden" id="authenticatorLabel" name="authenticatorLabel"/> <input type="hidden" id="authenticatorLabel" name="authenticatorLabel"/>
<input type="hidden" id="transports" name="transports"/>
<input type="hidden" id="error" name="error"/> <input type="hidden" id="error" name="error"/>
</div> </div>
</form> </form>
@ -103,6 +104,12 @@
$("#attestationObject").val(base64url.encode(new Uint8Array(attestationObject), {pad: false})); $("#attestationObject").val(base64url.encode(new Uint8Array(attestationObject), {pad: false}));
$("#publicKeyCredentialId").val(base64url.encode(new Uint8Array(publicKeyCredentialId), {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 initLabel = "WebAuthn Authenticator (Default Label)";
let labelResult = window.prompt("Please input your registered authenticator's label", initLabel); let labelResult = window.prompt("Please input your registered authenticator's label", initLabel);
if (labelResult === null) labelResult = initLabel; if (labelResult === null) labelResult = initLabel;
@ -150,6 +157,18 @@
} }
return excludeCredentials; 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> </script>
<input type="submit" <input type="submit"

View file

@ -274,6 +274,19 @@ div.kc-logo-text span {
color: var(--pf-global--Color--300); 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 { #kc-content-wrapper {
margin-top: 20px; margin-top: 20px;
} }

View file

@ -49,7 +49,14 @@ kcFeedbackSuccessIcon=fa fa-fw fa-check-circle
kcFeedbackInfoIcon=fa fa-fw fa-info-circle kcFeedbackInfoIcon=fa fa-fw fa-info-circle
kcResetFlowIcon=pficon pficon-arrow fa kcResetFlowIcon=pficon pficon-arrow fa
# WebAuthn icons
kcWebAuthnKeyIcon=pficon pficon-key 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 kcFormClass=form-horizontal
kcFormGroupClass=form-group kcFormGroupClass=form-group
@ -100,6 +107,7 @@ kcSrOnlyClass=sr-only
kcSelectAuthListClass=pf-l-stack select-auth-container kcSelectAuthListClass=pf-l-stack select-auth-container
kcSelectAuthListItemClass=pf-l-stack__item select-auth-box-parent pf-l-split kcSelectAuthListItemClass=pf-l-stack__item select-auth-box-parent pf-l-split
kcSelectAuthListItemIconClass=pf-l-split__item select-auth-box-icon 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 kcSelectAuthListItemBodyClass=pf-l-split__item pf-l-stack
kcSelectAuthListItemHeadingClass=pf-l-stack__item select-auth-box-headline pf-c-title kcSelectAuthListItemHeadingClass=pf-l-stack__item select-auth-box-headline pf-c-title
kcSelectAuthListItemDescriptionClass=pf-l-stack__item select-auth-box-desc kcSelectAuthListItemDescriptionClass=pf-l-stack__item select-auth-box-desc