KEYCLOAK-12469 KEYCLOAK-12185 Implement nice design to the screen wit… (#6690)

* KEYCLOAK-12469 KEYCLOAK-12185 Add CredentialTypeMetadata. Implement the screen with authentication mechanisms and implement Account REST Credentials API by use the credential type metadata
This commit is contained in:
Marek Posolda 2020-01-31 14:28:23 +01:00 committed by GitHub
parent 6ac5a2a17e
commit d8e450719b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 918 additions and 235 deletions

View file

@ -1,16 +1,24 @@
package org.keycloak.authentication; package org.keycloak.authentication;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
public class AuthenticationSelectionOption { public class AuthenticationSelectionOption {
private final KeycloakSession session;
private final AuthenticationExecutionModel authExec; private final AuthenticationExecutionModel authExec;
private final CredentialTypeMetadata credentialTypeMetadata;
public AuthenticationSelectionOption(KeycloakSession session, AuthenticationExecutionModel authExec) { public AuthenticationSelectionOption(KeycloakSession session, AuthenticationExecutionModel authExec) {
this.session = session;
this.authExec = authExec; this.authExec = authExec;
Authenticator authenticator = session.getProvider(Authenticator.class, authExec.getAuthenticator());
if (authenticator instanceof CredentialValidator) {
CredentialProvider credentialProvider = ((CredentialValidator) authenticator).getCredentialProvider(session);
credentialTypeMetadata = credentialProvider.getCredentialTypeMetadata();
} else {
credentialTypeMetadata = null;
}
} }
@ -22,15 +30,18 @@ public class AuthenticationSelectionOption {
return authExec.getId(); return authExec.getId();
} }
public String getAuthExecName() { public String getDisplayName() {
return authExec.getAuthenticator(); return credentialTypeMetadata == null ? authExec.getAuthenticator() + "-display-name" : credentialTypeMetadata.getDisplayName();
} }
public String getAuthExecDisplayName() { public String getHelpText() {
// TODO: Retrieve the displayName for the authenticator from the AuthenticationFactory return credentialTypeMetadata == null ? authExec.getAuthenticator() + "-help-text" : credentialTypeMetadata.getHelpText();
// TODO: Retrieve icon CSS style }
// TODO: Should be addressed as part of https://issues.redhat.com/browse/KEYCLOAK-12185
return getAuthExecName(); public String getIconCssClass() {
// For now, we won't allow to retrieve "iconCssClass" from the AuthenticatorFactory. We will see in the future if we need
// this capability for authenticator factories, which authenticators don't implement credentialProvider
return credentialTypeMetadata == null ? CredentialTypeMetadata.DEFAULT_ICON_CSS_CLASS : credentialTypeMetadata.getIconCssClass();
} }

View file

@ -68,4 +68,5 @@ public interface ConfigurableAuthenticatorFactory extends ConfiguredProvider {
* @return * @return
*/ */
boolean isUserSetupAllowed(); boolean isUserSetupAllowed();
} }

View file

@ -49,4 +49,6 @@ public interface CredentialProvider<T extends CredentialModel> extends Provider
} }
return getCredentialFromModel(models.get(0)); return getCredentialFromModel(models.get(0));
} }
CredentialTypeMetadata getCredentialTypeMetadata();
} }

View file

@ -0,0 +1,282 @@
/*
* Copyright 2019 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.credential;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionProviderModel;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class CredentialTypeMetadata implements Comparable<CredentialTypeMetadata> {
private static final Logger logger = Logger.getLogger(CredentialTypeMetadata.class);
public static final String DEFAULT_ICON_CSS_CLASS = "kcAuthenticatorDefaultClass";
private String type;
private String displayName;
private String helpText;
private String iconCssClass = DEFAULT_ICON_CSS_CLASS;
private String createAction;
private String updateAction;
private Boolean removeable;
private Category category;
public enum Category {
PASSWORD("password", 1),
TWO_FACTOR("two-factor", 2),
PASSWORDLESS("passwordless", 3);
private String categoryName;
private int order;
Category(String categoryName, int order) {
this.categoryName = categoryName;
this.order = order;
}
@Override
public String toString() {
return categoryName;
}
public int compareWith(Category that) {
return order - that.order;
}
}
private CredentialTypeMetadata() {
}
// GETTERS
/**
* @return credential type like for example "password", "otp" or "webauthn"
*/
public String getType() {
return type;
}
/**
* @return the label, which will be shown to the end user on various screens, like login screen with available authentication mechanisms.
* This label will reference this particular authenticator type.
* It should be clear to end users. For example, implementations can return "Authenticator Application" for OTP or "Security Key" for WebAuthn.
*
* Alternatively, this method can return a message key, so that it is possible to localize it for various languages.
*/
public String getDisplayName() {
return displayName;
}
/**
* @return the text, which will be shown to the user on various screens, like login screen with available authentication mechanisms.
* This text will reference this particular authenticator type.
* For example for OTP, the returned text could be "Enter a verification code from authenticator application" .
*
* Alternatively, this method can return a message key, so that it is possible to localize it for various languages.
*/
public String getHelpText() {
return helpText;
}
/**
* Return the icon CSS, which can be used to display icon, which represents this particular authenticator.
*
* The icon will be displayed on various places. For example the "Select authenticator" screen during login, where user can select from
* various authentication mechanisms for two-factor or passwordless authentication.
*
* The returned value can be either:
* - Key of the property, which will reference the actual CSS in the themes.properties file. For example if you return "kcAuthenticatorWebAuthnClass"
* from this method, then your themes.properties should have the property like for example "kcAuthenticatorWebAuthnClass=fa fa-key list-view-pf-icon-lg" .
* This would mean that "fa fa-key list-view-pf-icon-lg" will be the actual CSS used.
* - the icon CSS class directly. For example you can return "fa fa-key list-view-pf-icon-lg" directly for the above example with WebAuthn.
* This alternative is fine just if your authenticator can use same CSS class for all the themes.
*
* If you don't expect your authenticator to need icon (for example it will never be shown in the "select authenticator" screen), then
* it is fine to keep the default value.
*/
public String getIconCssClass() {
return iconCssClass;
}
/**
* @return the providerID of the required action, which can be used by the user to create new credential of our type. Null if there is no
* action for creating credential. For example we're creating credential in case of "otp" type, but we're updating credential
* in case of type "password"
*/
public String getCreateAction() {
return createAction;
}
/**
* @return the providerID of the required action, which can be used by the user to update credential of our type. Null if there is no
* action for updating credential. For example we're creating credential in case of "otp" type, but we're updating credential
* in case of type "password"
*/
public String getUpdateAction() {
return updateAction;
}
/**
* @return true if user can remove some previously registered credentials of this type.
*/
public boolean isRemoveable() {
return removeable;
}
/**
* @return Category of this credential
*/
public Category getCategory() {
return category;
}
public static CredentialTypeMetadataBuilder builder() {
return new CredentialTypeMetadataBuilder();
}
@Override
public int compareTo(CredentialTypeMetadata other) {
int categoryCompare = category == null ? (other.category == null ? 0 : 1) : (other.category == null ? -1 : category.compareWith(other.category));
if (categoryCompare != 0) return categoryCompare;
int typeCompare = type == null ? (other.type == null ? 0 : 1) : (other.type == null ? -1 : type.compareTo(other.type));
return typeCompare;
}
// BUILDER
public static class CredentialTypeMetadataBuilder {
private CredentialTypeMetadata instance = new CredentialTypeMetadata();
public CredentialTypeMetadataBuilder type(String type) {
instance.type = type;
return this;
}
public CredentialTypeMetadataBuilder displayName(String displayName) {
instance.displayName = displayName;
return this;
}
public CredentialTypeMetadataBuilder helpText(String helpText) {
instance.helpText = helpText;
return this;
}
public CredentialTypeMetadataBuilder iconCssClass(String iconCssClass) {
instance.iconCssClass = iconCssClass;
return this;
}
public CredentialTypeMetadataBuilder createAction(String createAction) {
instance.createAction = createAction;
return this;
}
public CredentialTypeMetadataBuilder updateAction(String updateAction) {
instance.updateAction = updateAction;
return this;
}
public CredentialTypeMetadataBuilder removeable(boolean removeable) {
instance.removeable = removeable;
return this;
}
public CredentialTypeMetadataBuilder category(Category category) {
instance.category = category;
return this;
}
/**
* This will validate metadata and return them
*
* @return metadata
*/
public CredentialTypeMetadata build(KeycloakSession session) {
assertNotNull(instance.type, "type");
assertNotNull(instance.displayName, "displayName");
assertNotNull(instance.helpText, "helpText");
assertNotNull(instance.iconCssClass, "iconCssClass");
assertNotNull(instance.removeable, "removeable");
assertNotNull(instance.category, "category");
if (!verifyRequiredAction(session, instance.createAction)) {
instance.createAction = null;
}
if (!verifyRequiredAction(session, instance.updateAction)) {
instance.updateAction = null;
}
// Assume credential can't have both createAction and updateAction.
if (instance.createAction != null && instance.updateAction != null) {
throw new IllegalStateException("Both createAction and updateAction are not null when building CredentialTypeMetadata for the credential type '" + instance.type);
}
return instance;
}
private void assertNotNull(Object input, String fieldName) {
if (input == null) {
throw new IllegalStateException("Field '" + fieldName + "' is null when building CredentialTypeMetadata for the credential type '" + instance.type);
}
}
// Check if required action of specified providerId is registered in the realm and enabled
private boolean verifyRequiredAction(KeycloakSession session, String requiredActionProviderId) {
if (requiredActionProviderId == null) {
return false;
}
RealmModel realm = session.getContext().getRealm();
if (realm == null) {
logger.warnf("Realm was not set in context when trying to get credential metadata of provider '%s'", instance.type);
return false;
}
for (RequiredActionProviderModel requiredActionProvider : realm.getRequiredActionProviders()) {
if (requiredActionProviderId.equals(requiredActionProvider.getProviderId()) && requiredActionProvider.isEnabled()) {
return true;
}
}
return false;
}
}
}

