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;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
public class AuthenticationSelectionOption {
private final KeycloakSession session;
private final AuthenticationExecutionModel authExec;
private final CredentialTypeMetadata credentialTypeMetadata;
public AuthenticationSelectionOption(KeycloakSession session, AuthenticationExecutionModel authExec) {
this.session = session;
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();
}
public String getAuthExecName() {
return authExec.getAuthenticator();
public String getDisplayName() {
return credentialTypeMetadata == null ? authExec.getAuthenticator() + "-display-name" : credentialTypeMetadata.getDisplayName();
}
public String getAuthExecDisplayName() {
// TODO: Retrieve the displayName for the authenticator from the AuthenticationFactory
// TODO: Retrieve icon CSS style
// TODO: Should be addressed as part of https://issues.redhat.com/browse/KEYCLOAK-12185
return getAuthExecName();
public String getHelpText() {
return credentialTypeMetadata == null ? authExec.getAuthenticator() + "-help-text" : credentialTypeMetadata.getHelpText();
}
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
*/
boolean isUserSetupAllowed();
}

View file

@ -49,4 +49,6 @@ public interface CredentialProvider<T extends CredentialModel> extends Provider
}
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.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.List;
@ -37,7 +38,7 @@ public class WebAuthnAuthenticatorFactory implements AuthenticatorFactory {
@Override
public String getReferenceCategory() {
return "auth";
return WebAuthnCredentialModel.TYPE_TWOFACTOR;
}
@Override

View file

@ -20,6 +20,7 @@ package org.keycloak.authentication.authenticators.browser;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.credential.WebAuthnCredentialModel;
/**
* @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";
@Override
public String getReferenceCategory() {
return WebAuthnCredentialModel.TYPE_PASSWORDLESS;
}
@Override
public String getDisplayType() {
return "WebAuthn Passwordless Authenticator";

View file

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

View file

@ -18,6 +18,7 @@ package org.keycloak.credential;
import org.jboss.logging.Logger;
import org.keycloak.common.util.Time;
import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.dto.OTPCredentialData;
import org.keycloak.models.credential.dto.OTPSecretData;
@ -134,4 +135,17 @@ public class OTPCredentialProvider implements CredentialProvider<OTPCredentialMo
public String getType() {
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;
import org.jboss.logging.Logger;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.common.util.Time;
import org.keycloak.credential.hash.PasswordHashProvider;
import org.keycloak.models.ModelException;
@ -293,4 +294,17 @@ public class PasswordCredentialProvider implements CredentialProvider<PasswordCr
public String getType() {
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 org.jboss.logging.Logger;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.common.util.Base64;
import org.keycloak.common.util.Time;
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;
import com.webauthn4j.converter.util.CborConverter;
import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.credential.WebAuthnCredentialModel;
@ -37,4 +38,17 @@ public class WebAuthnPasswordlessCredentialProvider extends WebAuthnCredentialPr
public String getType() {
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;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.keycloak.authentication.CredentialRegistrator;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.credential.PasswordCredentialProvider;
import org.keycloak.credential.PasswordCredentialProviderFactory;
import org.keycloak.credential.UserCredentialStoreManager;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.*;
@ -25,13 +28,25 @@ import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.keycloak.models.AuthenticationExecutionModel.Requirement.DISABLED;
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 EventBuilder event;
private final UserModel user;
@ -46,42 +61,38 @@ public class AccountCredentialResource {
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 {
// ** These first three attributes can be ordinary UI text or a key into
public static class CredentialContainer {
// ** 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
// the UI will work just fine if you don't care about localization
// and you want to just send UI text.
//
// Also, the ${} shown in Apicurio is not needed.
private String type;
private String category; // **
private String type; // **
private String displayName;
private String helptext; // **
private boolean enabled;
private String iconCssClass;
private String createAction;
private String updateAction;
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) {
this.category = category;
this.type = type;
this.helptext = helptext;
this.enabled = enabled;
this.createAction = createAction;
this.updateAction = updateAction;
this.removeable = removeable;
public CredentialContainer() {
}
public CredentialContainer(CredentialTypeMetadata metadata, List<CredentialRepresentation> userCredentials) {
this.metadata = metadata;
this.type = metadata.getType();
this.category = metadata.getCategory().toString();
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;
}
@ -93,12 +104,16 @@ public class AccountCredentialResource {
return type;
}
public String getDisplayName() {
return displayName;
}
public String getHelptext() {
return helptext;
}
public boolean isEnabled() {
return enabled;
public String getIconCssClass() {
return iconCssClass;
}
public String getCreateAction() {
@ -113,107 +128,127 @@ public class AccountCredentialResource {
return removeable;
}
public List<CredentialModel> getUserCredentials() {
public List<CredentialRepresentation> getUserCredentials() {
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
@NoCache
@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);
List<CredentialModel> models = session.userCredentialManager().getStoredCredentials(realm, user);
List<CredentialModel> passwordUserCreds = new java.util.ArrayList<>();
passwordUserCreds.add(models.get(0));
boolean filterUserCredentials = userCredentials != null && !userCredentials;
List<CredentialModel> otpUserCreds = new java.util.ArrayList<>();
if (models.size() > 1) otpUserCreds.add(models.get(1));
if (models.size() > 2) otpUserCreds.add(models.get(2));
List<CredentialContainer> credentialTypes = new LinkedList<>();
List<CredentialProvider> credentialProviders = UserCredentialStoreManager.getCredentialProviders(session, realm, CredentialProvider.class);
Set<String> enabledCredentialTypes = getEnabledCredentialTypes(credentialProviders);
List<CredentialModel> webauthnUserCreds = new java.util.ArrayList<>();
CredentialModel webauthnCred = new CredentialModel();
webauthnCred.setId("bogus-id");
webauthnCred.setUserLabel("yubikey key");
webauthnCred.setCreatedDate(1579122652382L);
webauthnUserCreds.add(webauthnCred);
List<CredentialModel> models = filterUserCredentials ? null : session.userCredentialManager().getStoredCredentials(realm, user);
List<CredentialModel> webauthnStrongUserCreds = new java.util.ArrayList<>();
CredentialModel webauthnStrongCred = new CredentialModel();
webauthnStrongCred.setId("bogus-id-for-webauthnStrong");
webauthnStrongCred.setUserLabel("My very strong key with required PIN");
webauthnStrongCred.setCreatedDate(1579122652382L);
webauthnUserCreds.add(webauthnStrongCred);
// Don't return secrets from REST endpoint
if (models != null) {
for (CredentialModel credential : models) {
credential.setSecretData(null);
}
}
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
);
for (CredentialProvider credentialProvider : credentialProviders) {
String credentialProviderType = credentialProvider.getType();
List<CredentialContainer> dummyCreds = new java.util.ArrayList<>();
dummyCreds.add(password);
dummyCreds.add(otp);
dummyCreds.add(webAuthn);
dummyCreds.add(passwordless);
// Filter just by single type
if (type != null && !type.equals(credentialProviderType)) {
continue;
}
return dummyCreds;
boolean enabled = enabledCredentialTypes.contains(credentialProviderType);
// 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;
}
//
//
// @GET
// @Path("registrators")
// @NoCache
// @Produces(javax.ws.rs.core.MediaType.APPLICATION_JSON)
// public List<String> getCredentialRegistrators(){
// auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
//
// return session.getContext().getRealm().getRequiredActionProviders().stream()
// .map(RequiredActionProviderModel::getProviderId)
// .filter(providerId -> session.getProvider(RequiredActionProvider.class, providerId) instanceof CredentialRegistrator)
// .collect(Collectors.toList());
// }
//
// 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}")
@DELETE
@ -222,18 +257,23 @@ public class AccountCredentialResource {
auth.require(AccountRoles.MANAGE_ACCOUNT);
session.userCredentialManager().removeStoredCredential(realm, user, credentialId);
}
//
// /**
// * Update a credential label for a user
// */
// @PUT
// @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);
// }
//
/**
* Update a user label of specified credential of current user
*
* @param credentialId ID of the credential, which will be updated
* @param userLabel new user label
*/
@PUT
@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
// * @param credentialId The credential to move

View file

@ -45,7 +45,6 @@ public abstract class LanguageComboboxAwarePage extends AbstractPage {
@FindBy(id = "kc-attempted-username")
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")
private WebElement resetLoginLink;

View file

@ -8,7 +8,6 @@ import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
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...)
@ -17,30 +16,64 @@ import org.openqa.selenium.support.ui.Select;
*/
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")
private WebElement authenticatorsSelect;
/**
* Return list of names like for example [ "Password", "Authenticator Application", "Security Key" ]
*/
public List<String> getAvailableLoginMethods() {
return new Select(authenticatorsSelect).getOptions()
.stream()
.map(WebElement::getText)
List<WebElement> rows = getLoginMethodsRows();
return rows.stream()
.map(this::getLoginMethodNameFromRow)
.collect(Collectors.toList());
}
public String getSelectedLoginMethod() {
return new Select(authenticatorsSelect).getOptions()
.stream()
.filter(webElement -> webElement.getAttribute("selected") != null)
.findFirst()
.orElseThrow(() -> new AssertionError("Selected login method not found"))
.getText();
/**
*
* Selects the chosen login method (For example "Password") by click on it.
*
* @param loginMethodName name as displayed. For example "Password" or "Authenticator Application"
*
*/
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) {
new Select(authenticatorsSelect).selectByVisibleText(loginMethod);
private List<WebElement> getLoginMethodsRows() {
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
try {
driver.findElement(By.id("authenticators-choice"));
driver.findElement(By.id("kc-select-credential-form"));
} catch (NoSuchElementException nfe) {
return false;
}

View file

@ -20,17 +20,37 @@ import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Assert;
import org.junit.Test;
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.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.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation;
import org.keycloak.representations.account.SessionRepresentation;
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.CredentialRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
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.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.TokenUtil;
@ -41,11 +61,14 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.ws.rs.core.Response;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.Assert.*;
import org.keycloak.services.resources.account.AccountCredentialResource.PasswordUpdate;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import org.keycloak.testsuite.util.WaitUtils;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -277,6 +300,198 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
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 {
TokenUtil viewToken = new TokenUtil("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
passwordPage.clickTryAnotherWayLink();
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("OTP");
loginTotpPage.clickTryAnotherWayLink();
selectAuthenticatorPage.selectLoginMethod("Password");
selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.PASSWORD);
// Login with password
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
passwordPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent();
selectAuthenticatorPage.selectLoginMethod("OTP");
selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION);
loginTotpPage.assertCurrent();

View file

@ -49,7 +49,6 @@ import org.keycloak.testsuite.pages.PasswordPage;
import org.keycloak.testsuite.pages.SelectAuthenticatorPage;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.WaitUtils;
import org.openqa.selenium.WebDriver;
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
@ -101,14 +100,6 @@ public class MultiFactorAuthenticationTest extends AbstractTestRealmKeycloakTest
return res;
}
private void importTestRealm(Consumer<RealmRepresentation> realmUpdater) {
RealmRepresentation realm = loadTestRealm();
if (realmUpdater != null) {
realmUpdater.accept(realm);
}
importRealm(realm);
}
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
log.debug("Adding test realm for import from testrealm.json");
@ -138,10 +129,14 @@ public class MultiFactorAuthenticationTest extends AbstractTestRealmKeycloakTest
passwordPage.clickTryAnotherWayLink();
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
selectAuthenticatorPage.selectLoginMethod("OTP");
selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION);
loginTotpPage.assertCurrent();
loginTotpPage.assertTryAnotherWayLinkAvailability(true);
loginTotpPage.assertOtpCredentialSelectorAvailability(false);
@ -159,7 +154,7 @@ public class MultiFactorAuthenticationTest extends AbstractTestRealmKeycloakTest
loginTotpPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent();
Assert.assertEquals(Arrays.asList("OTP", "Password"), selectAuthenticatorPage.getAvailableLoginMethods());
Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION, SelectAuthenticatorPage.PASSWORD), selectAuthenticatorPage.getAvailableLoginMethods());
} finally {
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
passwordPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent();
Assert.assertEquals(Arrays.asList("Password", "OTP"), selectAuthenticatorPage.getAvailableLoginMethods());
selectAuthenticatorPage.selectLoginMethod("OTP");
Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.PASSWORD, SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION), selectAuthenticatorPage.getAvailableLoginMethods());
selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION);
// Should be on the OTP now. Click "Try another way" again. Should see again both Password and OTP
loginTotpPage.assertCurrent();
@ -227,9 +222,9 @@ public class MultiFactorAuthenticationTest extends AbstractTestRealmKeycloakTest
loginTotpPage.clickTryAnotherWayLink();
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.login("password");

