From d8e450719b77a45982b3e550590b2d575937e19a Mon Sep 17 00:00:00 2001 From: Marek Posolda Date: Fri, 31 Jan 2020 14:28:23 +0100 Subject: [PATCH] =?UTF-8?q?KEYCLOAK-12469=20KEYCLOAK-12185=20Implement=20n?= =?UTF-8?q?ice=20design=20to=20the=20screen=20wit=E2=80=A6=20(#6690)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * KEYCLOAK-12469 KEYCLOAK-12185 Add CredentialTypeMetadata. Implement the screen with authentication mechanisms and implement Account REST Credentials API by use the credential type metadata --- .../AuthenticationSelectionOption.java | 29 +- .../ConfigurableAuthenticatorFactory.java | 1 + .../credential/CredentialProvider.java | 2 + .../credential/CredentialTypeMetadata.java | 282 ++++++++++++++++ .../browser/WebAuthnAuthenticatorFactory.java | 3 +- ...AuthnPasswordlessAuthenticatorFactory.java | 6 + .../requiredactions/WebAuthnRegister.java | 6 + .../credential/OTPCredentialProvider.java | 14 + .../PasswordCredentialProvider.java | 14 + .../WebAuthnCredentialProvider.java | 18 ++ ...ebAuthnPasswordlessCredentialProvider.java | 14 + .../account/AccountCredentialResource.java | 306 ++++++++++-------- .../pages/LanguageComboboxAwarePage.java | 1 - .../pages/SelectAuthenticatorPage.java | 61 +++- .../account/AccountRestServiceTest.java | 215 ++++++++++++ .../KcOidcFirstBrokerLoginNewAuthTest.java | 9 +- .../forms/MultiFactorAuthenticationTest.java | 27 +- .../login/messages/messages_en.properties | 19 +- .../theme/base/login/select-authenticator.ftl | 49 +-- .../resources/theme/base/login/template.ftl | 26 +- .../account/messages/messages_en.properties | 12 +- .../content/signingin-page/SigningInPage.tsx | 14 +- .../keycloak/login/resources/css/login.css | 8 + .../theme/keycloak/login/theme.properties | 17 + 24 files changed, 918 insertions(+), 235 deletions(-) create mode 100644 server-spi/src/main/java/org/keycloak/credential/CredentialTypeMetadata.java diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationSelectionOption.java b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationSelectionOption.java index 64cfe9c037..ec47b8a116 100644 --- a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationSelectionOption.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationSelectionOption.java @@ -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(); } diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/ConfigurableAuthenticatorFactory.java b/server-spi-private/src/main/java/org/keycloak/authentication/ConfigurableAuthenticatorFactory.java index 6a7306ad7a..5e3305c19f 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/ConfigurableAuthenticatorFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/ConfigurableAuthenticatorFactory.java @@ -68,4 +68,5 @@ public interface ConfigurableAuthenticatorFactory extends ConfiguredProvider { * @return */ boolean isUserSetupAllowed(); + } diff --git a/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java b/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java index 244ac72819..561df1cba2 100644 --- a/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java +++ b/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java @@ -49,4 +49,6 @@ public interface CredentialProvider extends Provider } return getCredentialFromModel(models.get(0)); } + + CredentialTypeMetadata getCredentialTypeMetadata(); } diff --git a/server-spi/src/main/java/org/keycloak/credential/CredentialTypeMetadata.java b/server-spi/src/main/java/org/keycloak/credential/CredentialTypeMetadata.java new file mode 100644 index 0000000000..9112e24ec0 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/credential/CredentialTypeMetadata.java @@ -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 Marek Posolda + */ +public class CredentialTypeMetadata implements Comparable { + + 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; + } + + } + + + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticatorFactory.java index cfccf3bf42..3543de5482 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnAuthenticatorFactory.java @@ -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 diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticatorFactory.java index 6293437cdf..a51558d9e4 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticatorFactory.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticatorFactory.java @@ -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 Marek Posolda @@ -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"; diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java index e149d26f91..85903d2dfa 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/WebAuthnRegister.java @@ -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(); diff --git a/services/src/main/java/org/keycloak/credential/OTPCredentialProvider.java b/services/src/main/java/org/keycloak/credential/OTPCredentialProvider.java index 92be2d73f3..19c9d6ef4c 100644 --- a/services/src/main/java/org/keycloak/credential/OTPCredentialProvider.java +++ b/services/src/main/java/org/keycloak/credential/OTPCredentialProvider.java @@ -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 credentials(){ -// auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); -// List 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 userCredentials; - - public CredentialContainer(String category, String type, String helptext, boolean enabled, String createAction, String updateAction, boolean removeable,List userCredentials) { - this.category = category; - this.type = type; - this.helptext = helptext; - this.enabled = enabled; - this.createAction = createAction; - this.updateAction = updateAction; - this.removeable = removeable; + private List userCredentials; + private CredentialTypeMetadata metadata; + + public CredentialContainer() { + } + + public CredentialContainer(CredentialTypeMetadata metadata, List 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 getUserCredentials() { + public List 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 dummyCredentialTypes(){ + public List credentialTypes(@QueryParam(TYPE) String type, + @QueryParam(USER_CREDENTIALS) Boolean userCredentials) { auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); - List models = session.userCredentialManager().getStoredCredentials(realm, user); - - List passwordUserCreds = new java.util.ArrayList<>(); - passwordUserCreds.add(models.get(0)); - - List otpUserCreds = new java.util.ArrayList<>(); - if (models.size() > 1) otpUserCreds.add(models.get(1)); - if (models.size() > 2) otpUserCreds.add(models.get(2)); - - List webauthnUserCreds = new java.util.ArrayList<>(); - CredentialModel webauthnCred = new CredentialModel(); - webauthnCred.setId("bogus-id"); - webauthnCred.setUserLabel("yubikey key"); - webauthnCred.setCreatedDate(1579122652382L); - webauthnUserCreds.add(webauthnCred); - - List 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); - - 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 dummyCreds = new java.util.ArrayList<>(); - dummyCreds.add(password); - dummyCreds.add(otp); - dummyCreds.add(webAuthn); - dummyCreds.add(passwordless); - - return dummyCreds; + + boolean filterUserCredentials = userCredentials != null && !userCredentials; + + List credentialTypes = new LinkedList<>(); + List credentialProviders = UserCredentialStoreManager.getCredentialProviders(session, realm, CredentialProvider.class); + Set enabledCredentialTypes = getEnabledCredentialTypes(credentialProviders); + + List models = filterUserCredentials ? null : session.userCredentialManager().getStoredCredentials(realm, user); + + // Don't return secrets from REST endpoint + if (models != null) { + for (CredentialModel credential : models) { + credential.setSecretData(null); + } + } + + for (CredentialProvider credentialProvider : credentialProviders) { + String credentialProviderType = credentialProvider.getType(); + + // Filter just by single type + if (type != null && !type.equals(credentialProviderType)) { + continue; + } + + boolean enabled = enabledCredentialTypes.contains(credentialProviderType); + + // Filter disabled credential types + if (!enabled) { + continue; + } + + CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata(); + + List 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 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 getEnabledCredentialTypes(List credentialProviders) { + Set 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 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 diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java index 47e8d443b8..bcdd1d203e 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java @@ -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; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SelectAuthenticatorPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SelectAuthenticatorPage.java index 99ac60e2ac..237f537c4c 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SelectAuthenticatorPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SelectAuthenticatorPage.java @@ -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 getAvailableLoginMethods() { - return new Select(authenticatorsSelect).getOptions() - .stream() - .map(WebElement::getText) + List 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 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; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java index a107d6ac30..a32952abb0 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java @@ -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 Stian Thorgersen @@ -277,6 +300,198 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { assertEquals(expectedStatus, status); } + @Test + public void testCredentialsGet() throws IOException { + configureBrowserFlowWithWebAuthnAuthenticator("browser-webauthn"); + + List 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>() {}); + 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>() {}); + 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 getCredentials() throws IOException { + return SimpleHttp.doGet(getAccountUrl("credentials"), httpClient) + .auth(tokenUtil.getToken()).asJson(new TypeReference>() {}); + } + + @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 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 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 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 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"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginNewAuthTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginNewAuthTest.java index 3576620a1a..f41b5b8950 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginNewAuthTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcFirstBrokerLoginNewAuthTest.java @@ -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(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/MultiFactorAuthenticationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/MultiFactorAuthenticationTest.java index e72ac6c788..fa496a37ce 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/MultiFactorAuthenticationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/MultiFactorAuthenticationTest.java @@ -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 realmUpdater) { - RealmRepresentation realm = loadTestRealm(); - if (realmUpdater != null) { - realmUpdater.accept(realm); - } - importRealm(realm); - } - @Override public void addTestRealms(List 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"); diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index b85b02e827..6a795dc38e 100755 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -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 diff --git a/themes/src/main/resources/theme/base/login/select-authenticator.ftl b/themes/src/main/resources/theme/base/login/select-authenticator.ftl index 80184aa54d..0225cf94e6 100644 --- a/themes/src/main/resources/theme/base/login/select-authenticator.ftl +++ b/themes/src/main/resources/theme/base/login/select-authenticator.ftl @@ -2,34 +2,41 @@ <@layout.registrationLayout displayInfo=true; section> <#if section = "header" || section = "show-username"> + <#if section = "header"> + ${msg("loginChooseAuthenticator")} + <#elseif section = "form"> +
-
-
- -
-
- - -
+
+ <#list auth.authenticationSelections as authenticationSelection> +
+
+
+ +
+
+
+
+ ${msg('${authenticationSelection.displayName}')} +
+
+ ${msg('${authenticationSelection.helpText}')} +
+
+
+
+
+ +
+ diff --git a/themes/src/main/resources/theme/base/login/template.ftl b/themes/src/main/resources/theme/base/login/template.ftl index d91966aded..09c874d68a 100644 --- a/themes/src/main/resources/theme/base/login/template.ftl +++ b/themes/src/main/resources/theme/base/login/template.ftl @@ -56,26 +56,22 @@

<#nested "header">

<#else> <#nested "show-username"> +
+
+ + + + +
+
- <#if auth?has_content && auth.showUsername() && !auth.showResetCredentials()> -
-
- - - - -
-
-
- - <#-- 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??)> diff --git a/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties b/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties index 7ba8784c44..b8d69f3110 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties @@ -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 diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx index c0e04e800b..979e93e7a0 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx @@ -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 , - , + , ]}/> @@ -229,7 +231,7 @@ class SigningInPage extends React.Component @@ -238,7 +240,7 @@ class SigningInPage extends React.Component - <strong><Msg msgKey={credContainer.type}/></strong> + <strong><Msg msgKey={credContainer.displayName}/></strong> , @@ -249,7 +251,7 @@ class SigningInPage extends React.Component - + diff --git a/themes/src/main/resources/theme/keycloak/login/resources/css/login.css b/themes/src/main/resources/theme/keycloak/login/resources/css/login.css index 0680d2b59d..3cbbdc1f51 100644 --- a/themes/src/main/resources/theme/keycloak/login/resources/css/login.css +++ b/themes/src/main/resources/theme/keycloak/login/resources/css/login.css @@ -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 { diff --git a/themes/src/main/resources/theme/keycloak/login/theme.properties b/themes/src/main/resources/theme/keycloak/login/theme.properties index e98b4653cb..53096dabff 100644 --- a/themes/src/main/resources/theme/keycloak/login/theme.properties +++ b/themes/src/main/resources/theme/keycloak/login/theme.properties @@ -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