View file

@ -22,6 +22,7 @@ import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigProperty;
import java.util.List; import java.util.List;
@ -37,7 +38,7 @@ public class WebAuthnAuthenticatorFactory implements AuthenticatorFactory {
@Override @Override
public String getReferenceCategory() { public String getReferenceCategory() {
return "auth"; return WebAuthnCredentialModel.TYPE_TWOFACTOR;
} }
@Override @Override

View file

@ -20,6 +20,7 @@ package org.keycloak.authentication.authenticators.browser;
import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.credential.WebAuthnCredentialModel;
/** /**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -28,6 +29,11 @@ public class WebAuthnPasswordlessAuthenticatorFactory extends WebAuthnAuthentica
public static final String PROVIDER_ID = "webauthn-authenticator-passwordless"; public static final String PROVIDER_ID = "webauthn-authenticator-passwordless";
@Override
public String getReferenceCategory() {
return WebAuthnCredentialModel.TYPE_PASSWORDLESS;
}
@Override @Override
public String getDisplayType() { public String getDisplayType() {
return "WebAuthn Passwordless Authenticator"; return "WebAuthn Passwordless Authenticator";

View file

@ -27,6 +27,7 @@ import javax.ws.rs.core.Response;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.WebAuthnConstants; import org.keycloak.WebAuthnConstants;
import org.keycloak.authentication.CredentialRegistrator; import org.keycloak.authentication.CredentialRegistrator;
import org.keycloak.authentication.InitiatedActionSupport;
import org.keycloak.authentication.RequiredActionContext; import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.Base64Url;
@ -82,6 +83,11 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis
this.certPathtrustValidator = certPathtrustValidator; this.certPathtrustValidator = certPathtrustValidator;
} }
@Override
public InitiatedActionSupport initiatedActionSupport() {
return InitiatedActionSupport.SUPPORTED;
}
@Override @Override
public void requiredActionChallenge(RequiredActionContext context) { public void requiredActionChallenge(RequiredActionContext context) {
UserModel userModel = context.getUser(); UserModel userModel = context.getUser();

View file

@ -18,6 +18,7 @@ package org.keycloak.credential;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.credential.OTPCredentialModel; import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.dto.OTPCredentialData; import org.keycloak.models.credential.dto.OTPCredentialData;
import org.keycloak.models.credential.dto.OTPSecretData; import org.keycloak.models.credential.dto.OTPSecretData;
@ -134,4 +135,17 @@ public class OTPCredentialProvider implements CredentialProvider<OTPCredentialMo
public String getType() { public String getType() {
return OTPCredentialModel.TYPE; return OTPCredentialModel.TYPE;
} }
@Override
public CredentialTypeMetadata getCredentialTypeMetadata() {
return CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.TWO_FACTOR)
.displayName("otp-display-name")
.helpText("otp-help-text")
.iconCssClass("kcAuthenticatorOTPClass")
.createAction(UserModel.RequiredAction.CONFIGURE_TOTP.toString())
.removeable(true)
.build(session);
}
} }

View file

@ -17,6 +17,7 @@
package org.keycloak.credential; package org.keycloak.credential;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.credential.hash.PasswordHashProvider; import org.keycloak.credential.hash.PasswordHashProvider;
import org.keycloak.models.ModelException; import org.keycloak.models.ModelException;
@ -293,4 +294,17 @@ public class PasswordCredentialProvider implements CredentialProvider<PasswordCr
public String getType() { public String getType() {
return PasswordCredentialModel.TYPE; return PasswordCredentialModel.TYPE;
} }
@Override
public CredentialTypeMetadata getCredentialTypeMetadata() {
return CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.PASSWORD)
.displayName("password")
.helpText("password-help-text")
.iconCssClass("kcAuthenticatorPasswordClass")
.updateAction(UserModel.RequiredAction.UPDATE_PASSWORD.toString())
.removeable(false)
.build(session);
}
} }

View file

@ -22,6 +22,7 @@ import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.common.util.Base64; import org.keycloak.common.util.Base64;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
@ -228,4 +229,21 @@ public class WebAuthnCredentialProvider implements CredentialProvider<WebAuthnCr
} }
} }
@Override
public CredentialTypeMetadata getCredentialTypeMetadata() {
return CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.TWO_FACTOR)
.displayName("webauthn-display-name")
.helpText("webauthn-help-text")
.iconCssClass("kcAuthenticatorWebAuthnClass")
.createAction(WebAuthnRegisterFactory.PROVIDER_ID)
.removeable(true)
.build(session);
}
protected KeycloakSession getKeycloakSession() {
return session;
}
} }

View file

@ -19,6 +19,7 @@
package org.keycloak.credential; package org.keycloak.credential;
import com.webauthn4j.converter.util.CborConverter; import com.webauthn4j.converter.util.CborConverter;
import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.credential.WebAuthnCredentialModel; import org.keycloak.models.credential.WebAuthnCredentialModel;
@ -37,4 +38,17 @@ public class WebAuthnPasswordlessCredentialProvider extends WebAuthnCredentialPr
public String getType() { public String getType() {
return WebAuthnCredentialModel.TYPE_PASSWORDLESS; return WebAuthnCredentialModel.TYPE_PASSWORDLESS;
} }
@Override
public CredentialTypeMetadata getCredentialTypeMetadata() {
return CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.PASSWORDLESS)
.displayName("webauthn-passwordless-display-name")
.helpText("webauthn-passwordless-help-text")
.iconCssClass("kcAuthenticatorWebAuthnPasswordlessClass")
.createAction(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID)
.removeable(true)
.build(getKeycloakSession());
}
} }

View file

@ -1,12 +1,15 @@
package org.keycloak.services.resources.account; package org.keycloak.services.resources.account;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.annotations.cache.NoCache;
import org.keycloak.authentication.CredentialRegistrator; import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.credential.CredentialModel; import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider; import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.credential.PasswordCredentialProvider; import org.keycloak.credential.PasswordCredentialProvider;
import org.keycloak.credential.PasswordCredentialProviderFactory; import org.keycloak.credential.PasswordCredentialProviderFactory;
import org.keycloak.credential.UserCredentialStoreManager;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.models.*; import org.keycloak.models.*;
@ -25,13 +28,25 @@ import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.io.IOException; import java.io.IOException;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.keycloak.models.AuthenticationExecutionModel.Requirement.DISABLED;
public class AccountCredentialResource { public class AccountCredentialResource {
public static final String TYPE = "type";
public static final String ENABLED_ONLY = "enabled-only";
public static final String USER_CREDENTIALS = "user-credentials";
private final KeycloakSession session; private final KeycloakSession session;
private final EventBuilder event; private final EventBuilder event;
private final UserModel user; private final UserModel user;
@ -46,42 +61,38 @@ public class AccountCredentialResource {
realm = session.getContext().getRealm(); realm = session.getContext().getRealm();
} }
// TODO: This is kept here for now and commented. The endpoints will be added by team cheetah during work on account console.
// This is here just to show what logic will need to be called in the new endpoints. We may need to remove it and/or change it
// @GET
// @NoCache
// @Produces(javax.ws.rs.core.MediaType.APPLICATION_JSON)
// public List<CredentialRepresentation> credentials(){
// auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
// List<CredentialModel> models = session.userCredentialManager().getStoredCredentials(realm, user);
// models.forEach(c -> c.setSecretData(null));
// return models.stream().map(ModelToRepresentation::toRepresentation).collect(Collectors.toList());
// }
private static class CredentialContainer { public static class CredentialContainer {
// ** These first three attributes can be ordinary UI text or a key into // ** category, displayName and helptext attributes can be ordinary UI text or a key into
// a localized message bundle. Typically, it will be a key, but // a localized message bundle. Typically, it will be a key, but
// the UI will work just fine if you don't care about localization // the UI will work just fine if you don't care about localization
// and you want to just send UI text. // and you want to just send UI text.
// //
// Also, the ${} shown in Apicurio is not needed. // Also, the ${} shown in Apicurio is not needed.
private String type;
private String category; // ** private String category; // **
private String type; // ** private String displayName;
private String helptext; // ** private String helptext; // **
private boolean enabled; private String iconCssClass;
private String createAction; private String createAction;
private String updateAction; private String updateAction;
private boolean removeable; private boolean removeable;
private List<CredentialModel> userCredentials; private List<CredentialRepresentation> userCredentials;
private CredentialTypeMetadata metadata;
public CredentialContainer(String category, String type, String helptext, boolean enabled, String createAction, String updateAction, boolean removeable,List<CredentialModel> userCredentials) { public CredentialContainer() {
this.category = category; }
this.type = type;
this.helptext = helptext; public CredentialContainer(CredentialTypeMetadata metadata, List<CredentialRepresentation> userCredentials) {
this.enabled = enabled; this.metadata = metadata;
this.createAction = createAction; this.type = metadata.getType();
this.updateAction = updateAction; this.category = metadata.getCategory().toString();
this.removeable = removeable; this.displayName = metadata.getDisplayName();
this.helptext = metadata.getHelpText();
this.iconCssClass = metadata.getIconCssClass();
this.createAction = metadata.getCreateAction();
this.updateAction = metadata.getUpdateAction();
this.removeable = metadata.isRemoveable();
this.userCredentials = userCredentials; this.userCredentials = userCredentials;
} }
@ -93,12 +104,16 @@ public class AccountCredentialResource {
return type; return type;
} }
public String getDisplayName() {
return displayName;
}
public String getHelptext() { public String getHelptext() {
return helptext; return helptext;
} }
public boolean isEnabled() { public String getIconCssClass() {
return enabled; return iconCssClass;
} }
public String getCreateAction() { public String getCreateAction() {
@ -113,107 +128,127 @@ public class AccountCredentialResource {
return removeable; return removeable;
} }
public List<CredentialModel> getUserCredentials() { public List<CredentialRepresentation> getUserCredentials() {
return userCredentials; return userCredentials;
} }
@JsonIgnore
public CredentialTypeMetadata getMetadata() {
return metadata;
}
} }
/**
* Retrieve the list of credentials available to the current logged in user. It will return only credentials of enabled types,
* which user can use to authenticate in some authentication flow.
*
* @param type Allows to filter just single credential type, which will be specified as this parameter. If null, it will return all credential types
* @param userCredentials specifies if user credentials should be returned. If true, they will be returned in the "userCredentials" attribute of
* particular credential. Defaults to true.
* @return
*/
@GET @GET
@NoCache @NoCache
@Produces(javax.ws.rs.core.MediaType.APPLICATION_JSON) @Produces(javax.ws.rs.core.MediaType.APPLICATION_JSON)
public List<CredentialContainer> dummyCredentialTypes(){ public List<CredentialContainer> credentialTypes(@QueryParam(TYPE) String type,
@QueryParam(USER_CREDENTIALS) Boolean userCredentials) {
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
List<CredentialModel> models = session.userCredentialManager().getStoredCredentials(realm, user);
List<CredentialModel> passwordUserCreds = new java.util.ArrayList<>(); boolean filterUserCredentials = userCredentials != null && !userCredentials;
passwordUserCreds.add(models.get(0));
List<CredentialModel> otpUserCreds = new java.util.ArrayList<>(); List<CredentialContainer> credentialTypes = new LinkedList<>();
if (models.size() > 1) otpUserCreds.add(models.get(1)); List<CredentialProvider> credentialProviders = UserCredentialStoreManager.getCredentialProviders(session, realm, CredentialProvider.class);
if (models.size() > 2) otpUserCreds.add(models.get(2)); Set<String> enabledCredentialTypes = getEnabledCredentialTypes(credentialProviders);
List<CredentialModel> webauthnUserCreds = new java.util.ArrayList<>(); List<CredentialModel> models = filterUserCredentials ? null : session.userCredentialManager().getStoredCredentials(realm, user);
CredentialModel webauthnCred = new CredentialModel();
webauthnCred.setId("bogus-id");
webauthnCred.setUserLabel("yubikey key");
webauthnCred.setCreatedDate(1579122652382L);
webauthnUserCreds.add(webauthnCred);
List<CredentialModel> webauthnStrongUserCreds = new java.util.ArrayList<>(); // Don't return secrets from REST endpoint
CredentialModel webauthnStrongCred = new CredentialModel(); if (models != null) {
webauthnStrongCred.setId("bogus-id-for-webauthnStrong"); for (CredentialModel credential : models) {
webauthnStrongCred.setUserLabel("My very strong key with required PIN"); credential.setSecretData(null);
webauthnStrongCred.setCreatedDate(1579122652382L);
webauthnUserCreds.add(webauthnStrongCred);
CredentialContainer password = new CredentialContainer(
"password",
"password",
"passwordHelptext",
true,
null, // no create action
"UPDATE_PASSWORD",
false,
passwordUserCreds
);
CredentialContainer otp = new CredentialContainer(
"two-factor",
"otp",
"otpHelptext",
true,
"CONFIGURE_TOTP",
null, // no update action
true,
otpUserCreds
);
CredentialContainer webAuthn = new CredentialContainer(
"two-factor",
"webauthn",
"webauthnHelptext",
true,
"CONFIGURE_WEBAUTHN",
null, // no update action
true,
webauthnUserCreds
);
CredentialContainer passwordless = new CredentialContainer(
"passwordless",
"webauthn-passwordless",
"webauthn-passwordlessHelptext",
true,
"CONFIGURE_WEBAUTHN_STRONG",
null, // no update action
true,
webauthnStrongUserCreds
);
List<CredentialContainer> dummyCreds = new java.util.ArrayList<>();
dummyCreds.add(password);
dummyCreds.add(otp);
dummyCreds.add(webAuthn);
dummyCreds.add(passwordless);
return dummyCreds;
} }
// }
//
// @GET for (CredentialProvider credentialProvider : credentialProviders) {
// @Path("registrators") String credentialProviderType = credentialProvider.getType();
// @NoCache
// @Produces(javax.ws.rs.core.MediaType.APPLICATION_JSON) // Filter just by single type
// public List<String> getCredentialRegistrators(){ if (type != null && !type.equals(credentialProviderType)) {
// auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); continue;
// }
// return session.getContext().getRealm().getRequiredActionProviders().stream()
// .map(RequiredActionProviderModel::getProviderId) boolean enabled = enabledCredentialTypes.contains(credentialProviderType);
// .filter(providerId -> session.getProvider(RequiredActionProvider.class, providerId) instanceof CredentialRegistrator)
// .collect(Collectors.toList()); // Filter disabled credential types
// } if (!enabled) {
// continue;
}
CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata();
List<CredentialRepresentation> userCredentialModels = filterUserCredentials ? null : models.stream()
.filter(credentialModel -> credentialProvider.getType().equals(credentialModel.getType()))
.map(ModelToRepresentation::toRepresentation)
.collect(Collectors.toList());
CredentialContainer credType = new CredentialContainer(metadata, userCredentialModels);
credentialTypes.add(credType);
}
credentialTypes.sort(Comparator.comparing(CredentialContainer::getMetadata));
return credentialTypes;
}
// Going through all authentication flows and their authentication executions to see if there is any authenticator of the corresponding
// credential type.
private Set<String> getEnabledCredentialTypes(List<CredentialProvider> credentialProviders) {
Set<String> enabledCredentialTypes = new HashSet<>();
for (AuthenticationFlowModel flow : realm.getAuthenticationFlows()) {
// Ignore DISABLED executions and flows
if (isFlowEffectivelyDisabled(flow)) continue;
for (AuthenticationExecutionModel execution : realm.getAuthenticationExecutions(flow.getId())) {
if (execution.getAuthenticator() != null && DISABLED != execution.getRequirement()) {
AuthenticatorFactory authenticatorFactory = (AuthenticatorFactory) session.getKeycloakSessionFactory().getProviderFactory(Authenticator.class, execution.getAuthenticator());
if (authenticatorFactory != null && authenticatorFactory.getReferenceCategory() != null) {
enabledCredentialTypes.add(authenticatorFactory.getReferenceCategory());
}
}
}
}
Set<String> credentialTypes = credentialProviders.stream()
.map(CredentialProvider::getType)
.collect(Collectors.toSet());
enabledCredentialTypes.retainAll(credentialTypes);
return enabledCredentialTypes;
}
// Returns true if flow is effectively disabled - either it's execution or some parent execution is disabled
private boolean isFlowEffectivelyDisabled(AuthenticationFlowModel flow) {
while (!flow.isTopLevel()) {
AuthenticationExecutionModel flowExecution = realm.getAuthenticationExecutionByFlowId(flow.getId());
if (flowExecution == null) return false; // Can happen under some corner cases
if (DISABLED == flowExecution.getRequirement()) return true;
if (flowExecution.getParentFlow() == null) return false;
// Check parent flow
flow = realm.getAuthenticationFlowById(flowExecution.getParentFlow());
if (flow == null) return false;
}
return false;
}
/** /**
* Remove a credential for a user * Remove a credential of current user
* *
* @param credentialId ID of the credential, which will be removed
*/ */
@Path("{credentialId}") @Path("{credentialId}")
@DELETE @DELETE
@ -222,18 +257,23 @@ public class AccountCredentialResource {
auth.require(AccountRoles.MANAGE_ACCOUNT); auth.require(AccountRoles.MANAGE_ACCOUNT);
session.userCredentialManager().removeStoredCredential(realm, user, credentialId); session.userCredentialManager().removeStoredCredential(realm, user, credentialId);
} }
//
// /**
// * Update a credential label for a user /**
// */ * Update a user label of specified credential of current user
// @PUT *
// @Consumes(javax.ws.rs.core.MediaType.TEXT_PLAIN) * @param credentialId ID of the credential, which will be updated
// @Path("{credentialId}/label") * @param userLabel new user label
// public void setLabel(final @PathParam("credentialId") String credentialId, String userLabel) { */
// auth.require(AccountRoles.MANAGE_ACCOUNT); @PUT
// session.userCredentialManager().updateCredentialLabel(realm, user, credentialId, userLabel); @Consumes(javax.ws.rs.core.MediaType.TEXT_PLAIN)
// } @Path("{credentialId}/label")
// public void setLabel(final @PathParam("credentialId") String credentialId, String userLabel) {
auth.require(AccountRoles.MANAGE_ACCOUNT);
session.userCredentialManager().updateCredentialLabel(realm, user, credentialId, userLabel);
}
// TODO: This is kept here for now and commented.
// /** // /**
// * Move a credential to a position behind another credential // * Move a credential to a position behind another credential
// * @param credentialId The credential to move // * @param credentialId The credential to move