View file

@ -104,7 +104,7 @@ loginTotpCounter=Counter
loginTotp.totp=Time-based
loginTotp.hotp=Counter-based
loginChooseAuthenticator=Select your authentication method
loginChooseAuthenticator=Select login method
oauthGrantRequest=Do you grant these access privileges?
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.
#authenticators
auth-otp-form=OTP
auth-password-form=Password
auth-username-form=Username
auth-username-password-form=Username and password
webauthn-authenticator=WebAuthn
webauthn-authenticator-passwordless=WebAuthn Passwordless
otp-display-name=Authenticator Application
otp-help-text=Enter a verification code from authenticator application.
password-help-text=Log in by entering your password.
auth-username-form-display-name=Username
auth-username-form-help-text=Start log in by entering your username
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

View file

@ -2,34 +2,41 @@
<@layout.registrationLayout displayInfo=true; section>
<#if section = "header" || section = "show-username">
<script type="text/javascript">
// Fill up the two hidden and submit the form
function fillAndSubmit() {
document.getElementById('authexec-hidden-input').value = document.getElementById('authenticators-choice').value;
function fillAndSubmit(authExecId) {
document.getElementById('authexec-hidden-input').value = authExecId;
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>
<#if section = "header">
${msg("loginChooseAuthenticator")}
</#if>
<#elseif section = "form">
<form id="kc-select-credential-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<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>
<option value="${authenticationSelection.authExecId}" <#if authenticationSelection.authExecId == execution>selected</#if>>${msg('${authenticationSelection.authExecDisplayName}')}</option>
</#list>
</select>
<input type="hidden" id="authexec-hidden-input" name="authenticationExecution" />
</div>
<div class="${properties.kcSelectAuthListClass!}">
<#list auth.authenticationSelections as authenticationSelection>
<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>
<input type="hidden" id="authexec-hidden-input" name="authenticationExecution" />
</div>
</form>
</#if>
</@layout.registrationLayout>

View file

@ -56,26 +56,22 @@
<h1 id="kc-page-title"><#nested "header"></h1>
<#else>
<#nested "show-username">
<div class="${properties.kcFormGroupClass!}">
<div id="kc-username">
<label id="kc-attempted-username">${auth.attemptedUsername}</label>
<a id="reset-login" href="${url.loginRestartFlowUrl}">
<div class="kc-login-tooltip">
<i class="${properties.kcResetFlowIcon!}"></i>
<span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
</#if>
</header>
<div id="kc-content">
<div id="kc-content-wrapper">
<#if auth?has_content && auth.showUsername() && !auth.showResetCredentials()>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-username">
<label id="kc-attempted-username">${auth.attemptedUsername}</label>
<a id="reset-login" href="${url.loginRestartFlowUrl}">
<div class="kc-login-tooltip">
<i class="${properties.kcResetFlowIcon!}"></i>
<span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
<hr/>
</#if>
<#-- App-initiated actions should not see warning messages about the need to complete the action -->
<#-- during login. -->
<#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>

View file

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

View file

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

View file

@ -493,6 +493,14 @@ a.zocial {
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-register-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
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