View file

@ -45,7 +45,6 @@ public abstract class LanguageComboboxAwarePage extends AbstractPage {
@FindBy(id = "kc-attempted-username") @FindBy(id = "kc-attempted-username")
private WebElement attemptedUsernameLabel; private WebElement attemptedUsernameLabel;
// TODO: This won't be a link, but some kind of an icon once we do better design
@FindBy(id = "reset-login") @FindBy(id = "reset-login")
private WebElement resetLoginLink; private WebElement resetLoginLink;

View file

@ -8,7 +8,6 @@ import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement; import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy; import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.ui.Select;
/** /**
* Login page with the list of authentication mechanisms, which are available to the user (Password, OTP, WebAuthn...) * Login page with the list of authentication mechanisms, which are available to the user (Password, OTP, WebAuthn...)
@ -17,30 +16,64 @@ import org.openqa.selenium.support.ui.Select;
*/ */
public class SelectAuthenticatorPage extends LanguageComboboxAwarePage { public class SelectAuthenticatorPage extends LanguageComboboxAwarePage {
// Corresponds to the PasswordForm
public static final String PASSWORD = "Password";
// Corresponds to the OTPFormAuthenticator
public static final String AUTHENTICATOR_APPLICATION = "Authenticator Application";
@FindBy(id = "authenticators-choice") @FindBy(id = "authenticators-choice")
private WebElement authenticatorsSelect; private WebElement authenticatorsSelect;
/**
* Return list of names like for example [ "Password", "Authenticator Application", "Security Key" ]
*/
public List<String> getAvailableLoginMethods() { public List<String> getAvailableLoginMethods() {
return new Select(authenticatorsSelect).getOptions() List<WebElement> rows = getLoginMethodsRows();
.stream()
.map(WebElement::getText) return rows.stream()
.map(this::getLoginMethodNameFromRow)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
public String getSelectedLoginMethod() { /**
return new Select(authenticatorsSelect).getOptions() *
.stream() * Selects the chosen login method (For example "Password") by click on it.
.filter(webElement -> webElement.getAttribute("selected") != null) *
.findFirst() * @param loginMethodName name as displayed. For example "Password" or "Authenticator Application"
.orElseThrow(() -> new AssertionError("Selected login method not found")) *
.getText(); */
public void selectLoginMethod(String loginMethodName) {
getLoginMethodRowByName(loginMethodName).click();
}
/**
* Return help text corresponding to the named login method
*
* @param loginMethodName name as displayed. For example "Password" or "Authenticator Application"
* @return
*/
public String getLoginMethodHelpText(String loginMethodName) {
return getLoginMethodRowByName(loginMethodName).findElement(By.className("list-group-item-text")).getText();
} }
public void selectLoginMethod(String loginMethod) { private List<WebElement> getLoginMethodsRows() {
new Select(authenticatorsSelect).selectByVisibleText(loginMethod); return driver.findElements(By.className("list-view-pf-main-info"));
}
private String getLoginMethodNameFromRow(WebElement loginMethodRow) {
return loginMethodRow.findElement(By.className("list-group-item-heading")).getText();
}
private WebElement getLoginMethodRowByName(String loginMethodName) {
return getLoginMethodsRows().stream()
.filter(loginMethodRow -> loginMethodName.equals(getLoginMethodNameFromRow(loginMethodRow)))
.findFirst()
.orElseThrow(() -> new AssertionError("Login method '" + loginMethodName + "' not found in the available authentication mechanisms"));
} }
@ -53,7 +86,7 @@ public class SelectAuthenticatorPage extends LanguageComboboxAwarePage {
// Check the authenticators-choice available // Check the authenticators-choice available
try { try {
driver.findElement(By.id("authenticators-choice")); driver.findElement(By.id("kc-select-credential-form"));
} catch (NoSuchElementException nfe) { } catch (NoSuchElementException nfe) {
return false; return false;
} }

View file

@ -20,17 +20,37 @@ import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory;
import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory;
import org.keycloak.authentication.requiredactions.WebAuthnRegister;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.representations.account.ClientRepresentation; import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation; import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation; import org.keycloak.representations.account.ConsentScopeRepresentation;
import org.keycloak.representations.account.SessionRepresentation; import org.keycloak.representations.account.SessionRepresentation;
import org.keycloak.representations.account.UserRepresentation; import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
import org.keycloak.representations.idm.AuthenticationExecutionRepresentation;
import org.keycloak.representations.idm.AuthenticationFlowRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation; import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.account.AccountCredentialResource; import org.keycloak.services.resources.account.AccountCredentialResource;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.admin.authentication.AbstractAuthenticationTest;
import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.TokenUtil; import org.keycloak.testsuite.util.TokenUtil;
@ -41,11 +61,14 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.ws.rs.core.Response;
import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.Assert.*; import static org.junit.Assert.*;
import org.keycloak.services.resources.account.AccountCredentialResource.PasswordUpdate; import org.keycloak.services.resources.account.AccountCredentialResource.PasswordUpdate;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import org.keycloak.testsuite.util.WaitUtils;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -277,6 +300,198 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
assertEquals(expectedStatus, status); assertEquals(expectedStatus, status);
} }
@Test
public void testCredentialsGet() throws IOException {
configureBrowserFlowWithWebAuthnAuthenticator("browser-webauthn");
List<AccountCredentialResource.CredentialContainer> credentials = getCredentials();
Assert.assertEquals(4, credentials.size());
AccountCredentialResource.CredentialContainer password = credentials.get(0);
assertCredentialContainerExpected(password, PasswordCredentialModel.TYPE, CredentialTypeMetadata.Category.PASSWORD.toString(),
"password", "password-help-text", "kcAuthenticatorPasswordClass",
null, UserModel.RequiredAction.UPDATE_PASSWORD.toString(), false, 1);
CredentialRepresentation password1 = password.getUserCredentials().get(0);
Assert.assertNull(password1.getSecretData());
Assert.assertNotNull(password1.getCredentialData());
AccountCredentialResource.CredentialContainer otp = credentials.get(1);
assertCredentialContainerExpected(otp, OTPCredentialModel.TYPE, CredentialTypeMetadata.Category.TWO_FACTOR.toString(),
"otp-display-name", "otp-help-text", "kcAuthenticatorOTPClass",
UserModel.RequiredAction.CONFIGURE_TOTP.toString(), null, true, 0);
// WebAuthn credentials will be returned, but createAction will be still null because requiredAction "webauthn register" not yet registered
AccountCredentialResource.CredentialContainer webauthn = credentials.get(2);
assertCredentialContainerExpected(webauthn, WebAuthnCredentialModel.TYPE_TWOFACTOR, CredentialTypeMetadata.Category.TWO_FACTOR.toString(),
"webauthn-display-name", "webauthn-help-text", "kcAuthenticatorWebAuthnClass",
null, null, true, 0);
AccountCredentialResource.CredentialContainer webauthnPasswordless = credentials.get(3);
assertCredentialContainerExpected(webauthnPasswordless, WebAuthnCredentialModel.TYPE_PASSWORDLESS, CredentialTypeMetadata.Category.PASSWORDLESS.toString(),
"webauthn-passwordless-display-name", "webauthn-passwordless-help-text", "kcAuthenticatorWebAuthnPasswordlessClass",
null, null, true, 0);
// Register requiredActions for WebAuthn
RequiredActionProviderSimpleRepresentation requiredAction = new RequiredActionProviderSimpleRepresentation();
requiredAction.setId("12345");
requiredAction.setName(WebAuthnRegisterFactory.PROVIDER_ID);
requiredAction.setProviderId(WebAuthnRegisterFactory.PROVIDER_ID);
testRealm().flows().registerRequiredAction(requiredAction);
requiredAction = new RequiredActionProviderSimpleRepresentation();
requiredAction.setId("6789");
requiredAction.setName(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID);
requiredAction.setProviderId(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID);
testRealm().flows().registerRequiredAction(requiredAction);
// requiredActions should be available
credentials = getCredentials();
Assert.assertEquals(WebAuthnRegisterFactory.PROVIDER_ID, credentials.get(2).getCreateAction());
Assert.assertEquals(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID, credentials.get(3).getCreateAction());
// disable WebAuthn passwordless required action. It won't be returned then
RequiredActionProviderRepresentation requiredActionRep = testRealm().flows().getRequiredAction(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID);
requiredActionRep.setEnabled(false);
testRealm().flows().updateRequiredAction(WebAuthnRegisterFactory.PROVIDER_ID, requiredActionRep);
credentials = getCredentials();
Assert.assertNull(credentials.get(2).getCreateAction());
// Test that WebAuthn won't be returned when removed from the authentication flow
removeWebAuthnFlow("browser-webauthn");
credentials = getCredentials();
Assert.assertEquals(2, credentials.size());
Assert.assertEquals(PasswordCredentialModel.TYPE, credentials.get(0).getType());
Assert.assertNotNull(OTPCredentialModel.TYPE, credentials.get(1).getType());
// Test password-only
credentials = SimpleHttp.doGet(getAccountUrl("credentials?" + AccountCredentialResource.TYPE + "=password"), httpClient)
.auth(tokenUtil.getToken()).asJson(new TypeReference<List<AccountCredentialResource.CredentialContainer>>() {});
Assert.assertEquals(1, credentials.size());
password = credentials.get(0);
Assert.assertEquals(PasswordCredentialModel.TYPE, password.getType());
Assert.assertEquals(1, password.getUserCredentials().size());
// Test password-only and user-credentials
credentials = SimpleHttp.doGet(getAccountUrl("credentials?" + AccountCredentialResource.TYPE + "=password&" +
AccountCredentialResource.USER_CREDENTIALS + "=false"), httpClient)
.auth(tokenUtil.getToken()).asJson(new TypeReference<List<AccountCredentialResource.CredentialContainer>>() {});
Assert.assertEquals(1, credentials.size());
password = credentials.get(0);
Assert.assertEquals(PasswordCredentialModel.TYPE, password.getType());
Assert.assertNull(password.getUserCredentials());
}
// Send REST request to get all credential containers and credentials of current user
private List<AccountCredentialResource.CredentialContainer> getCredentials() throws IOException {
return SimpleHttp.doGet(getAccountUrl("credentials"), httpClient)
.auth(tokenUtil.getToken()).asJson(new TypeReference<List<AccountCredentialResource.CredentialContainer>>() {});
}
@Test
public void testCredentialsGetDisabledOtp() throws IOException {
// Disable OTP in all built-in flows
// Disable parent subflow - that should treat OTP execution as disabled too
AuthenticationExecutionModel.Requirement currentBrowserReq = setExecutionRequirement(DefaultAuthenticationFlows.BROWSER_FLOW,
"Browser - Conditional OTP", AuthenticationExecutionModel.Requirement.DISABLED);
// Disable OTP directly in first-broker-login and direct-grant
AuthenticationExecutionModel.Requirement currentFBLReq = setExecutionRequirement(DefaultAuthenticationFlows.FIRST_BROKER_LOGIN_FLOW,
"OTP Form", AuthenticationExecutionModel.Requirement.DISABLED);
AuthenticationExecutionModel.Requirement currentDirectGrantReq = setExecutionRequirement(DefaultAuthenticationFlows.DIRECT_GRANT_FLOW,
"Direct Grant - Conditional OTP", AuthenticationExecutionModel.Requirement.DISABLED);
try {
// Test that OTP credential is not included. Only password
List<AccountCredentialResource.CredentialContainer> credentials = getCredentials();
Assert.assertEquals(1, credentials.size());
Assert.assertEquals(PasswordCredentialModel.TYPE, credentials.get(0).getType());
// Enable browser subflow. OTP should be available then
setExecutionRequirement(DefaultAuthenticationFlows.BROWSER_FLOW,
"Browser - Conditional OTP", currentBrowserReq);
credentials = getCredentials();
Assert.assertEquals(2, credentials.size());
Assert.assertEquals(OTPCredentialModel.TYPE, credentials.get(1).getType());
// Disable browser subflow and enable FirstBrokerLogin. OTP should be available then
setExecutionRequirement(DefaultAuthenticationFlows.BROWSER_FLOW,
"Browser - Conditional OTP", AuthenticationExecutionModel.Requirement.DISABLED);
setExecutionRequirement(DefaultAuthenticationFlows.FIRST_BROKER_LOGIN_FLOW,
"OTP Form", currentFBLReq);
credentials = getCredentials();
Assert.assertEquals(2, credentials.size());
Assert.assertEquals(OTPCredentialModel.TYPE, credentials.get(1).getType());
} finally {
// Revert flows
setExecutionRequirement(DefaultAuthenticationFlows.BROWSER_FLOW,
"Browser - Conditional OTP", currentBrowserReq);
setExecutionRequirement(DefaultAuthenticationFlows.DIRECT_GRANT_FLOW,
"Direct Grant - Conditional OTP", currentDirectGrantReq);
}
}
// Sets new requirement and returns current requirement
private AuthenticationExecutionModel.Requirement setExecutionRequirement(String flowAlias, String executionDisplayName, AuthenticationExecutionModel.Requirement newRequirement) {
List<AuthenticationExecutionInfoRepresentation> executionInfos = testRealm().flows().getExecutions(flowAlias);
for (AuthenticationExecutionInfoRepresentation exInfo : executionInfos) {
if (executionDisplayName.equals(exInfo.getDisplayName())) {
AuthenticationExecutionModel.Requirement currentRequirement = AuthenticationExecutionModel.Requirement.valueOf(exInfo.getRequirement());
exInfo.setRequirement(newRequirement.toString());
testRealm().flows().updateExecutions(flowAlias, exInfo);
return currentRequirement;
}
}
throw new IllegalStateException("Not found execution '" + executionDisplayName + "' in flow '" + flowAlias + "'.");
}
private void configureBrowserFlowWithWebAuthnAuthenticator(String newFlowAlias) {
HashMap<String, String> params = new HashMap<>();
params.put("newName", newFlowAlias);
Response response = testRealm().flows().copy("browser", params);
response.close();
String flowId = AbstractAuthenticationTest.findFlowByAlias(newFlowAlias, testRealm().flows().getFlows()).getId();
AuthenticationExecutionRepresentation execution = new AuthenticationExecutionRepresentation();
execution.setParentFlow(flowId);
execution.setAuthenticator(WebAuthnAuthenticatorFactory.PROVIDER_ID);
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.toString());
response = testRealm().flows().addExecution(execution);
response.close();
execution = new AuthenticationExecutionRepresentation();
execution.setParentFlow(flowId);
execution.setAuthenticator( WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID);
execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE.toString());
response = testRealm().flows().addExecution(execution);
response.close();
}
private void removeWebAuthnFlow(String flowToDeleteAlias) {
List<AuthenticationFlowRepresentation> flows = testRealm().flows().getFlows();
AuthenticationFlowRepresentation flowRepresentation = AbstractAuthenticationTest.findFlowByAlias(flowToDeleteAlias, flows);
testRealm().flows().deleteFlow(flowRepresentation.getId());
}
private void assertCredentialContainerExpected(AccountCredentialResource.CredentialContainer credential, String type, String category, String displayName, String helpText, String iconCssClass,
String createAction, String updateAction, boolean removeable, int userCredentialsCount) {
Assert.assertEquals(type, credential.getType());
Assert.assertEquals(category, credential.getCategory());
Assert.assertEquals(displayName, credential.getDisplayName());
Assert.assertEquals(helpText, credential.getHelptext());
Assert.assertEquals(iconCssClass, credential.getIconCssClass());
Assert.assertEquals(createAction, credential.getCreateAction());
Assert.assertEquals(updateAction, credential.getUpdateAction());
Assert.assertEquals(removeable, credential.isRemoveable());
Assert.assertEquals(userCredentialsCount, credential.getUserCredentials().size());
}
public void testDeleteSessions() throws IOException { public void testDeleteSessions() throws IOException {
TokenUtil viewToken = new TokenUtil("view-account-access", "password"); TokenUtil viewToken = new TokenUtil("view-account-access", "password");
oauth.doLogin("view-account-access", "password"); oauth.doLogin("view-account-access", "password");

View file

@ -140,12 +140,9 @@ public class KcOidcFirstBrokerLoginNewAuthTest extends AbstractInitializedBaseBr
// Just click "Try another way" to verify that both Password and OTP are available. But go back to Password then // Just click "Try another way" to verify that both Password and OTP are available. But go back to Password then
passwordPage.clickTryAnotherWayLink(); passwordPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent(); selectAuthenticatorPage.assertCurrent();
Assert.assertNames(selectAuthenticatorPage.getAvailableLoginMethods(), "Password", "OTP"); Assert.assertNames(selectAuthenticatorPage.getAvailableLoginMethods(), SelectAuthenticatorPage.PASSWORD, SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION);
// TODO: This is limitation of select, that it can't select the already present value. Should be improved when we change to select cart selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.PASSWORD);
selectAuthenticatorPage.selectLoginMethod("OTP");
loginTotpPage.clickTryAnotherWayLink();
selectAuthenticatorPage.selectLoginMethod("Password");
// Login with password // Login with password
Assert.assertTrue(passwordPage.isCurrent("consumer")); Assert.assertTrue(passwordPage.isCurrent("consumer"));
@ -176,7 +173,7 @@ public class KcOidcFirstBrokerLoginNewAuthTest extends AbstractInitializedBaseBr
// Click "Try another way", Select OTP and assert OTP form present // Click "Try another way", Select OTP and assert OTP form present
passwordPage.clickTryAnotherWayLink(); passwordPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent(); selectAuthenticatorPage.assertCurrent();
selectAuthenticatorPage.selectLoginMethod("OTP"); selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION);
loginTotpPage.assertCurrent(); loginTotpPage.assertCurrent();

View file

@ -49,7 +49,6 @@ import org.keycloak.testsuite.pages.PasswordPage;
import org.keycloak.testsuite.pages.SelectAuthenticatorPage; import org.keycloak.testsuite.pages.SelectAuthenticatorPage;
import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.WaitUtils;
import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebDriver;
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
@ -101,14 +100,6 @@ public class MultiFactorAuthenticationTest extends AbstractTestRealmKeycloakTest
return res; return res;
} }
private void importTestRealm(Consumer<RealmRepresentation> realmUpdater) {
RealmRepresentation realm = loadTestRealm();
if (realmUpdater != null) {
realmUpdater.accept(realm);
}
importRealm(realm);
}
@Override @Override
public void addTestRealms(List<RealmRepresentation> testRealms) { public void addTestRealms(List<RealmRepresentation> testRealms) {
log.debug("Adding test realm for import from testrealm.json"); log.debug("Adding test realm for import from testrealm.json");
@ -138,10 +129,14 @@ public class MultiFactorAuthenticationTest extends AbstractTestRealmKeycloakTest
passwordPage.clickTryAnotherWayLink(); passwordPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent(); selectAuthenticatorPage.assertCurrent();
Assert.assertEquals(Arrays.asList("Password", "OTP"), selectAuthenticatorPage.getAvailableLoginMethods()); Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.PASSWORD, SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION), selectAuthenticatorPage.getAvailableLoginMethods());
// Assert help texts
Assert.assertEquals("Log in by entering your password.", selectAuthenticatorPage.getLoginMethodHelpText(SelectAuthenticatorPage.PASSWORD));
Assert.assertEquals("Enter a verification code from authenticator application.", selectAuthenticatorPage.getLoginMethodHelpText(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION));
// Select OTP and see that just single OTP is available for this user // Select OTP and see that just single OTP is available for this user
selectAuthenticatorPage.selectLoginMethod("OTP"); selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION);
loginTotpPage.assertCurrent(); loginTotpPage.assertCurrent();
loginTotpPage.assertTryAnotherWayLinkAvailability(true); loginTotpPage.assertTryAnotherWayLinkAvailability(true);
loginTotpPage.assertOtpCredentialSelectorAvailability(false); loginTotpPage.assertOtpCredentialSelectorAvailability(false);
@ -159,7 +154,7 @@ public class MultiFactorAuthenticationTest extends AbstractTestRealmKeycloakTest
loginTotpPage.clickTryAnotherWayLink(); loginTotpPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent(); selectAuthenticatorPage.assertCurrent();
Assert.assertEquals(Arrays.asList("OTP", "Password"), selectAuthenticatorPage.getAvailableLoginMethods()); Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION, SelectAuthenticatorPage.PASSWORD), selectAuthenticatorPage.getAvailableLoginMethods());
} finally { } finally {
BrowserFlowTest.revertFlows(testRealm(), "browser - alternative"); BrowserFlowTest.revertFlows(testRealm(), "browser - alternative");
} }
@ -218,8 +213,8 @@ public class MultiFactorAuthenticationTest extends AbstractTestRealmKeycloakTest
// Click "Try another way" . Ability to have both password and OTP should be possible even if OTP is in different subflow // Click "Try another way" . Ability to have both password and OTP should be possible even if OTP is in different subflow
passwordPage.clickTryAnotherWayLink(); passwordPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent(); selectAuthenticatorPage.assertCurrent();
Assert.assertEquals(Arrays.asList("Password", "OTP"), selectAuthenticatorPage.getAvailableLoginMethods()); Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.PASSWORD, SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION), selectAuthenticatorPage.getAvailableLoginMethods());
selectAuthenticatorPage.selectLoginMethod("OTP"); selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION);
// Should be on the OTP now. Click "Try another way" again. Should see again both Password and OTP // Should be on the OTP now. Click "Try another way" again. Should see again both Password and OTP
loginTotpPage.assertCurrent(); loginTotpPage.assertCurrent();
@ -227,9 +222,9 @@ public class MultiFactorAuthenticationTest extends AbstractTestRealmKeycloakTest
loginTotpPage.clickTryAnotherWayLink(); loginTotpPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent(); selectAuthenticatorPage.assertCurrent();
Assert.assertEquals(Arrays.asList("Password", "OTP"), selectAuthenticatorPage.getAvailableLoginMethods()); Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.PASSWORD, SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION), selectAuthenticatorPage.getAvailableLoginMethods());
selectAuthenticatorPage.selectLoginMethod("Password"); selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.PASSWORD);
passwordPage.assertCurrent(); passwordPage.assertCurrent();
passwordPage.login("password"); passwordPage.login("password");

View file

@ -104,7 +104,7 @@ loginTotpCounter=Counter
loginTotp.totp=Time-based loginTotp.totp=Time-based
loginTotp.hotp=Counter-based loginTotp.hotp=Counter-based
loginChooseAuthenticator=Select your authentication method loginChooseAuthenticator=Select login method
oauthGrantRequest=Do you grant these access privileges? oauthGrantRequest=Do you grant these access privileges?
inResource=in inResource=in
@ -334,11 +334,16 @@ saml.post-form.message=Redirecting, please wait.
saml.post-form.js-disabled=JavaScript is disabled. We strongly recommend to enable it. Click the button below to continue. saml.post-form.js-disabled=JavaScript is disabled. We strongly recommend to enable it. Click the button below to continue.
#authenticators #authenticators
auth-otp-form=OTP otp-display-name=Authenticator Application
auth-password-form=Password otp-help-text=Enter a verification code from authenticator application.
auth-username-form=Username password-help-text=Log in by entering your password.
auth-username-password-form=Username and password auth-username-form-display-name=Username
webauthn-authenticator=WebAuthn auth-username-form-help-text=Start log in by entering your username
webauthn-authenticator-passwordless=WebAuthn Passwordless auth-username-password-form-display-name=Username and password
auth-username-password-form-help-text=Log in by entering your username and password.
webauthn-display-name=Security Key
webauthn-help-text=Use your security key to log in.
webauthn-passwordless-display-name=Security Key
webauthn-passwordless-help-text=Use your security key for passwordless log in.
identity-provider-redirector=Connect with another Identity Provider identity-provider-redirector=Connect with another Identity Provider

View file

@ -2,34 +2,41 @@
<@layout.registrationLayout displayInfo=true; section> <@layout.registrationLayout displayInfo=true; section>
<#if section = "header" || section = "show-username"> <#if section = "header" || section = "show-username">
<script type="text/javascript"> <script type="text/javascript">
// Fill up the two hidden and submit the form function fillAndSubmit(authExecId) {
function fillAndSubmit() { document.getElementById('authexec-hidden-input').value = authExecId;
document.getElementById('authexec-hidden-input').value = document.getElementById('authenticators-choice').value;
document.getElementById('kc-select-credential-form').submit(); document.getElementById('kc-select-credential-form').submit();
} }
<#if auth.authenticationSelections?size gt 1>
// We bind the action to the select
window.addEventListener('load', function() {
document.getElementById('authenticators-choice').addEventListener('change', fillAndSubmit);
});
</#if>
</script> </script>
<#if section = "header">
${msg("loginChooseAuthenticator")}
</#if>
<#elseif section = "form"> <#elseif section = "form">
<form id="kc-select-credential-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post"> <form id="kc-select-credential-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}"> <div class="${properties.kcSelectAuthListClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="authenticators-choice" class="${properties.kcLabelClass!}">${msg("loginCredential")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<select id="authenticators-choice" class="form-control" size="1">
<#list auth.authenticationSelections as authenticationSelection> <#list auth.authenticationSelections as authenticationSelection>
<option value="${authenticationSelection.authExecId}" <#if authenticationSelection.authExecId == execution>selected</#if>>${msg('${authenticationSelection.authExecDisplayName}')}</option> <div class="${properties.kcSelectAuthListItemClass!}">
<div class="${properties.kcSelectAuthListItemInfoClass!}" onclick="fillAndSubmit('${authenticationSelection.authExecId}')">
<div class="${properties.kcSelectAuthListItemLeftClass!}">
<span class="${properties['${authenticationSelection.iconCssClass}']!authenticationSelection.iconCssClass}"></span>
</div>
<div class="${properties.kcSelectAuthListItemBodyClass!}">
<div class="${properties.kcSelectAuthListItemDescriptionClass!}">
<div class="${properties.kcSelectAuthListItemHeadingClass!}">
${msg('${authenticationSelection.displayName}')}
</div>
<div class="${properties.kcSelectAuthListItemHelpTextClass!}">
${msg('${authenticationSelection.helpText}')}
</div>
</div>
</div>
</div>
</div>
</#list> </#list>
</select>
<input type="hidden" id="authexec-hidden-input" name="authenticationExecution" /> <input type="hidden" id="authexec-hidden-input" name="authenticationExecution" />
</div> </div>
</div>
</form> </form>
</#if> </#if>
</@layout.registrationLayout> </@layout.registrationLayout>

View file

@ -56,12 +56,6 @@
<h1 id="kc-page-title"><#nested "header"></h1> <h1 id="kc-page-title"><#nested "header"></h1>
<#else> <#else>
<#nested "show-username"> <#nested "show-username">
</#if>
</header>
<div id="kc-content">
<div id="kc-content-wrapper">
<#if auth?has_content && auth.showUsername() && !auth.showResetCredentials()>
<div class="${properties.kcFormGroupClass!}"> <div class="${properties.kcFormGroupClass!}">
<div id="kc-username"> <div id="kc-username">
<label id="kc-attempted-username">${auth.attemptedUsername}</label> <label id="kc-attempted-username">${auth.attemptedUsername}</label>
@ -73,8 +67,10 @@
</a> </a>
</div> </div>
</div> </div>
<hr/>
</#if> </#if>
</header>
<div id="kc-content">
<div id="kc-content-wrapper">
<#-- App-initiated actions should not see warning messages about the need to complete the action --> <#-- App-initiated actions should not see warning messages about the need to complete the action -->
<#-- during login. --> <#-- during login. -->

View file

@ -68,12 +68,12 @@ notSetUp={0} is not set up.
two-factor=Two-Factor Authentication two-factor=Two-Factor Authentication
passwordless=Passwordless passwordless=Passwordless
unknown=Unknown unknown=Unknown
otp=One Time Password otp-display-name=Authenticator Application
webauthn=WebAuthn otp-help-text=Enter a verification code from authenticator application.
webauthn-passwordless=WebAuthn Passwordless webauthn-display-name=Security Key
otpHelptext=A one-time password (OTP), is a password that is valid for only one login session. webauthn-help-text=Use your security key to log in.
webauthnHelptext=WebAuthn lets web applications authenticate users without storing their passwords on servers. webauthn-passwordless-display-name=Security Key
webauthn-passwordlessHelptext=I need help with this help text. Any suggestions? webauthn-passwordless-help-text=Use your security key for passwordless log in.
# Applications page # Applications page
applicationsPageTitle=Applications applicationsPageTitle=Applications

View file

@ -65,9 +65,11 @@ interface UserCredential {
interface CredentialContainer { interface CredentialContainer {
category: CredCategory; category: CredCategory;
type: CredType; type: CredType;
displayName: string;
helptext?: string; helptext?: string;
createAction: string; createAction: string;
updateAction: string; updateAction: string;
iconCssClass: string;
removeable: boolean; removeable: boolean;
userCredentials: UserCredential[]; userCredentials: UserCredential[];
} }
@ -176,17 +178,17 @@ class SigningInPage extends React.Component<SigningInPageProps, SigningInPageSta
const userCredentials: UserCredential[] = credTypeMap.get(credType)!.userCredentials; const userCredentials: UserCredential[] = credTypeMap.get(credType)!.userCredentials;
const removeable: boolean = credTypeMap.get(credType)!.removeable; const removeable: boolean = credTypeMap.get(credType)!.removeable;
const updateAction: string = credTypeMap.get(credType)!.updateAction; const updateAction: string = credTypeMap.get(credType)!.updateAction;
const type: string = credTypeMap.get(credType)!.type; const displayName: string = credTypeMap.get(credType)!.displayName;
if (userCredentials.length === 0) { if (userCredentials.length === 0) {
const localizedType = Msg.localize(type); const localizedDisplayName = Msg.localize(displayName);
return ( return (
<DataListItem aria-labelledby='no-credentials-list-item'> <DataListItem aria-labelledby='no-credentials-list-item'>
<DataListItemRow key='no-credentials-list-item-row'> <DataListItemRow key='no-credentials-list-item-row'>
<DataListItemCells <DataListItemCells
dataListCells={[ dataListCells={[
<DataListCell/>, <DataListCell/>,
<strong><Msg msgKey='notSetUp' params={[localizedType]}/></strong>, <strong><Msg msgKey='notSetUp' params={[localizedDisplayName]}/></strong>,
<DataListCell/> <DataListCell/>
]}/> ]}/>
</DataListItemRow> </DataListItemRow>
@ -229,7 +231,7 @@ class SigningInPage extends React.Component<SigningInPageProps, SigningInPageSta
if (!credContainer.createAction) return; if (!credContainer.createAction) return;
const setupAction: AIACommand = new AIACommand(credContainer.createAction, this.props.location.pathname); const setupAction: AIACommand = new AIACommand(credContainer.createAction, this.props.location.pathname);
const credContainerType: string = Msg.localize(credContainer.type); const credContainerDisplayName: string = Msg.localize(credContainer.displayName);
return ( return (
<DataListItem aria-labelledby={'type-datalistitem-' + credContainer.type}> <DataListItem aria-labelledby={'type-datalistitem-' + credContainer.type}>
@ -238,7 +240,7 @@ class SigningInPage extends React.Component<SigningInPageProps, SigningInPageSta
dataListCells={[ dataListCells={[
<DataListCell width={5} key={'credTypeTitle-' + credContainer.type}> <DataListCell width={5} key={'credTypeTitle-' + credContainer.type}>
<Title headingLevel={TitleLevel.h3} size='2xl'> <Title headingLevel={TitleLevel.h3} size='2xl'>
<strong><Msg msgKey={credContainer.type}/></strong> <strong><Msg msgKey={credContainer.displayName}/></strong>
</Title> </Title>
<Msg msgKey={credContainer.helptext}/> <Msg msgKey={credContainer.helptext}/>
</DataListCell>, </DataListCell>,
@ -249,7 +251,7 @@ class SigningInPage extends React.Component<SigningInPageProps, SigningInPageSta
<span className="pf-c-button__icon"> <span className="pf-c-button__icon">
<i className="fas fa-plus-circle" aria-hidden="true"></i> <i className="fas fa-plus-circle" aria-hidden="true"></i>
</span> </span>
<Msg msgKey='setUpNew' params={[credContainerType]}/> <Msg msgKey='setUpNew' params={[credContainerDisplayName]}/>
</button> </button>
</DataListAction> </DataListAction>
</DataListItemRow> </DataListItemRow>

View file

@ -493,6 +493,14 @@ a.zocial {
margin-top: 0; margin-top: 0;
} }
.login-pf-page .list-view-pf .list-group-item {
border-bottom: 1px solid #ededed;
}
.login-pf-page .list-view-pf-description {
width: 100%;
}
#kc-form-login div.form-group:last-of-type, #kc-form-login div.form-group:last-of-type,
#kc-register-form div.form-group:last-of-type, #kc-register-form div.form-group:last-of-type,
#kc-update-profile-form div.form-group:last-of-type { #kc-update-profile-form div.form-group:last-of-type {

View file

@ -68,3 +68,20 @@ kcInputLargeClass=input-lg
##### css classes for form accessability ##### css classes for form accessability
kcSrOnlyClass=sr-only kcSrOnlyClass=sr-only
##### css classes for select-authenticator form
kcSelectAuthListClass=list-group list-view-pf
kcSelectAuthListItemClass=list-group-item list-view-pf-stacked
kcSelectAuthListItemInfoClass=list-view-pf-main-info
kcSelectAuthListItemLeftClass=list-view-pf-left
kcSelectAuthListItemBodyClass=list-view-pf-body
kcSelectAuthListItemDescriptionClass=list-view-pf-description
kcSelectAuthListItemHeadingClass=list-group-item-heading
kcSelectAuthListItemHelpTextClass=list-group-item-text
##### css classes for the authenticators
kcAuthenticatorDefaultClass=fa list-view-pf-icon-lg
kcAuthenticatorPasswordClass=fa fa-unlock list-view-pf-icon-lg
kcAuthenticatorOTPClass=fa fa-mobile list-view-pf-icon-lg
kcAuthenticatorWebAuthnClass=fa fa-key list-view-pf-icon-lg
kcAuthenticatorWebAuthnPasswordlessClass=fa fa-key list-view-pf-icon-lg