diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index eddf339bb9..74e9c772ea 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -37,6 +37,7 @@ public class Profile { public static final String PRODUCT_NAME = ProductValue.RHSSO.getName(); public static final String PROJECT_NAME = ProductValue.KEYCLOAK.getName(); private static final Logger logger = Logger.getLogger(Profile.class); + private static Profile CURRENT; private final ProductValue product; private final ProfileValue profile; @@ -167,7 +168,8 @@ public class Profile { DECLARATIVE_USER_PROFILE("Configure user profiles using a declarative style", Type.PREVIEW), DYNAMIC_SCOPES("Dynamic OAuth 2.0 scopes", Type.EXPERIMENTAL), CLIENT_SECRET_ROTATION("Client Secret Rotation", Type.PREVIEW), - STEP_UP_AUTHENTICATION("Step-up Authentication", Type.DEFAULT); + STEP_UP_AUTHENTICATION("Step-up Authentication", Type.DEFAULT), + RECOVERY_CODES("Recovery codes", Type.PREVIEW); private final Type typeProject; diff --git a/common/src/test/java/org/keycloak/common/ProfileTest.java b/common/src/test/java/org/keycloak/common/ProfileTest.java index ff18cc3f62..b99964808a 100644 --- a/common/src/test/java/org/keycloak/common/ProfileTest.java +++ b/common/src/test/java/org/keycloak/common/ProfileTest.java @@ -22,8 +22,8 @@ public class ProfileTest { @Test public void checkDefaultsKeycloak() { Assert.assertEquals("community", Profile.getName()); - assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); - assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); + assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.RECOVERY_CODES, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); + assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.RECOVERY_CODES, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); @@ -38,8 +38,8 @@ public class ProfileTest { Profile.init(); Assert.assertEquals("product", Profile.getName()); - assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); - assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); + assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DYNAMIC_SCOPES, Profile.Feature.ADMIN2, Profile.Feature.DOCKER, Profile.Feature.RECOVERY_CODES, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.MAP_STORAGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); + assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.RECOVERY_CODES, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.DECLARATIVE_USER_PROFILE, Feature.CLIENT_SECRET_ROTATION); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); diff --git a/core/src/main/java/org/keycloak/representations/account/CredentialMetadataRepresentation.java b/core/src/main/java/org/keycloak/representations/account/CredentialMetadataRepresentation.java new file mode 100644 index 0000000000..da42b416bc --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/account/CredentialMetadataRepresentation.java @@ -0,0 +1,45 @@ +package org.keycloak.representations.account; + +import org.keycloak.representations.idm.CredentialRepresentation; + +public class CredentialMetadataRepresentation { + + String infoMessage; + String warningMessageTitle; + String warningMessageDescription; + + private CredentialRepresentation credential; + + + public CredentialRepresentation getCredential() { + return credential; + } + + public void setCredential(CredentialRepresentation credential) { + this.credential = credential; + } + + public String getInfoMessage() { + return infoMessage; + } + + public void setInfoMessage(String infoMessage) { + this.infoMessage = infoMessage; + } + + public String getWarningMessageTitle() { + return warningMessageTitle; + } + + public void setWarningMessageTitle(String warningMessageTitle) { + this.warningMessageTitle = warningMessageTitle; + } + + public String getWarningMessageDescription() { + return warningMessageDescription; + } + + public void setWarningMessageDescription(String warningMessageDescription) { + this.warningMessageDescription = warningMessageDescription; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java index 6ef50a6006..b29d8c27c7 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java @@ -27,6 +27,7 @@ public enum LoginFormsPages { OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, LOGIN_SELECT_AUTHENTICATOR, REGISTER, REGISTER_USER_PROFILE, INFO, ERROR, ERROR_WEBAUTHN, LOGIN_UPDATE_PROFILE, LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM, LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE, UPDATE_USER_PROFILE, IDP_REVIEW_USER_PROFILE, + LOGIN_RECOVERY_AUTHN_CODES_INPUT, LOGIN_RECOVERY_AUTHN_CODES_CONFIG, FRONTCHANNEL_LOGOUT; } diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java index 9726dea8f4..8106019e07 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java @@ -68,6 +68,8 @@ public interface LoginFormsProvider extends Provider { Response createLoginTotp(); + Response createLoginRecoveryAuthnCode(); + Response createLoginWebAuthn(); Response createRegistration(); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultRequiredActions.java b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultRequiredActions.java index d5de86719a..acfad2c940 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultRequiredActions.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultRequiredActions.java @@ -17,6 +17,7 @@ package org.keycloak.models.utils; +import org.keycloak.common.Profile; import org.keycloak.models.RealmModel; import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.UserModel; @@ -61,6 +62,18 @@ public class DefaultRequiredActions { realm.addRequiredActionProvider(totp); } + if (realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()) == null && + Profile.isFeatureEnabled(Profile.Feature.RECOVERY_CODES)) { + RequiredActionProviderModel recoveryCodes = new RequiredActionProviderModel(); + recoveryCodes.setEnabled(true); + recoveryCodes.setAlias(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + recoveryCodes.setName("Recovery Authentication Codes"); + recoveryCodes.setProviderId(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + recoveryCodes.setDefaultAction(false); + recoveryCodes.setPriority(70); + realm.addRequiredActionProvider(recoveryCodes); + } + if (realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_PASSWORD.name()) == null) { RequiredActionProviderModel updatePassword = new RequiredActionProviderModel(); updatePassword.setEnabled(true); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 1d53864d25..ff3f84edb5 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -29,6 +29,7 @@ import org.keycloak.common.Profile; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.Time; import org.keycloak.component.ComponentModel; +import org.keycloak.credential.CredentialMetadata; import org.keycloak.credential.CredentialModel; import org.keycloak.events.Event; import org.keycloak.events.admin.AdminEvent; @@ -36,11 +37,14 @@ import org.keycloak.events.admin.AuthDetails; import org.keycloak.models.*; import org.keycloak.models.credential.OTPCredentialModel; import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.account.CredentialMetadataRepresentation; import org.keycloak.representations.idm.*; import org.keycloak.representations.idm.authorization.*; import org.keycloak.storage.StorageId; +import org.keycloak.util.JsonSerialization; import org.keycloak.utils.StringUtil; +import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -595,6 +599,28 @@ public class ModelToRepresentation { return rep; } + public static CredentialMetadataRepresentation toRepresentation(CredentialMetadata credentialMetadata) { + CredentialMetadataRepresentation rep = new CredentialMetadataRepresentation(); + + rep.setCredential(ModelToRepresentation.toRepresentation(credentialMetadata.getCredentialModel())); + try { + rep.setInfoMessage(credentialMetadata.getInfoMessage() == null ? null : JsonSerialization.writeValueAsString(credentialMetadata.getInfoMessage())); + } catch (IOException e) { + LOG.warn("unable to serialize model information, skipping info message", e); + } + try { + rep.setWarningMessageDescription(credentialMetadata.getWarningMessageDescription() == null ? null : JsonSerialization.writeValueAsString(credentialMetadata.getWarningMessageDescription())); + } catch (IOException e) { + LOG.warn("unable to serialize model information, skipping warning message desc", e); + } + try { + rep.setWarningMessageTitle(credentialMetadata.getWarningMessageTitle() == null ? null : JsonSerialization.writeValueAsString(credentialMetadata.getWarningMessageTitle())); + } catch (IOException e) { + LOG.warn("unable to serialize model information, skipping warning message title", e); + } + return rep; + } + public static FederatedIdentityRepresentation toRepresentation(FederatedIdentityModel socialLink) { FederatedIdentityRepresentation rep = new FederatedIdentityRepresentation(); rep.setUserName(socialLink.getUserName()); diff --git a/server-spi-private/src/main/java/org/keycloak/policy/RecoveryCodesWarningThresholdPasswordPolicyProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/policy/RecoveryCodesWarningThresholdPasswordPolicyProviderFactory.java new file mode 100644 index 0000000000..7976c02af1 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/policy/RecoveryCodesWarningThresholdPasswordPolicyProviderFactory.java @@ -0,0 +1,99 @@ +/* + * Copyright 2016 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.policy; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.credential.hash.PasswordHashProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +/** + * @author Stian Thorgersen + */ +public class RecoveryCodesWarningThresholdPasswordPolicyProviderFactory implements PasswordPolicyProviderFactory, PasswordPolicyProvider, EnvironmentDependentProviderFactory { + + private KeycloakSession session; + + @Override + public PasswordPolicyProvider create(KeycloakSession session) { + this.session = session; + return this; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PasswordPolicy.RECOVERY_CODES_WARNING_THRESHOLD_ID; + } + + @Override + public PolicyError validate(RealmModel realm, UserModel user, String password) { + return null; + } + + @Override + public PolicyError validate(String user, String password) { + return null; + } + + @Override + public String getDisplayName() { + return "Recovery Codes Warning Threshold"; + } + + @Override + public String getConfigType() { + return PasswordPolicyProvider.INT_CONFIG_TYPE; + } + + @Override + public String getDefaultConfigValue() { + return String.valueOf(PasswordPolicy.RECOVERY_CODES_WARNING_THRESHOLD_DEFAULT); + } + + @Override + public boolean isMultiplSupported() { + return false; + } + + @Override + public Object parseConfig(String value) { + return parseInteger(value, PasswordPolicy.RECOVERY_CODES_WARNING_THRESHOLD_DEFAULT); + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.RECOVERY_CODES); + } +} diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory index a6211f4513..fd6da8459a 100644 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory @@ -29,3 +29,4 @@ org.keycloak.policy.SpecialCharsPasswordPolicyProviderFactory org.keycloak.policy.UpperCasePasswordPolicyProviderFactory org.keycloak.policy.BlacklistPasswordPolicyProviderFactory org.keycloak.policy.NotEmailPasswordPolicyProviderFactory +org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory diff --git a/server-spi/src/main/java/org/keycloak/credential/CredentialMetadata.java b/server-spi/src/main/java/org/keycloak/credential/CredentialMetadata.java new file mode 100644 index 0000000000..dcc915c809 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/credential/CredentialMetadata.java @@ -0,0 +1,62 @@ +package org.keycloak.credential; + +public class CredentialMetadata { + LocalizedMessage infoMessage; + LocalizedMessage warningMessageTitle; + LocalizedMessage warningMessageDescription; + CredentialModel credentialModel; + + public CredentialModel getCredentialModel() { + return credentialModel; + } + + public void setCredentialModel(CredentialModel credentialModel) { + this.credentialModel = credentialModel; + } + + public LocalizedMessage getInfoMessage() { + return infoMessage; + } + + public LocalizedMessage getWarningMessageTitle() { + return warningMessageTitle; + } + + public LocalizedMessage getWarningMessageDescription() { + return warningMessageDescription; + } + + public void setWarningMessageTitle(String key, String... parameters) { + LocalizedMessage message = new LocalizedMessage(key, parameters); + this.warningMessageTitle = message; + } + + public void setWarningMessageDescription(String key, String... parameters) { + LocalizedMessage message = new LocalizedMessage(key, parameters); + this.warningMessageDescription = message; + } + + public void setInfoMessage(String key, String... parameters) { + LocalizedMessage message = new LocalizedMessage(key, parameters); + this.infoMessage = message; + } + + public static class LocalizedMessage { + private final String key; + private final Object[] parameters; + + public LocalizedMessage(String key, Object[] parameters) { + this.key = key; + this.parameters = parameters; + } + + public String getKey() { + return key; + } + + public Object[] getParameters() { + return parameters; + } + } + +} 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 0c4a9b0c4f..94aafac039 100644 --- a/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java +++ b/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java @@ -21,6 +21,8 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.provider.Provider; +import java.io.IOException; + /** * @author Bill Burke * @version $Revision: 1 $ @@ -47,4 +49,10 @@ public interface CredentialProvider extends Provider } CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext); + + default CredentialMetadata getCredentialMetadata(T credentialModel, CredentialTypeMetadata credentialTypeMetadata) { + CredentialMetadata credentialMetadata = new CredentialMetadata(); + credentialMetadata.setCredentialModel(credentialModel); + return credentialMetadata; + } } diff --git a/server-spi/src/main/java/org/keycloak/models/PasswordPolicy.java b/server-spi/src/main/java/org/keycloak/models/PasswordPolicy.java index 10d59d9b16..0a22e3983c 100755 --- a/server-spi/src/main/java/org/keycloak/models/PasswordPolicy.java +++ b/server-spi/src/main/java/org/keycloak/models/PasswordPolicy.java @@ -17,6 +17,7 @@ package org.keycloak.models; +import org.keycloak.crypto.Algorithm; import org.keycloak.policy.PasswordPolicyConfigException; import org.keycloak.policy.PasswordPolicyProvider; @@ -43,6 +44,10 @@ public class PasswordPolicy implements Serializable { public static final String FORCE_EXPIRED_ID = "forceExpiredPasswordChange"; + public static final int RECOVERY_CODES_WARNING_THRESHOLD_DEFAULT = 4; + + public static final String RECOVERY_CODES_WARNING_THRESHOLD_ID = "recoveryCodesWarningThreshold"; + private Map policyConfig; private Builder builder; @@ -103,6 +108,14 @@ public class PasswordPolicy implements Serializable { } } + public int getRecoveryCodesWarningThreshold() { + if (policyConfig.containsKey(RECOVERY_CODES_WARNING_THRESHOLD_ID)) { + return getPolicyConfig(RECOVERY_CODES_WARNING_THRESHOLD_ID); + } else { + return 4; + } + } + @Override public String toString() { return builder.asString(); diff --git a/server-spi/src/main/java/org/keycloak/models/UserCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/UserCredentialModel.java index f642a4af45..b137d7116e 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserCredentialModel.java +++ b/server-spi/src/main/java/org/keycloak/models/UserCredentialModel.java @@ -20,6 +20,7 @@ package org.keycloak.models; import org.keycloak.common.util.SecretGenerator; import org.keycloak.credential.CredentialInput; import org.keycloak.credential.CredentialModel; +import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; import org.keycloak.models.credential.OTPCredentialModel; import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.credential.PasswordUserCredentialModel; @@ -126,6 +127,14 @@ public class UserCredentialModel implements CredentialInput { return new UserCredentialModel("", SECRET, SecretGenerator.getInstance().randomString()); } + public static UserCredentialModel buildFromBackupAuthnCode(String backupAuthnCodeInput) { + return buildFromBackupAuthnCode("", backupAuthnCodeInput); + } + + public static UserCredentialModel buildFromBackupAuthnCode(String credentialId, String backupAuthnCodeInput) { + return new UserCredentialModel(credentialId, RecoveryAuthnCodesCredentialModel.TYPE, backupAuthnCodeInput); + } + @Override public String getCredentialId() { return credentialId; diff --git a/server-spi/src/main/java/org/keycloak/models/UserModel.java b/server-spi/src/main/java/org/keycloak/models/UserModel.java index af0eac0d9e..65b6cd60c5 100755 --- a/server-spi/src/main/java/org/keycloak/models/UserModel.java +++ b/server-spi/src/main/java/org/keycloak/models/UserModel.java @@ -299,7 +299,12 @@ public interface UserModel extends RoleMapperModel { void setServiceAccountClientLink(String clientInternalId); enum RequiredAction { - VERIFY_EMAIL, UPDATE_PROFILE, CONFIGURE_TOTP, UPDATE_PASSWORD, TERMS_AND_CONDITIONS, + VERIFY_EMAIL, + UPDATE_PROFILE, + CONFIGURE_TOTP, + CONFIGURE_RECOVERY_AUTHN_CODES, + UPDATE_PASSWORD, + TERMS_AND_CONDITIONS, VERIFY_PROFILE } diff --git a/server-spi/src/main/java/org/keycloak/models/credential/PasswordCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/credential/PasswordCredentialModel.java index 044e70481b..ca04e0a38c 100644 --- a/server-spi/src/main/java/org/keycloak/models/credential/PasswordCredentialModel.java +++ b/server-spi/src/main/java/org/keycloak/models/credential/PasswordCredentialModel.java @@ -45,7 +45,6 @@ public class PasswordCredentialModel extends CredentialModel { PasswordCredentialData credentialData = JsonSerialization.readValue(credentialModel.getCredentialData(), PasswordCredentialData.class); PasswordSecretData secretData = JsonSerialization.readValue(credentialModel.getSecretData(), PasswordSecretData.class); - PasswordCredentialModel passwordCredentialModel = new PasswordCredentialModel(credentialData, secretData); passwordCredentialModel.setCreatedDate(credentialModel.getCreatedDate()); passwordCredentialModel.setCredentialData(credentialModel.getCredentialData()); diff --git a/server-spi/src/main/java/org/keycloak/models/credential/RecoveryAuthnCodesCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/credential/RecoveryAuthnCodesCredentialModel.java new file mode 100644 index 0000000000..5bbd84aae2 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/credential/RecoveryAuthnCodesCredentialModel.java @@ -0,0 +1,108 @@ +package org.keycloak.models.credential; + +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.keycloak.credential.CredentialMetadata; +import org.keycloak.credential.CredentialModel; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.credential.dto.RecoveryAuthnCodeRepresentation; +import org.keycloak.models.credential.dto.RecoveryAuthnCodesCredentialData; +import org.keycloak.models.credential.dto.RecoveryAuthnCodesSecretData; +import org.keycloak.models.utils.RecoveryAuthnCodesUtils; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.List; + +public class RecoveryAuthnCodesCredentialModel extends CredentialModel { + + public static final String TYPE = "recovery-authn-codes"; + + public static final String RECOVERY_CODES_NUMBER_USED = "recovery-codes-number-used"; + public static final String RECOVERY_CODES_NUMBER_REMAINING = "recovery-codes-number-remaining"; + public static final String RECOVERY_CODES_GENERATE_NEW_CODES = "recovery-codes-generate-new-codes"; + + private final RecoveryAuthnCodesCredentialData credentialData; + private final RecoveryAuthnCodesSecretData secretData; + + private RecoveryAuthnCodesCredentialModel(RecoveryAuthnCodesCredentialData credentialData, + RecoveryAuthnCodesSecretData secretData) { + this.credentialData = credentialData; + this.secretData = secretData; + } + + public Optional getNextRecoveryAuthnCode() { + if (allCodesUsed()) { + return Optional.empty(); + } + return Optional.of(this.secretData.getCodes().get(0)); + } + + public boolean allCodesUsed() { + return this.secretData.getCodes().isEmpty(); + } + + public void removeRecoveryAuthnCode() { + try { + this.secretData.removeNextBackupCode(); + this.credentialData.setRemainingCodes(this.secretData.getCodes().size()); + this.setSecretData(JsonSerialization.writeValueAsString(this.secretData)); + this.setCredentialData(JsonSerialization.writeValueAsString(this.credentialData)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static RecoveryAuthnCodesCredentialModel createFromValues(List originalGeneratedCodes, long generatedAt, + String userLabel) { + RecoveryAuthnCodesSecretData secretData; + RecoveryAuthnCodesCredentialData credentialData; + RecoveryAuthnCodesCredentialModel model; + + try { + List recoveryCodes = IntStream.range(0, originalGeneratedCodes.size()) + .mapToObj(i -> new RecoveryAuthnCodeRepresentation(i + 1, + RecoveryAuthnCodesUtils.hashRawCode(originalGeneratedCodes.get(i)))) + .collect(Collectors.toList()); + secretData = new RecoveryAuthnCodesSecretData(recoveryCodes); + credentialData = new RecoveryAuthnCodesCredentialData(RecoveryAuthnCodesUtils.NUM_HASH_ITERATIONS, + RecoveryAuthnCodesUtils.NOM_ALGORITHM_TO_HASH, recoveryCodes.size(), recoveryCodes.size()); + model = new RecoveryAuthnCodesCredentialModel(credentialData, secretData); + model.setCredentialData(JsonSerialization.writeValueAsString(credentialData)); + model.setSecretData(JsonSerialization.writeValueAsString(secretData)); + model.setCreatedDate(generatedAt); + model.setType(TYPE); + + if (userLabel != null) { + model.setUserLabel(userLabel); + } + return model; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static RecoveryAuthnCodesCredentialModel createFromCredentialModel(CredentialModel credentialModel) { + RecoveryAuthnCodesCredentialData credentialData; + RecoveryAuthnCodesSecretData secretData = null; + RecoveryAuthnCodesCredentialModel newModel; + try { + credentialData = JsonSerialization.readValue(credentialModel.getCredentialData(), + RecoveryAuthnCodesCredentialData.class); + secretData = JsonSerialization.readValue(credentialModel.getSecretData(), RecoveryAuthnCodesSecretData.class); + newModel = new RecoveryAuthnCodesCredentialModel(credentialData, secretData); + newModel.setUserLabel(credentialModel.getUserLabel()); + newModel.setCreatedDate(credentialModel.getCreatedDate()); + newModel.setType(TYPE); + newModel.setId(credentialModel.getId()); + newModel.setSecretData(credentialModel.getSecretData()); + newModel.setCredentialData(credentialModel.getCredentialData()); + return newModel; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/server-spi/src/main/java/org/keycloak/models/credential/dto/RecoveryAuthnCodeRepresentation.java b/server-spi/src/main/java/org/keycloak/models/credential/dto/RecoveryAuthnCodeRepresentation.java new file mode 100644 index 0000000000..0a3bfed65a --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/credential/dto/RecoveryAuthnCodeRepresentation.java @@ -0,0 +1,28 @@ +package org.keycloak.models.credential.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RecoveryAuthnCodeRepresentation { + + private final int number; + private final String encodedHashedValue; + + @JsonCreator + public RecoveryAuthnCodeRepresentation(@JsonProperty("number") int number, + @JsonProperty("encodedHashedValue") String encodedHashedValue) { + this.number = number; + this.encodedHashedValue = encodedHashedValue; + } + + public int getNumber() { + return this.number; + } + + public String getEncodedHashedValue() { + return this.encodedHashedValue; + } + +} diff --git a/server-spi/src/main/java/org/keycloak/models/credential/dto/RecoveryAuthnCodesCredentialData.java b/server-spi/src/main/java/org/keycloak/models/credential/dto/RecoveryAuthnCodesCredentialData.java new file mode 100644 index 0000000000..3470f51828 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/credential/dto/RecoveryAuthnCodesCredentialData.java @@ -0,0 +1,49 @@ +package org.keycloak.models.credential.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RecoveryAuthnCodesCredentialData { + + private final int hashIterations; + private final String algorithm; + + private int totalCodes; + private int remainingCodes; + + @JsonCreator + public RecoveryAuthnCodesCredentialData(@JsonProperty("hashIterations") int hashIterations, + @JsonProperty("algorithm") String algorithm, @JsonProperty("remaining") int remainingCodes, + @JsonProperty("total") int totalCodes) { + this.hashIterations = hashIterations; + this.algorithm = algorithm; + this.remainingCodes = remainingCodes; + this.totalCodes = totalCodes; + } + + public int getHashIterations() { + return hashIterations; + } + + public String getAlgorithm() { + return algorithm; + } + + public int getRemainingCodes() { + return remainingCodes; + } + + public void setRemainingCodes(int remainingCodes) { + this.remainingCodes = remainingCodes; + } + + public int getTotalCodes() { + return totalCodes; + } + + public void setTotalCodes(int totalCodes) { + this.totalCodes = totalCodes; + } + + +} diff --git a/server-spi/src/main/java/org/keycloak/models/credential/dto/RecoveryAuthnCodesSecretData.java b/server-spi/src/main/java/org/keycloak/models/credential/dto/RecoveryAuthnCodesSecretData.java new file mode 100644 index 0000000000..58b47738eb --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/credential/dto/RecoveryAuthnCodesSecretData.java @@ -0,0 +1,25 @@ +package org.keycloak.models.credential.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public class RecoveryAuthnCodesSecretData { + + private final List codes; + + @JsonCreator + public RecoveryAuthnCodesSecretData(@JsonProperty("codes") List codes) { + this.codes = codes; + } + + public List getCodes() { + return this.codes; + } + + public void removeNextBackupCode() { + this.codes.remove(0); + } + +} diff --git a/server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java b/server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java new file mode 100644 index 0000000000..6cfda6768e --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java @@ -0,0 +1,46 @@ +package org.keycloak.models.utils; + +import java.util.function.Supplier; +import org.keycloak.common.util.Base64; +import org.keycloak.common.util.SecretGenerator; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.JavaAlgorithm; +import org.keycloak.jose.jws.crypto.HashUtils; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class RecoveryAuthnCodesUtils { + + private static final int QUANTITY_OF_CODES_TO_GENERATE = 12; + private static final int CODE_LENGTH = 12; + public static final char[] UPPERNUM = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789".toCharArray(); + private static final SecretGenerator SECRET_GENERATOR = SecretGenerator.getInstance(); + public static final String NOM_ALGORITHM_TO_HASH = Algorithm.RS512; + public static final int NUM_HASH_ITERATIONS = 1; + public static final String RECOVERY_AUTHN_CODES_INPUT_DEFAULT_ERROR_MESSAGE = "recovery-codes-error-invalid"; + public static final String FIELD_RECOVERY_CODE_IN_BROWSER_FLOW = "recoveryCodeInput"; + + public static String hashRawCode(String rawGeneratedCode) { + Objects.requireNonNull(rawGeneratedCode, "rawGeneratedCode cannot be null"); + + byte[] rawCodeHashedAsBytes = HashUtils.hash(JavaAlgorithm.getJavaAlgorithmForHash(NOM_ALGORITHM_TO_HASH), + rawGeneratedCode.getBytes(StandardCharsets.UTF_8)); + + return Base64.encodeBytes(rawCodeHashedAsBytes); + } + + public static boolean verifyRecoveryCodeInput(String rawInputRecoveryCode, String hashedSavedRecoveryCode) { + String hashedInputBackupCode = hashRawCode(rawInputRecoveryCode); + return (hashedInputBackupCode.equals(hashedSavedRecoveryCode)); + } + + public static List generateRawCodes() { + Supplier code = () -> SECRET_GENERATOR.randomString(CODE_LENGTH,UPPERNUM); + return Stream.generate(code).limit(QUANTITY_OF_CODES_TO_GENERATE).collect(Collectors.toList()); + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java index 6f02b2b988..a8e79d201b 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java @@ -258,7 +258,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth } protected boolean isDisabledByBruteForce(AuthenticationFlowContext context, UserModel user) { - String bruteForceError = getDisabledByBruteForceEventError(context.getProtector(), context.getSession(), context.getRealm(), user); + String bruteForceError = getDisabledByBruteForceEventError(context, user); if (bruteForceError != null) { context.getEvent().user(user); context.getEvent().error(bruteForceError); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java new file mode 100644 index 0000000000..d6f651f3de --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java @@ -0,0 +1,153 @@ +package org.keycloak.authentication.authenticators.browser; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.authenticators.util.AuthenticatorUtils; +import org.keycloak.common.util.ObjectUtil; +import org.keycloak.credential.CredentialModel; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialManager; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; +import org.keycloak.models.utils.RecoveryAuthnCodesUtils; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.services.messages.Messages; +import org.keycloak.sessions.AuthenticationSessionModel; + +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import java.util.Optional; + +import static org.keycloak.services.validation.Validation.FIELD_USERNAME; + +public class RecoveryAuthnCodesFormAuthenticator implements Authenticator { + + private final UserCredentialManager userCredentialManager; + + public RecoveryAuthnCodesFormAuthenticator(KeycloakSession keycloakSession) { + this.userCredentialManager = keycloakSession.userCredentialManager(); + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + context.challenge(createLoginForm(context, false, null, null)); + } + + @Override + public void action(AuthenticationFlowContext context) { + context.getEvent().detail(Details.CREDENTIAL_TYPE, RecoveryAuthnCodesCredentialModel.TYPE); + if (isRecoveryAuthnCodeInputValid(context)) { + context.success(); + } + } + + private boolean isRecoveryAuthnCodeInputValid(AuthenticationFlowContext authnFlowContext) { + boolean result = false; + MultivaluedMap formParamsMap = authnFlowContext.getHttpRequest().getDecodedFormParameters(); + String recoveryAuthnCodeUserInput = formParamsMap.getFirst(RecoveryAuthnCodesUtils.FIELD_RECOVERY_CODE_IN_BROWSER_FLOW); + + if (ObjectUtil.isBlank(recoveryAuthnCodeUserInput)) { + authnFlowContext.forceChallenge(createLoginForm(authnFlowContext, true, + RecoveryAuthnCodesUtils.RECOVERY_AUTHN_CODES_INPUT_DEFAULT_ERROR_MESSAGE, + RecoveryAuthnCodesUtils.FIELD_RECOVERY_CODE_IN_BROWSER_FLOW)); + return result; + } + RealmModel targetRealm = authnFlowContext.getRealm(); + UserModel authenticatedUser = authnFlowContext.getUser(); + if (!isDisabledByBruteForce(authnFlowContext, authenticatedUser)) { + boolean isValid = this.userCredentialManager.isValid(targetRealm, authenticatedUser, + UserCredentialModel.buildFromBackupAuthnCode(recoveryAuthnCodeUserInput.replace("-", ""))); + if (!isValid) { + Response responseChallenge = createLoginForm(authnFlowContext, true, + RecoveryAuthnCodesUtils.RECOVERY_AUTHN_CODES_INPUT_DEFAULT_ERROR_MESSAGE, + RecoveryAuthnCodesUtils.FIELD_RECOVERY_CODE_IN_BROWSER_FLOW); + authnFlowContext.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, responseChallenge); + } else { + result = true; + Optional optUserCredentialFound = this.userCredentialManager.getStoredCredentialsByTypeStream(targetRealm, + authenticatedUser, RecoveryAuthnCodesCredentialModel.TYPE).findFirst(); + RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = null; + if (optUserCredentialFound.isPresent()) { + recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel + .createFromCredentialModel(optUserCredentialFound.get()); + if (recoveryCodeCredentialModel.allCodesUsed()) { + this.userCredentialManager.removeStoredCredential(targetRealm, authenticatedUser, + recoveryCodeCredentialModel.getId()); + } + } + if (recoveryCodeCredentialModel == null || recoveryCodeCredentialModel.allCodesUsed()) { + authenticatedUser.addRequiredAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES); + } + } + } + return result; + } + + protected boolean isDisabledByBruteForce(AuthenticationFlowContext authnFlowContext, UserModel authenticatedUser) { + String bruteForceError; + Response challengeResponse; + bruteForceError = getDisabledByBruteForceEventError(authnFlowContext, authenticatedUser); + if (bruteForceError == null) { + return false; + } + authnFlowContext.getEvent().user(authenticatedUser); + authnFlowContext.getEvent().error(bruteForceError); + challengeResponse = createLoginForm(authnFlowContext, false, Messages.INVALID_USER, FIELD_USERNAME); + authnFlowContext.forceChallenge(challengeResponse); + return true; + } + + protected String getDisabledByBruteForceEventError(AuthenticationFlowContext authnFlowContext, UserModel authenticatedUser) { + return AuthenticatorUtils.getDisabledByBruteForceEventError(authnFlowContext, authenticatedUser); + } + + private Response createLoginForm(AuthenticationFlowContext authnFlowContext, boolean withInvalidUserCredentialsError, + String errorToRaise, String fieldError) { + Response challengeResponse; + LoginFormsProvider loginFormsProvider; + if (withInvalidUserCredentialsError) { + loginFormsProvider = authnFlowContext.form(); + authnFlowContext.getEvent().user(authnFlowContext.getUser()); + authnFlowContext.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + loginFormsProvider.addError(new FormMessage(fieldError, errorToRaise)); + } else { + loginFormsProvider = authnFlowContext.form().setExecution(authnFlowContext.getExecution().getId()); + if (errorToRaise != null) { + if (fieldError != null) { + loginFormsProvider.addError(new FormMessage(fieldError, errorToRaise)); + } else { + loginFormsProvider.setError(errorToRaise); + } + } + } + challengeResponse = loginFormsProvider.createLoginRecoveryAuthnCode(); + return challengeResponse; + } + + @Override + public boolean requiresUser() { + return true; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return session.userCredentialManager().isConfiguredFor(realm, user, RecoveryAuthnCodesCredentialModel.TYPE); + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + AuthenticationSessionModel authenticationSession = session.getContext().getAuthenticationSession(); + authenticationSession.addRequiredAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + } + + @Override + public void close() { + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticatorFactory.java new file mode 100644 index 0000000000..950c557298 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticatorFactory.java @@ -0,0 +1,82 @@ +package org.keycloak.authentication.authenticators.browser; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.ConfigurableAuthenticatorFactory; +import org.keycloak.common.Profile; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +public class RecoveryAuthnCodesFormAuthenticatorFactory implements AuthenticatorFactory, EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "auth-recovery-authn-code-form"; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Recovery Authentication Code Form"; + } + + @Override + public String getReferenceCategory() { + return RecoveryAuthnCodesCredentialModel.TYPE; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return ConfigurableAuthenticatorFactory.REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return true; + } + + @Override + public String getHelpText() { + return "Validates a Recovery Authentication Code"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public Authenticator create(KeycloakSession keycloakSession) { + return new RecoveryAuthnCodesFormAuthenticator(keycloakSession); + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.RECOVERY_CODES); + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java index b14ffe0437..8bb36b9eb4 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java @@ -77,7 +77,7 @@ public class ValidateUsername extends AbstractDirectGrantAuthenticator { return; } - String bruteForceError = getDisabledByBruteForceEventError(context.getProtector(), context.getSession(), context.getRealm(), user); + String bruteForceError = getDisabledByBruteForceEventError(context, user); if (bruteForceError != null) { context.getEvent().user(user); context.getEvent().error(bruteForceError); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java b/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java index 9045f26c28..3fc8044aa4 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java @@ -17,6 +17,7 @@ package org.keycloak.authentication.authenticators.util; +import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.events.Errors; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -39,4 +40,8 @@ public final class AuthenticatorUtils { } return null; } + + public static String getDisabledByBruteForceEventError(AuthenticationFlowContext authnFlowContext, UserModel authenticatedUser) { + return AuthenticatorUtils.getDisabledByBruteForceEventError(authnFlowContext.getProtector(), authnFlowContext.getSession(), authnFlowContext.getRealm(), authenticatedUser); + } } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/x509/ValidateX509CertificateUsername.java b/services/src/main/java/org/keycloak/authentication/authenticators/x509/ValidateX509CertificateUsername.java index 12815d5154..6bef0456cc 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/x509/ValidateX509CertificateUsername.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/x509/ValidateX509CertificateUsername.java @@ -124,7 +124,7 @@ public class ValidateX509CertificateUsername extends AbstractX509ClientCertifica return; } - String bruteForceError = getDisabledByBruteForceEventError(context.getProtector(), context.getSession(), context.getRealm(), user); + String bruteForceError = getDisabledByBruteForceEventError(context, user); if (bruteForceError != null) { context.getEvent().user(user); context.getEvent().error(bruteForceError); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/x509/X509ClientCertificateAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/x509/X509ClientCertificateAuthenticator.java index 735719a3e1..2a0be0d1a1 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/x509/X509ClientCertificateAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/x509/X509ClientCertificateAuthenticator.java @@ -139,7 +139,7 @@ public class X509ClientCertificateAuthenticator extends AbstractX509ClientCertif return; } - String bruteForceError = getDisabledByBruteForceEventError(context.getProtector(), context.getSession(), context.getRealm(), user); + String bruteForceError = getDisabledByBruteForceEventError(context, user); if (bruteForceError != null) { context.getEvent().user(user); context.getEvent().error(bruteForceError); diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java b/services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java new file mode 100644 index 0000000000..ac54d2a55f --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java @@ -0,0 +1,116 @@ +package org.keycloak.authentication.requiredactions; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.keycloak.Config; +import org.keycloak.authentication.InitiatedActionSupport; +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.common.Profile; +import org.keycloak.credential.RecoveryAuthnCodesCredentialProviderFactory; +import org.keycloak.credential.CredentialProvider; +import org.keycloak.events.Details; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; +import org.keycloak.models.utils.RecoveryAuthnCodesUtils; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; + +public class RecoveryAuthnCodesAction implements RequiredActionProvider, RequiredActionFactory, EnvironmentDependentProviderFactory { + + private static final String FIELD_GENERATED_RECOVERY_AUTHN_CODES_HIDDEN = "generatedRecoveryAuthnCodes"; + private static final String FIELD_GENERATED_AT_HIDDEN = "generatedAt"; + private static final String FIELD_USER_LABEL_HIDDEN = "userLabel"; + public static final String PROVIDER_ID = UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name(); + private static final RecoveryAuthnCodesAction INSTANCE = new RecoveryAuthnCodesAction(); + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayText() { + return "Recovery Authentication Codes"; + } + + @Override + public RequiredActionProvider create(KeycloakSession session) { + return INSTANCE; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public boolean isOneTimeAction() { + return true; + } + + @Override + public InitiatedActionSupport initiatedActionSupport() { + return InitiatedActionSupport.SUPPORTED; + } + + @Override + public void evaluateTriggers(RequiredActionContext context) { + } + + @Override + public void requiredActionChallenge(RequiredActionContext context) { + Response challenge = context.form().createResponse(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES); + context.challenge(challenge); + } + + @Override + public void processAction(RequiredActionContext reqActionContext) { + CredentialProvider recoveryCodeCredentialProvider; + MultivaluedMap httpReqParamsMap; + Long generatedAtTime; + String generatedUserLabel; + + recoveryCodeCredentialProvider = reqActionContext.getSession().getProvider(CredentialProvider.class, + RecoveryAuthnCodesCredentialProviderFactory.PROVIDER_ID); + + reqActionContext.getEvent().detail(Details.CREDENTIAL_TYPE, RecoveryAuthnCodesCredentialModel.TYPE); + + httpReqParamsMap = reqActionContext.getHttpRequest().getDecodedFormParameters(); + List generatedCodes = new ArrayList<>( + Arrays.asList(httpReqParamsMap.getFirst(FIELD_GENERATED_RECOVERY_AUTHN_CODES_HIDDEN).split(","))); + generatedAtTime = Long.parseLong(httpReqParamsMap.getFirst(FIELD_GENERATED_AT_HIDDEN)); + generatedUserLabel = httpReqParamsMap.getFirst(FIELD_USER_LABEL_HIDDEN); + + RecoveryAuthnCodesCredentialModel credentialModel = createFromValues(generatedCodes, generatedAtTime, generatedUserLabel); + + recoveryCodeCredentialProvider.createCredential(reqActionContext.getRealm(), reqActionContext.getUser(), + credentialModel); + + reqActionContext.success(); + } + + protected RecoveryAuthnCodesCredentialModel createFromValues(List generatedCodes, Long generatedAtTime, String generatedUserLabel) { + return RecoveryAuthnCodesCredentialModel.createFromValues(generatedCodes, + generatedAtTime, generatedUserLabel); + } + + @Override + public void close() { + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.RECOVERY_CODES); + } +} diff --git a/services/src/main/java/org/keycloak/credential/RecoveryAuthnCodesCredentialProvider.java b/services/src/main/java/org/keycloak/credential/RecoveryAuthnCodesCredentialProvider.java new file mode 100644 index 0000000000..6bb03d465a --- /dev/null +++ b/services/src/main/java/org/keycloak/credential/RecoveryAuthnCodesCredentialProvider.java @@ -0,0 +1,132 @@ +package org.keycloak.credential; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; +import org.keycloak.models.credential.dto.RecoveryAuthnCodeRepresentation; +import org.keycloak.models.credential.dto.RecoveryAuthnCodesCredentialData; +import org.keycloak.models.utils.RecoveryAuthnCodesUtils; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +import static org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel.*; + +public class RecoveryAuthnCodesCredentialProvider + implements CredentialProvider, CredentialInputValidator { + + private static final Logger logger = Logger.getLogger(RecoveryAuthnCodesCredentialProvider.class); + + private final KeycloakSession session; + + public RecoveryAuthnCodesCredentialProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public String getType() { + return RecoveryAuthnCodesCredentialModel.TYPE; + } + + @Override + public CredentialModel createCredential(RealmModel realm, UserModel user, + RecoveryAuthnCodesCredentialModel credentialModel) { + + session.userCredentialManager().getStoredCredentialsByTypeStream(realm, user, getType()).findFirst() + .ifPresent(model -> deleteCredential(realm, user, model.getId())); + + return session.userCredentialManager().createCredential(realm, user, credentialModel); + } + + @Override + public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) { + return session.userCredentialManager().removeStoredCredential(realm, user, credentialId); + } + + @Override + public RecoveryAuthnCodesCredentialModel getCredentialFromModel(CredentialModel model) { + return RecoveryAuthnCodesCredentialModel.createFromCredentialModel(model); + } + + @Override + public CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext) { + CredentialTypeMetadata.CredentialTypeMetadataBuilder builder = CredentialTypeMetadata.builder().type(getType()) + .category(CredentialTypeMetadata.Category.TWO_FACTOR).displayName("recovery-authn-codes-display-name") + .helpText("recovery-authn-codes-help-text").iconCssClass("kcAuthenticatorRecoveryAuthnCodesClass") + .removeable(true); + UserModel user = metadataContext.getUser(); + if (user != null && !isConfiguredFor(session.getContext().getRealm(), user, getType())) { + builder.createAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + } else { + builder.updateAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + } + return builder.build(session); + } + + @Override + public CredentialMetadata getCredentialMetadata(RecoveryAuthnCodesCredentialModel credentialModel, CredentialTypeMetadata credentialTypeMetadata) { + + CredentialMetadata credentialMetadata = new CredentialMetadata(); + try { + RecoveryAuthnCodesCredentialData credentialData = JsonSerialization.readValue(credentialModel.getCredentialData(), RecoveryAuthnCodesCredentialData.class); + if (credentialData.getRemainingCodes() < getWarningThreshold()) { + credentialMetadata.setWarningMessageTitle(RECOVERY_CODES_NUMBER_REMAINING, String.valueOf(credentialData.getRemainingCodes())); + credentialMetadata.setWarningMessageDescription(RECOVERY_CODES_GENERATE_NEW_CODES); + } + + int codesUsed = credentialData.getTotalCodes() - credentialData.getRemainingCodes(); + String codesUsedMessage = codesUsed + "/" + credentialData.getTotalCodes(); + credentialMetadata.setInfoMessage(RECOVERY_CODES_NUMBER_USED, codesUsedMessage); + } catch (IOException e) { + logger.warn("unable to deserialize model information, skipping messages", e); + } + credentialMetadata.setCredentialModel(credentialModel); + + return credentialMetadata; + } + + @Override + public boolean supportsCredentialType(String credentialType) { + return getType().equals(credentialType); + } + + @Override + public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) { + return session.userCredentialManager().getStoredCredentialsByTypeStream(realm, user, credentialType).anyMatch(Objects::nonNull); + } + + @Override + public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) { + String rawInputRecoveryAuthnCode = credentialInput.getChallengeResponse(); + Optional credential = session.userCredentialManager() + .getStoredCredentialsByTypeStream(realm, user, getType()).findFirst(); + if (credential.isPresent()) { + RecoveryAuthnCodesCredentialModel credentialModel = RecoveryAuthnCodesCredentialModel + .createFromCredentialModel(credential.get()); + if (!credentialModel.allCodesUsed()) { + Optional nextRecoveryAuthnCode = credentialModel.getNextRecoveryAuthnCode(); + if (nextRecoveryAuthnCode.isPresent()) { + String nextRecoveryCode = nextRecoveryAuthnCode.get().getEncodedHashedValue(); + if (RecoveryAuthnCodesUtils.verifyRecoveryCodeInput(rawInputRecoveryAuthnCode, nextRecoveryCode)) { + credentialModel.removeRecoveryAuthnCode(); + session.userCredentialManager().updateCredential(realm, user, credentialModel); + return true; + } + + } + } + } + return false; + } + + protected int getWarningThreshold() { + return session.getContext().getRealm().getPasswordPolicy().getRecoveryCodesWarningThreshold(); + } +} diff --git a/services/src/main/java/org/keycloak/credential/RecoveryAuthnCodesCredentialProviderFactory.java b/services/src/main/java/org/keycloak/credential/RecoveryAuthnCodesCredentialProviderFactory.java new file mode 100644 index 0000000000..a101778ec6 --- /dev/null +++ b/services/src/main/java/org/keycloak/credential/RecoveryAuthnCodesCredentialProviderFactory.java @@ -0,0 +1,28 @@ +package org.keycloak.credential; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.userprofile.DeclarativeUserProfileProvider; + +public class RecoveryAuthnCodesCredentialProviderFactory + implements CredentialProviderFactory, EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "keycloak-recovery-authn-codes"; + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public RecoveryAuthnCodesCredentialProvider create(KeycloakSession session) { + return new RecoveryAuthnCodesCredentialProvider(session); + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.RECOVERY_CODES); + } +} diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index 3646df6798..ad1e53fd32 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -28,6 +28,8 @@ import org.keycloak.common.util.ObjectUtil; import org.keycloak.forms.login.LoginFormsPages; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean; +import org.keycloak.forms.login.freemarker.model.RecoveryAuthnCodeInputLoginBean; +import org.keycloak.forms.login.freemarker.model.RecoveryAuthnCodesBean; import org.keycloak.forms.login.freemarker.model.ClientBean; import org.keycloak.forms.login.freemarker.model.CodeBean; import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; @@ -151,6 +153,10 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { actionMessage = Messages.CONFIGURE_TOTP; page = LoginFormsPages.LOGIN_CONFIG_TOTP; break; + case CONFIGURE_RECOVERY_AUTHN_CODES: + actionMessage = Messages.CONFIGURE_BACKUP_CODES; + page = LoginFormsPages.LOGIN_RECOVERY_AUTHN_CODES_CONFIG; + break; case UPDATE_PROFILE: UpdateProfileContext userBasedContext = new UserUpdateProfileContext(realm, user); this.attributes.put(UPDATE_PROFILE_CONTEXT_ATTR, userBasedContext); @@ -218,6 +224,12 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { case LOGIN_CONFIG_TOTP: attributes.put("totp", new TotpBean(session, realm, user, uriInfo.getRequestUriBuilder())); break; + case LOGIN_RECOVERY_AUTHN_CODES_CONFIG: + attributes.put("recoveryAuthnCodesConfigBean", new RecoveryAuthnCodesBean()); + break; + case LOGIN_RECOVERY_AUTHN_CODES_INPUT: + attributes.put("recoveryAuthnCodesInputBean", new RecoveryAuthnCodeInputLoginBean(session, realm, user)); + break; case LOGIN_UPDATE_PROFILE: UpdateProfileContext userCtx = (UpdateProfileContext) attributes.get(LoginFormsProvider.UPDATE_PROFILE_CONTEXT_ATTR); attributes.put("user", new ProfileBean(userCtx, formData)); @@ -549,6 +561,11 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { return createResponse(LoginFormsPages.LOGIN_TOTP); } + @Override + public Response createLoginRecoveryAuthnCode() { + return createResponse(LoginFormsPages.LOGIN_RECOVERY_AUTHN_CODES_INPUT); + } + @Override public Response createLoginWebAuthn() { return createResponse(LoginFormsPages.LOGIN_WEBAUTHN); diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java b/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java index 3c6e582242..8c639864a4 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java @@ -36,6 +36,10 @@ public class Templates { return "login-otp.ftl"; case LOGIN_CONFIG_TOTP: return "login-config-totp.ftl"; + case LOGIN_RECOVERY_AUTHN_CODES_INPUT: + return "login-recovery-authn-code-input.ftl"; + case LOGIN_RECOVERY_AUTHN_CODES_CONFIG: + return "login-recovery-authn-code-config.ftl"; case LOGIN_WEBAUTHN: return "webauthn-authenticate.ftl"; case LOGIN_VERIFY_EMAIL: diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java new file mode 100644 index 0000000000..73b8a69b2b --- /dev/null +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java @@ -0,0 +1,29 @@ +package org.keycloak.forms.login.freemarker.model; + +import org.keycloak.credential.CredentialModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; + +public class RecoveryAuthnCodeInputLoginBean { + + private final int codeNumber; + + public RecoveryAuthnCodeInputLoginBean(KeycloakSession session, RealmModel realm, UserModel user) { + CredentialModel credentialModel = session.userCredentialManager() + .getStoredCredentialsByTypeStream(realm, + user, + RecoveryAuthnCodesCredentialModel.TYPE) + .findFirst().get(); + + RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModel); + + this.codeNumber = recoveryCodeCredentialModel.getNextRecoveryAuthnCode().get().getNumber(); + } + + public int getCodeNumber() { + return this.codeNumber; + } + +} diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodesBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodesBean.java new file mode 100644 index 0000000000..0fd3aa7b9c --- /dev/null +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodesBean.java @@ -0,0 +1,32 @@ +package org.keycloak.forms.login.freemarker.model; + +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.utils.RecoveryAuthnCodesUtils; + +import java.util.List; + +public class RecoveryAuthnCodesBean { + + private final List generatedRecoveryAuthnCodesList; + private final long generatedAt; + + public RecoveryAuthnCodesBean() { + this.generatedRecoveryAuthnCodesList = RecoveryAuthnCodesUtils.generateRawCodes(); + this.generatedAt = Time.currentTimeMillis(); + } + + public List getGeneratedRecoveryAuthnCodesList() { + return this.generatedRecoveryAuthnCodesList; + } + + public String getGeneratedRecoveryAuthnCodesAsString() { + return String.join(",", this.generatedRecoveryAuthnCodesList); + } + + public long getGeneratedAt() { + return generatedAt; + } + +} diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index fb39f5fc09..0cb6bd4d81 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -92,6 +92,8 @@ public class Messages { public static final String CONFIGURE_TOTP = "configureTotpMessage"; + public static final String CONFIGURE_BACKUP_CODES = "configureBackupCodesMessage"; + public static final String UPDATE_PROFILE = "updateProfileMessage"; public static final String RESET_PASSWORD = "resetPasswordMessage"; diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java b/services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java index 04b83faecd..361464f4e1 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java @@ -5,6 +5,7 @@ import org.jboss.logging.Logger; import org.jboss.resteasy.annotations.cache.NoCache; import org.keycloak.authentication.Authenticator; import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.credential.CredentialMetadata; import org.keycloak.credential.CredentialModel; import org.keycloak.credential.CredentialProvider; import org.keycloak.credential.CredentialTypeMetadata; @@ -17,6 +18,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.representations.account.CredentialMetadataRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.ErrorResponseException; @@ -86,13 +88,13 @@ public class AccountCredentialResource { private String createAction; private String updateAction; private boolean removeable; - private List userCredentials; + private List userCredentialMetadatas; private CredentialTypeMetadata metadata; public CredentialContainer() { } - public CredentialContainer(CredentialTypeMetadata metadata, List userCredentials) { + public CredentialContainer(CredentialTypeMetadata metadata, List userCredentialMetadatas) { this.metadata = metadata; this.type = metadata.getType(); this.category = metadata.getCategory().toString(); @@ -102,7 +104,7 @@ public class AccountCredentialResource { this.createAction = metadata.getCreateAction(); this.updateAction = metadata.getUpdateAction(); this.removeable = metadata.isRemoveable(); - this.userCredentials = userCredentials; + this.userCredentialMetadatas = userCredentialMetadatas; } public String getCategory() { @@ -137,8 +139,8 @@ public class AccountCredentialResource { return removeable; } - public List getUserCredentials() { - return userCredentials; + public List getUserCredentialMetadatas() { + return userCredentialMetadatas; } @JsonIgnore @@ -171,8 +173,7 @@ public class AccountCredentialResource { Set enabledCredentialTypes = getEnabledCredentialTypes(credentialProviders); Stream modelsStream = includeUserCredentials ? session.userCredentialManager().getStoredCredentialsStream(realm, user) : Stream.empty(); - // Don't return secrets from REST endpoint - List models = modelsStream.peek(model -> model.setSecretData(null)).collect(Collectors.toList()); + List models = modelsStream.collect(Collectors.toList()); Function toCredentialContainer = (credentialProvider) -> { CredentialTypeMetadataContext ctx = CredentialTypeMetadataContext.builder() @@ -180,29 +181,43 @@ public class AccountCredentialResource { .build(session); CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata(ctx); - List userCredentialModels = null; + List userCredentialMetadataModels = null; + if (includeUserCredentials) { - userCredentialModels = models.stream() + List modelsOfType = models.stream() .filter(credentialModel -> credentialProvider.getType().equals(credentialModel.getType())) - .map(ModelToRepresentation::toRepresentation) .collect(Collectors.toList()); - if (userCredentialModels.isEmpty() && + + List credentialMetadataList = modelsOfType.stream() + .map(m -> { + return credentialProvider.getCredentialMetadata( + credentialProvider.getCredentialFromModel(m), metadata + ); + }).collect(Collectors.toList()); + + // Don't return secrets from REST endpoint + credentialMetadataList.stream().forEach(md -> md.getCredentialModel().setSecretData(null)); + userCredentialMetadataModels = credentialMetadataList.stream().map(ModelToRepresentation::toRepresentation).collect(Collectors.toList()); + + if (userCredentialMetadataModels.isEmpty() && session.userCredentialManager().isConfiguredFor(realm, user, credentialProvider.getType())) { // In case user is federated in the userStorage, he may have credential configured on the userStorage side. We're // creating "dummy" credential representing the credential provided by userStorage + CredentialMetadataRepresentation metadataRepresentation = new CredentialMetadataRepresentation(); CredentialRepresentation credential = createUserStorageCredentialRepresentation(credentialProvider.getType()); - userCredentialModels = Collections.singletonList(credential); + metadataRepresentation.setCredential(credential); + userCredentialMetadataModels = Collections.singletonList(metadataRepresentation); } // In case that there are no userCredentials AND there are not required actions for setup new credential, // we won't include credentialType as user won't be able to do anything with it - if (userCredentialModels.isEmpty() && metadata.getCreateAction() == null && metadata.getUpdateAction() == null) { + if (userCredentialMetadataModels.isEmpty() && metadata.getCreateAction() == null && metadata.getUpdateAction() == null) { return null; } } - return new CredentialContainer(metadata, userCredentialModels); + return new CredentialContainer(metadata, userCredentialMetadataModels); }; return credentialProviders.stream() diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index 19e5518c51..f8dee2e680 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -54,4 +54,5 @@ org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory -org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory \ No newline at end of file +org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory +org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory index eb56155543..89b8c9a38d 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory @@ -24,4 +24,5 @@ org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory org.keycloak.authentication.requiredactions.UpdateUserLocaleAction org.keycloak.authentication.requiredactions.DeleteAccount -org.keycloak.authentication.requiredactions.VerifyUserProfile \ No newline at end of file +org.keycloak.authentication.requiredactions.VerifyUserProfile +org.keycloak.authentication.requiredactions.RecoveryAuthnCodesAction \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.credential.CredentialProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.credential.CredentialProviderFactory index 77af812282..73305e54a6 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.credential.CredentialProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.credential.CredentialProviderFactory @@ -1,3 +1,4 @@ +org.keycloak.credential.RecoveryAuthnCodesCredentialProviderFactory org.keycloak.credential.OTPCredentialProviderFactory org.keycloak.credential.PasswordCredentialProviderFactory org.keycloak.credential.WebAuthnCredentialProviderFactory diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/EnterRecoveryAuthnCodePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/EnterRecoveryAuthnCodePage.java new file mode 100644 index 0000000000..b4bc8015a0 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/EnterRecoveryAuthnCodePage.java @@ -0,0 +1,61 @@ +package org.keycloak.testsuite.pages; + +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * Signing In Page with required action "Enter Backup Code for authentication" + * + * @author Venkata Nukala + */ +public class EnterRecoveryAuthnCodePage extends LanguageComboboxAwarePage { + + @FindBy(xpath = "//label[@for='recoveryCodeInput']") + private WebElement recoveryAuthnCodeLabel; + + @FindBy(id = "recoveryCodeInput") + private WebElement recoveryAuthnCodeTextField; + + @FindBy(id = "kc-login") + private WebElement signInButton; + + @FindBy(className = "kc-feedback-text") + private WebElement feedbackText; + + public int getRecoveryAuthnCodeToEnterNumber() { + String [] recoveryAuthnCodeLabelParts = recoveryAuthnCodeLabel.getText().split("#"); + return Integer.valueOf(recoveryAuthnCodeLabelParts[1]) - 1; // Recovery Authn Code 1 is at element 0 in the list + } + + public void enterRecoveryAuthnCode(String recoveryCode) { + recoveryAuthnCodeTextField.sendKeys(recoveryCode); + } + + public void clickSignInButton() { + signInButton.click(); + } + + @Override + public boolean isCurrent() { + + // Check the backup code text box and label available + try { + driver.findElement(By.id("recoveryCodeInput")); + driver.findElement(By.xpath("//label[@for='recoveryCodeInput']")); + } catch (NoSuchElementException nfe) { + return false; + } + return true; + } + + @Override + public void open() throws Exception { + throw new UnsupportedOperationException(); + } + + public String getFeedbackText() { + return feedbackText.getText().trim(); + } +} 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 bcdd1d203e..2b3d24e000 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 @@ -48,6 +48,9 @@ public abstract class LanguageComboboxAwarePage extends AbstractPage { @FindBy(id = "reset-login") private WebElement resetLoginLink; + @FindBy(id = "account") + private WebElement accountLink; + public String getLanguageDropdownText() { return languageText.getText(); } @@ -73,6 +76,18 @@ public abstract class LanguageComboboxAwarePage extends AbstractPage { tryAnotherWayLink.click(); } + public void assertAccountLinkAvailability(boolean expectedAvailability) { + try { + driver.findElement(By.id("account")); + Assert.assertTrue(expectedAvailability); + } catch (NoSuchElementException nse) { + Assert.assertFalse(expectedAvailability); + } + } + + public void clickAccountLink() { + accountLink.click(); + } // If false, we don't expect "attempted username" link available on the page. If true, we expect that it is available on the page public void assertAttemptedUsernameAvailability(boolean expectedAvailability) { @@ -92,7 +107,6 @@ public abstract class LanguageComboboxAwarePage extends AbstractPage { return attemptedUsernameLabel.getText(); } - public void clickResetLogin() { resetLoginLink.click(); } 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 2dd64aebf9..523131aa9a 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 @@ -25,18 +25,17 @@ public class SelectAuthenticatorPage extends LanguageComboboxAwarePage { // Corresponds to the WebAuthn authenticators public static final String SECURITY_KEY = "Security Key"; + public static final String RECOVERY_AUTHN_CODES = "Recovery Authentication Code"; /** * Return list of names like for example [ "Password", "Authenticator Application", "Security Key" ] */ public List getAvailableLoginMethods() { List rows = getLoginMethodsRows(); - return rows.stream() .map(this::getLoginMethodNameFromRow) .collect(Collectors.toList()); } - /** * * Selects the chosen login method (For example "Password") by click on it. @@ -74,29 +73,23 @@ public class SelectAuthenticatorPage extends LanguageComboboxAwarePage { .orElseThrow(() -> new AssertionError("Login method '" + loginMethodName + "' not found in the available authentication mechanisms")); } - @Override public boolean isCurrent() { // Check the title if (!DroneUtils.getCurrentDriver().getTitle().startsWith("Sign in to ") && !DroneUtils.getCurrentDriver().getTitle().startsWith("Anmeldung bei ")) { return false; } - // Check the authenticators-choice available try { driver.findElement(By.id("kc-select-credential-form")); } catch (NoSuchElementException nfe) { return false; } - return true; } - @Override public void open() throws Exception { throw new UnsupportedOperationException(); } - - } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SetupRecoveryAuthnCodesPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SetupRecoveryAuthnCodesPage.java new file mode 100644 index 0000000000..76cc797ca8 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/SetupRecoveryAuthnCodesPage.java @@ -0,0 +1,56 @@ +package org.keycloak.testsuite.pages; + +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Scanner; + +public class SetupRecoveryAuthnCodesPage extends LanguageComboboxAwarePage { + + @FindBy(id = "kc-recovery-codes-list") + private WebElement recoveryAuthnCodesList; + + @FindBy(id = "saveRecoveryAuthnCodesBtn") + private WebElement saveRecoveryAuthnCodesButton; + + @FindBy(id="kcRecoveryCodesConfirmationCheck") + private WebElement kcRecoveryCodesConfirmationCheck; + + public void clickSaveRecoveryAuthnCodesButton() { + kcRecoveryCodesConfirmationCheck.click(); + saveRecoveryAuthnCodesButton.click(); + } + + public List getRecoveryAuthnCodes() { + String recoveryAuthnCodesText = recoveryAuthnCodesList.getText(); + List recoveryAuthnCodesList = new ArrayList<>(); + Scanner scanner = new Scanner(recoveryAuthnCodesText); + while (scanner.hasNextLine()) { + recoveryAuthnCodesList.add(scanner.nextLine()); + } + scanner.close(); + return recoveryAuthnCodesList; + } + + @Override + public boolean isCurrent() { + + // Check the backup code text box and label available + try { + driver.findElement(By.id("kc-recovery-codes-list")); + driver.findElement(By.id("saveRecoveryAuthnCodesBtn")); + } catch (NoSuchElementException nfe) { + return false; + } + return true; + } + + @Override + public void open() throws Exception { + throw new UnsupportedOperationException(); + } +} 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 1a8015888b..f13ba65a89 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 @@ -553,7 +553,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { "password-display-name", "password-help-text", "kcAuthenticatorPasswordClass", null, UserModel.RequiredAction.UPDATE_PASSWORD.toString(), false, 1); - CredentialRepresentation password1 = password.getUserCredentials().get(0); + CredentialRepresentation password1 = password.getUserCredentialMetadatas().get(0).getCredential(); assertNull(password1.getSecretData()); Assert.assertNotNull(password1.getCredentialData()); @@ -592,7 +592,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { Assert.assertEquals(1, credentials.size()); password = credentials.get(0); Assert.assertEquals(PasswordCredentialModel.TYPE, password.getType()); - Assert.assertEquals(1, password.getUserCredentials().size()); + Assert.assertEquals(1, password.getUserCredentialMetadatas().size()); // Test password-only and user-credentials credentials = SimpleHttp.doGet(getAccountUrl("credentials?" + AccountCredentialResource.TYPE + "=password&" + @@ -601,7 +601,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { Assert.assertEquals(1, credentials.size()); password = credentials.get(0); Assert.assertEquals(PasswordCredentialModel.TYPE, password.getType()); - assertNull(password.getUserCredentials()); + assertNull(password.getUserCredentialMetadatas()); } @@ -820,7 +820,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest { Assert.assertEquals(createAction, credential.getCreateAction()); Assert.assertEquals(updateAction, credential.getUpdateAction()); Assert.assertEquals(removeable, credential.isRemoveable()); - Assert.assertEquals(userCredentialsCount, credential.getUserCredentials().size()); + Assert.assertEquals(userCredentialsCount, credential.getUserCredentialMetadatas().size()); } public void testDeleteSessions() throws IOException { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java index 362dedb519..9d326a8240 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java @@ -85,7 +85,9 @@ public class RequiredActionsTest extends AbstractAuthenticationTest { // Dummy RequiredAction is not registered in the realm and WebAuthn actions List result = authMgmtResource.getUnregisteredRequiredActions(); Assert.assertEquals(4, result.size()); - RequiredActionProviderSimpleRepresentation action = result.get(0); + RequiredActionProviderSimpleRepresentation action = result.stream().filter( + a -> a.getProviderId().equals(DummyRequiredActionFactory.PROVIDER_ID) + ).findFirst().get(); Assert.assertEquals(DummyRequiredActionFactory.PROVIDER_ID, action.getProviderId()); Assert.assertEquals("Dummy Action", action.getName()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPAccountRestApiTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPAccountRestApiTest.java index c62a3aec67..dde7b459c2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPAccountRestApiTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPAccountRestApiTest.java @@ -177,8 +177,8 @@ public class LDAPAccountRestApiTest extends AbstractLDAPTest { AccountCredentialResource.CredentialContainer password = credentials.get(0); Assert.assertEquals(PasswordCredentialModel.TYPE, password.getType()); - Assert.assertEquals(1, password.getUserCredentials().size()); - CredentialRepresentation userPassword = password.getUserCredentials().get(0); + Assert.assertEquals(1, password.getUserCredentialMetadatas().size()); + CredentialRepresentation userPassword = password.getUserCredentialMetadatas().get(0).getCredential(); // Password won't have createdDate and any metadata set Assert.assertEquals(PasswordCredentialModel.TYPE, userPassword.getType()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RecoveryAuthnCodesAuthenticatorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RecoveryAuthnCodesAuthenticatorTest.java new file mode 100644 index 0000000000..bb2478a15b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/RecoveryAuthnCodesAuthenticatorTest.java @@ -0,0 +1,229 @@ +package org.keycloak.testsuite.forms; + +import org.jboss.arquillian.drone.api.annotation.Drone; +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Test; +import org.keycloak.authentication.AuthenticationFlow; +import org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; +import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; +import org.keycloak.credential.CredentialModel; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel; +import org.keycloak.models.utils.RecoveryAuthnCodesUtils; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.client.KeycloakTestingClient; +import org.keycloak.testsuite.pages.EnterRecoveryAuthnCodePage; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; +import org.keycloak.testsuite.pages.PasswordPage; +import org.keycloak.testsuite.pages.SelectAuthenticatorPage; +import org.keycloak.testsuite.pages.SetupRecoveryAuthnCodesPage; +import org.keycloak.testsuite.util.FlowUtil; +import org.openqa.selenium.WebDriver; +import org.junit.Assert; +import org.keycloak.testsuite.util.WaitUtils; + +import java.util.Arrays; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.keycloak.common.Profile.Feature.RECOVERY_CODES; + +/** + * Backup Code Authentication test + * + * @author Venkata Nukala + */ +@EnableFeature(value = RECOVERY_CODES, skipRestart = true) +public class RecoveryAuthnCodesAuthenticatorTest extends AbstractTestRealmKeycloakTest { + + private static final String BROWSER_FLOW_WITH_RECOVERY_AUTHN_CODES = "Browser with Recovery Authentication Codes"; + + private static final int BRUTE_FORCE_FAIL_ATTEMPTS = 3; + + @Drone + protected WebDriver driver; + + @Page + protected LoginPage loginPage; + + @Page + protected LoginUsernameOnlyPage loginUsernameOnlyPage; + + @Page + protected EnterRecoveryAuthnCodePage enterRecoveryAuthnCodePage; + + @Page + protected SetupRecoveryAuthnCodesPage setupRecoveryAuthnCodesPage; + + @Page + protected SelectAuthenticatorPage selectAuthenticatorPage; + + @Page + protected PasswordPage passwordPage; + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + + } + + void configureBrowserFlowWithRecoveryAuthnCodes(KeycloakTestingClient testingClient) { + final String newFlowAlias = BROWSER_FLOW_WITH_RECOVERY_AUTHN_CODES; + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); + testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session) + .selectFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID) + .addSubFlowExecution(AuthenticationExecutionModel.Requirement.REQUIRED, reqSubFlow -> reqSubFlow + // Add authenticators to this flow: 1 PASSWORD, 2 Another subflow with having only OTP as child + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.ALTERNATIVE, PasswordFormFactory.PROVIDER_ID) + .addSubFlowExecution("Recovery-Authn-Codes subflow", AuthenticationFlow.BASIC_FLOW, AuthenticationExecutionModel.Requirement.ALTERNATIVE, altSubFlow -> altSubFlow + .addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, RecoveryAuthnCodesFormAuthenticatorFactory.PROVIDER_ID) + ) + ) + ) + .defineAsBrowserFlow() + ); + + ApiUtil.removeUserByUsername(testRealm(), "test-user@localhost"); + String userId = createUser("test", "test-user@localhost", "password", UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + } + + // In a sub-flow with alternative credential executors, test whether Recovery Authentication Codes are working + @Test + public void testAuthenticateRecoveryAuthnCodes() { + try { + configureBrowserFlowWithRecoveryAuthnCodes(testingClient); + testRealm().flows().removeRequiredAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertAttemptedUsernameAvailability(false); + loginUsernameOnlyPage.login("test-user@localhost"); + // On the password page, username should be shown as we know the user + passwordPage.assertCurrent(); + passwordPage.assertAttemptedUsernameAvailability(true); + Assert.assertEquals("test-user@localhost", passwordPage.getAttemptedUsername()); + passwordPage.assertTryAnotherWayLinkAvailability(true); + List generatedRecoveryAuthnCodes = RecoveryAuthnCodesUtils.generateRawCodes(); + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealmByName("test"); + UserModel user = session.users().getUserByUsername(realm, "test-user@localhost"); + CredentialModel recoveryAuthnCodesCred = RecoveryAuthnCodesCredentialModel.createFromValues( + generatedRecoveryAuthnCodes, + System.currentTimeMillis(), + null); + session.userCredentialManager().createCredential(realm, user, recoveryAuthnCodesCred); + }); + passwordPage.clickTryAnotherWayLink(); + selectAuthenticatorPage.assertCurrent(); + Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.PASSWORD, SelectAuthenticatorPage.RECOVERY_AUTHN_CODES), selectAuthenticatorPage.getAvailableLoginMethods()); + selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.RECOVERY_AUTHN_CODES); + enterRecoveryAuthnCodePage.assertCurrent(); + enterRecoveryAuthnCodePage.enterRecoveryAuthnCode(generatedRecoveryAuthnCodes.get(enterRecoveryAuthnCodePage.getRecoveryAuthnCodeToEnterNumber())); + enterRecoveryAuthnCodePage.clickSignInButton(); + enterRecoveryAuthnCodePage.assertAccountLinkAvailability(true); + } finally { + // Remove saved Recovery Authentication Codes to keep a clean slate after this test + enterRecoveryAuthnCodePage.assertAccountLinkAvailability(true); + enterRecoveryAuthnCodePage.clickAccountLink(); + assertThat(driver.getTitle(), containsString("Account Management")); + // Revert copy of browser flow to original to keep clean slate after this test + BrowserFlowTest.revertFlows(testRealm(), BROWSER_FLOW_WITH_RECOVERY_AUTHN_CODES); + } + } + + //// In a sub-flow with alternative credential executors, test whether setup Recovery Authentication Codes flow is working + @Test + public void testSetupRecoveryAuthnCodes() { + try { + configureBrowserFlowWithRecoveryAuthnCodes(testingClient); + RequiredActionProviderSimpleRepresentation simpleRepresentation = new RequiredActionProviderSimpleRepresentation(); + simpleRepresentation.setProviderId(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + simpleRepresentation.setName(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + testRealm().flows().registerRequiredAction(simpleRepresentation); + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertAttemptedUsernameAvailability(false); + loginUsernameOnlyPage.login("test-user@localhost"); + // On the password page, username should be shown as we know the user + passwordPage.assertCurrent(); + //passwordPage.assertAttemptedUsernameAvailability(true); + Assert.assertEquals("test-user@localhost", passwordPage.getAttemptedUsername()); + passwordPage.login("password"); + setupRecoveryAuthnCodesPage.assertCurrent(); + setupRecoveryAuthnCodesPage.clickSaveRecoveryAuthnCodesButton(); + } finally { + // Remove saved backup codes to keep a clean slate after this test + setupRecoveryAuthnCodesPage.assertAccountLinkAvailability(true); + setupRecoveryAuthnCodesPage.clickAccountLink(); + assertThat(driver.getTitle(), containsString("Account Management")); + testRealm().flows().removeRequiredAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()); + // Revert copy of browser flow to original to keep clean slate after this test + BrowserFlowTest.revertFlows(testRealm(), BROWSER_FLOW_WITH_RECOVERY_AUTHN_CODES); + } + } + + + @Test + public void testBruteforceProtectionRecoveryAuthnCodes() { + try { + configureBrowserFlowWithRecoveryAuthnCodes(testingClient); + RealmRepresentation rep = testRealm().toRepresentation(); + rep.setBruteForceProtected(true); + testRealm().update(rep); + loginUsernameOnlyPage.open(); + loginUsernameOnlyPage.assertAttemptedUsernameAvailability(false); + loginUsernameOnlyPage.login("test-user@localhost"); + // On the password page, username should be shown as we know the user + passwordPage.assertCurrent(); + passwordPage.assertAttemptedUsernameAvailability(true); + Assert.assertEquals("test-user@localhost", passwordPage.getAttemptedUsername()); + passwordPage.assertTryAnotherWayLinkAvailability(true); + List generatedRecoveryAuthnCodes = RecoveryAuthnCodesUtils.generateRawCodes(); + testingClient.server().run(session -> { + RealmModel realm = session.realms().getRealmByName("test"); + UserModel user = session.users().getUserByUsername(realm, "test-user@localhost"); + CredentialModel recoveryAuthnCodesCred = RecoveryAuthnCodesCredentialModel.createFromValues( + generatedRecoveryAuthnCodes, + System.currentTimeMillis(), + null); + session.userCredentialManager().createCredential(realm, user, recoveryAuthnCodesCred); + }); + passwordPage.clickTryAnotherWayLink(); + selectAuthenticatorPage.assertCurrent(); + Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.PASSWORD, SelectAuthenticatorPage.RECOVERY_AUTHN_CODES), selectAuthenticatorPage.getAvailableLoginMethods()); + selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.RECOVERY_AUTHN_CODES); + enterRecoveryAuthnCodePage.assertCurrent(); + generatedRecoveryAuthnCodes.forEach(code -> System.out.println(code)); + for(int i=0; i < (BRUTE_FORCE_FAIL_ATTEMPTS - 1); i++) { + long randomNumber = (long)Math.random()*1000000000000L; + enterRecoveryAuthnCodePage.enterRecoveryAuthnCode(String.valueOf(randomNumber)); + enterRecoveryAuthnCodePage.clickSignInButton(); + WaitUtils.waitForPageToLoad(); + enterRecoveryAuthnCodePage.assertCurrent(); + String feedbackText = enterRecoveryAuthnCodePage.getFeedbackText(); + Assert.assertEquals(feedbackText, "Invalid recovery authentication code"); + } + // Now enter the right code which should not work + enterRecoveryAuthnCodePage.enterRecoveryAuthnCode(generatedRecoveryAuthnCodes.get(enterRecoveryAuthnCodePage.getRecoveryAuthnCodeToEnterNumber())); + enterRecoveryAuthnCodePage.clickSignInButton(); + // Message changes after exhausting number of brute force attempts + Assert.assertEquals(enterRecoveryAuthnCodePage.getFeedbackText(), "Invalid username or password."); + enterRecoveryAuthnCodePage.assertAccountLinkAvailability(false); + } finally { + RealmRepresentation rep = testRealm().toRepresentation(); + rep.setBruteForceProtected(false); + testRealm().update(rep); + // Revert copy of browser flow to original to keep clean slate after this test + BrowserFlowTest.revertFlows(testRealm(), BROWSER_FLOW_WITH_RECOVERY_AUTHN_CODES); + } + } + +} diff --git a/themes/src/main/resources-community/theme/base/account/messages/messages_da.properties b/themes/src/main/resources-community/theme/base/account/messages/messages_da.properties index 2ed56521f5..2533abf71f 100644 --- a/themes/src/main/resources-community/theme/base/account/messages/messages_da.properties +++ b/themes/src/main/resources-community/theme/base/account/messages/messages_da.properties @@ -282,9 +282,6 @@ authenticatorSMSMessage=Keycloak vil sende godkendelses koden til din telefon so authenticatorSMSFinishSetUpMessage=Tekst beskeder er sendt til authenticatorDefaultStatus=Standard authenticatorChangePhone=Ændre Telefonnummer -authenticatorBackupCodesTitle=Backup Koder -authenticatorBackupCodesMessage=Få dine 8-cifre backup koder -authenticatorBackupCodesFinishSetUpMessage=12 backup koder blev genereret denne gang. Alle kan bruges. #Authenticator - Mobile Authenticator setup authenticatorMobileSetupTitle=Mobile Authenticator Opsætning @@ -306,15 +303,11 @@ enterYourVerficationCode=Indtast din verifikationskode #Authenticator - backup Code setup authenticatorBackupCodesSetupTitle=Backup Kode Opsætning -backupcodesIntroMessage=Hvis du mister adgangen til din telefon, kan du stadig logge ind på konto ved at bruge backup koder. Opbevar dem sikkert og tilgængeligt. realmName=Rige doDownload=Download doPrint=Print doCopy=Kopier -backupCodesTips-1=Hver backup kode kan kun bruges en gang. -backupCodesTips-2=Disse koder blev generet d. generateNewBackupCodes=Generer Nye Backup Koder -backupCodesTips-3=Når du genererer nye backup koder, vil de gamle ikke længere virke. backtoAuthenticatorPage=Tilbage til Authenticator siden diff --git a/themes/src/main/resources-community/theme/base/account/messages/messages_de.properties b/themes/src/main/resources-community/theme/base/account/messages/messages_de.properties index 6802912ec7..12a81d13ee 100644 --- a/themes/src/main/resources-community/theme/base/account/messages/messages_de.properties +++ b/themes/src/main/resources-community/theme/base/account/messages/messages_de.properties @@ -292,9 +292,6 @@ authenticatorSMSMessage=Keycloak sendet den Verifizierungscode an Ihr Telefon al authenticatorSMSFinishSetUpMessage=Textnachrichten werden gesendet an authenticatorDefaultStatus=Standard authenticatorChangePhone=Telefonnummer \u00E4ndern -authenticatorBackupCodesTitle=Backup-Codes -authenticatorBackupCodesMessage=Abfrage der 8-stelligen Backup-Codes -authenticatorBackupCodesFinishSetUpMessage=Zu diesem Zeitpunkt wurden 12 Backup-Codes erzeugt. Jeder davon kann einmal verwendet werden. #Authenticator - Mobile Authenticator setup authenticatorMobileSetupTitle=Handy-Authenticator-Setup @@ -316,14 +313,10 @@ enterYourVerficationCode=Geben Sie Ihren Verifizierungscode ein #Authenticator - backup Code setup authenticatorBackupCodesSetupTitle=Backup-Codes einrichten -backupcodesIntroMessage=Wenn Sie den Zugriff auf Ihr Telefon verlieren, k\u00F6nnen Sie sich immer noch \u00FCber Backup-Codes bei Ihrem Konto anmelden. Bewahren Sie diese an einem sicheren und zug\u00E4nglichen Ort auf. realmName=Realm doDownload=Herunterladen doPrint=Drucken -backupCodesTips-1=Jeder Backup-Code kann nur einmal verwendet werden. -backupCodesTips-2=Diese Codes wurden generiert am generateNewBackupCodes=Neue Backup-Codes generieren -backupCodesTips-3=Wenn Sie neue Backup-Codes generieren, werden die aktuellen Codes nicht mehr funktionieren. backtoAuthenticatorPage=Zur\u00FCck zur Authenticator-Seite diff --git a/themes/src/main/resources-community/theme/base/account/messages/messages_hu.properties b/themes/src/main/resources-community/theme/base/account/messages/messages_hu.properties index 18950c9099..20e9a7638f 100644 --- a/themes/src/main/resources-community/theme/base/account/messages/messages_hu.properties +++ b/themes/src/main/resources-community/theme/base/account/messages/messages_hu.properties @@ -278,9 +278,6 @@ authenticatorSMSMessage=A Keycloak SMS ellenőrző kódot küld a telefonjára ( authenticatorSMSFinishSetUpMessage=A következő telefonszámokra SMS-t küldünk authenticatorDefaultStatus=Alapértelmezett authenticatorChangePhone=Módosítsa telefonszámát -authenticatorBackupCodesTitle=Tartalék kódok -authenticatorBackupCodesMessage=8 számjegyű tartalék kódok igénylése -authenticatorBackupCodesFinishSetUpMessage=12 darab tartalék kódot generáltunk, mindegyiket csak egyszer használhatja fel. #Authenticator - Mobile Authenticator setup authenticatorMobileSetupTitle=Mobil hitelesítő eszköz beállítása @@ -302,14 +299,10 @@ enterYourVerficationCode=Adja meg az ellenőrző kódot #Authenticator - backup Code setup authenticatorBackupCodesSetupTitle=Tartalék kódok beállítása -backupcodesIntroMessage=Ha elveszíti telefonját, még mindig hozzáférhet a felhasználói fiókjához tartalék kódok segítségével. A tartalék kódokat tartsa egy biztonságos, de könnyen hozzáférhető helyen. realmName=Tartomány doDownload=Letöltés doPrint=Nyomtatás -backupCodesTips-1=Minden tartalék kód csak egyszer használható fel. -backupCodesTips-2=Ezeket a kódokat ekkor generálta generateNewBackupCodes=Új tartalék kódok generálása -backupCodesTips-3=Új tartalék kódok generálásakor a korábban generált kódok érvénytelenné válnak. backtoAuthenticatorPage=Vissza a hitelesítő lapra diff --git a/themes/src/main/resources-community/theme/base/account/messages/messages_it.properties b/themes/src/main/resources-community/theme/base/account/messages/messages_it.properties index 40e2c06f87..81c91ba637 100644 --- a/themes/src/main/resources-community/theme/base/account/messages/messages_it.properties +++ b/themes/src/main/resources-community/theme/base/account/messages/messages_it.properties @@ -280,9 +280,6 @@ authenticatorSMSMessage=Keycloak invier\u00E0 il codice di verifica al tuo telef authenticatorSMSFinishSetUpMessage=I messaggi di testo vengono inviati a authenticatorDefaultStatus=Default authenticatorChangePhone=Cambia numero di telefono -authenticatorBackupCodesTitle=Codici di backup -authenticatorBackupCodesMessage=Ottieni i tuoi codici di backup a otto cifre -authenticatorBackupCodesFinishSetUpMessage=Sono stati generati dodici codici di backup. Ognuno pu\u00f2 essere usato una sola volta. #Authenticator - Mobile Authenticator setup authenticatorMobileSetupTitle=Setup autenticatore mobile @@ -304,14 +301,10 @@ enterYourVerficationCode=Inserisci il codice di verifica #Authenticator - backup Code setup authenticatorBackupCodesSetupTitle=Setup backup codici -backupcodesIntroMessage=Se non disponi pi\u00f9 del tuo telefono, puoi comunque accedere al tuo account attraverso i codici di backup. Conservali in un posto sicuro e accessibile. realmName=Realm doDownload=Download doPrint=Stampa -backupCodesTips-1=Ogni codice di backup pu\u00f2 essere usato una sola volta. -backupCodesTips-2=Questi codici sono stati generati il generateNewBackupCodes=Genera dei nuovi codici di backup -backupCodesTips-3=Quando generi dei nuovi codici di backup, quelli attuali non funzioneranno pi\u00f9. backtoAuthenticatorPage=Torna alla pagina dell''autenticatore diff --git a/themes/src/main/resources-community/theme/base/account/messages/messages_ja.properties b/themes/src/main/resources-community/theme/base/account/messages/messages_ja.properties index d40914ffa6..ec07c92862 100644 --- a/themes/src/main/resources-community/theme/base/account/messages/messages_ja.properties +++ b/themes/src/main/resources-community/theme/base/account/messages/messages_ja.properties @@ -279,9 +279,6 @@ authenticatorSMSMessage=Keycloakは、2要素認証として確認コードを authenticatorSMSFinishSetUpMessage=テキスト・メッセージが次の電話番号宛に送信されます: authenticatorDefaultStatus=デフォルト authenticatorChangePhone=電話番号の変更 -authenticatorBackupCodesTitle=バックアップ・コード -authenticatorBackupCodesMessage=8桁のバックアップ・コードの入手 -authenticatorBackupCodesFinishSetUpMessage=この時点で12個のバックアップ・コードが生成されました。それぞれ一度だけ使用できます。 #Authenticator - Mobile Authenticator setup authenticatorMobileSetupTitle=モバイル・オーセンティケーターのセットアップ @@ -303,14 +300,10 @@ enterYourVerficationCode=確認コードを入力してください #Authenticator - backup Code setup authenticatorBackupCodesSetupTitle=バックアップ・コードのセットアップ -backupcodesIntroMessage=携帯電話にアクセスできない場合でも、バックアップ・コードを使用してアカウントにログインできます。どこか安全でアクセス可能な場所に保管してください。 realmName=レルム doDownload=ダウンロード doPrint=印刷 -backupCodesTips-1=各バックアップ・コードは1回使用できます。 -backupCodesTips-2=これらのコードはこの日に生成されました: generateNewBackupCodes=新しいバックアップ・コードを生成する -backupCodesTips-3=新しいバックアップ・コードを生成すると、現在のコードは機能しなくなります。 backtoAuthenticatorPage=オーセンティケーター・ページに戻る diff --git a/themes/src/main/resources-community/theme/base/account/messages/messages_pt_BR.properties b/themes/src/main/resources-community/theme/base/account/messages/messages_pt_BR.properties index 1ab2b4ef6d..462c817e46 100644 --- a/themes/src/main/resources-community/theme/base/account/messages/messages_pt_BR.properties +++ b/themes/src/main/resources-community/theme/base/account/messages/messages_pt_BR.properties @@ -293,9 +293,6 @@ authenticatorSMSMessage=A aplica\u00e7\u00e3o ir\u00e1 enviar o c\u00f3digo de v authenticatorSMSFinishSetUpMessage=As mensagens de texto ser\u00e3o enviadas para authenticatorDefaultStatus=Padr\u00e3o authenticatorChangePhone=Mudar N\u00famero de Celular -authenticatorBackupCodesTitle=C\u00f3digos de Emerg\u00eancia -authenticatorBackupCodesMessage=Veja seus c\u00f3digos de emerg\u00eancia de 8 d\u00edgitos -authenticatorBackupCodesFinishSetUpMessage=12 c\u00f3digos de emerg\u00eancia foram gerados. Cada um pode ser utilizado apenas uma vez. #Authenticator - Mobile Authenticator setup authenticatorMobileSetupTitle=Configura\u00e7\u00e3o do Autenticador M\u00f3vel @@ -317,14 +314,10 @@ enterYourVerficationCode=Insira o seu c\u00f3digo de verifica\u00e7\u00e3o #Authenticator - backup Code setup authenticatorBackupCodesSetupTitle=Configura\u00e7\u00e3o de C\u00f3digos de Emerg\u00eancia -backupcodesIntroMessage=Se perder o acesso ao seu telefone, voc\u00ea ainda poder\u00e1 acessar a sua conta com c\u00f3digos de emerg\u00eancia. Guarde-os em um lugar seguro e acess\u00edvel. realmName=Dom\u00ednio doDownload=Baixar doPrint=Imprimir -backupCodesTips-1=Cada c\u00f3digo de emerg\u00eancia s\u00f3 pode ser usado uma vez. -backupCodesTips-2=Estes c\u00f3digos foram gerados em generateNewBackupCodes=Gerar Novos C\u00f3digos de Emerg\u00eancia -backupCodesTips-3=Ao gerar novos c\u00f3digos de emerg\u00eancia, quaisquer c\u00f3digos antigos deixar\u00e3o de funcionar. backtoAuthenticatorPage=Voltar \u00e0 P\u00e1gina de Autenticador diff --git a/themes/src/main/resources-community/theme/base/account/messages/messages_tr.properties b/themes/src/main/resources-community/theme/base/account/messages/messages_tr.properties index 3ec4f11a79..5b19938380 100644 --- a/themes/src/main/resources-community/theme/base/account/messages/messages_tr.properties +++ b/themes/src/main/resources-community/theme/base/account/messages/messages_tr.properties @@ -266,9 +266,6 @@ authenticatorSMSMessage=Keycloak, do\u011Frulama kodunu telefonunuza iki fakt\u0 authenticatorSMSFinishSetUpMessage=K\u0131sa mesajlar g\u00F6nderilir authenticatorDefaultStatus=Varsay\u0131lan authenticatorChangePhone=Telefon Numaras\u0131n\u0131 De\u011Fi\u015Ftir -authenticatorBackupCodesTitle=Yedekleme Kodlar\u0131 -authenticatorBackupCodesMessage=8 haneli yedek kodlar\u0131n\u0131z\u0131 al\u0131n -authenticatorBackupCodesFinishSetUpMessage=\u015Eu anda 12 haneli yedek kod olu\u015Fturuldu. Her biri bir kez kullan\u0131labilir. #Authenticator - Mobile Authenticator setup authenticatorMobileSetupTitle=Mobil Kimlik Do\u011Frulama Kurulumu @@ -290,14 +287,10 @@ enterYourVerficationCode=Onaylama kodunu girin #Authenticator - backup Code setup authenticatorBackupCodesSetupTitle=Yedekleme Kodlar\u0131 Kurulumu -backupcodesIntroMessage=Telefonunuza eri\u015Fimi kaybederseniz, yine de yedek kodlar arac\u0131l\u0131\u011F\u0131yla hesab\u0131n\u0131za giri\u015F yapabilirsiniz. Onlar\u0131 g\u00FCvenli ve eri\u015Filebilir bir yerde saklay\u0131n. realmName=Realm doDownload=\u0130ndir doPrint=Yazd\u0131r -backupCodesTips-1=Her yedek kod bir kez kullan\u0131labilir. -backupCodesTips-2=Bu kodlar \u00FCzerinde olu\u015Fturuldu generateNewBackupCodes=Yeni Yedekleme Kodlar\u0131 Olu\u015Ftur -backupCodesTips-3=Yeni yedek kodlar olu\u015Fturdu\u011Funuzda, mevcut kodlar art\u0131k \u00E7al\u0131\u015Fmayacakt\u0131r. backtoAuthenticatorPage=Kimlik Do\u011Frulay\u0131c\u0131 Sayfas\u0131na Geri D\u00F6n #Resources diff --git a/themes/src/main/resources/theme/base/account/messages/messages_en.properties b/themes/src/main/resources/theme/base/account/messages/messages_en.properties index e3ec632c25..55b0724b3b 100755 --- a/themes/src/main/resources/theme/base/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/account/messages/messages_en.properties @@ -314,9 +314,6 @@ authenticatorSMSMessage=Keycloak will send the Verification code to your phone a authenticatorSMSFinishSetUpMessage=Text messages are sent to authenticatorDefaultStatus=Default authenticatorChangePhone=Change Phone Number -authenticatorBackupCodesTitle=Backup Codes -authenticatorBackupCodesMessage=Get your 8-digit backup codes -authenticatorBackupCodesFinishSetUpMessage=12 backup codes were generated at this time. Each one can be used once. #Authenticator - Mobile Authenticator setup authenticatorMobileSetupTitle=Mobile Authenticator Setup @@ -337,15 +334,11 @@ sendVerficationCode=Send Verification Code enterYourVerficationCode=Enter your verification code #Authenticator - backup Code setup -authenticatorBackupCodesSetupTitle=Backup Codes Setup -backupcodesIntroMessage=If you lose access to your phone, you can still log into your account through backup codes. Keep them somewhere safe and accessible. +authenticatorBackupCodesSetupTitle=Recovery Authentication Codes Setup realmName=Realm doDownload=Download doPrint=Print -backupCodesTips-1=Each backup code can be used once. -backupCodesTips-2=These codes were generated on -generateNewBackupCodes=Generate New Backup Codes -backupCodesTips-3=When you generate new backup codes, the current codes will not work anymore. +generateNewBackupCodes=Generate New Recovery Authentication Codes backtoAuthenticatorPage=Back to Authenticator Page diff --git a/themes/src/main/resources/theme/base/email/messages/messages_en.properties b/themes/src/main/resources/theme/base/email/messages/messages_en.properties index 7274cb67d5..132fe2516e 100755 --- a/themes/src/main/resources/theme/base/email/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/email/messages/messages_en.properties @@ -31,6 +31,7 @@ requiredAction.terms_and_conditions=Terms and Conditions requiredAction.UPDATE_PASSWORD=Update Password requiredAction.UPDATE_PROFILE=Update Profile requiredAction.VERIFY_EMAIL=Verify Email +requiredAction.CONFIGURE_RECOVERY_AUTHN_CODES=Generate Recovery Codes # units for link expiration timeout formatting linkExpirationFormatter.timePeriodUnit.seconds=seconds diff --git a/themes/src/main/resources/theme/base/login/login-recovery-authn-code-config.ftl b/themes/src/main/resources/theme/base/login/login-recovery-authn-code-config.ftl new file mode 100644 index 0000000000..9aad93ca08 --- /dev/null +++ b/themes/src/main/resources/theme/base/login/login-recovery-authn-code-config.ftl @@ -0,0 +1,184 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + +<#if section = "header"> + ${msg("recovery-code-config-header")} +<#elseif section = "form"> + +
+
+ +
+

+ Warning alert: + ${msg("recovery-code-config-warning-title")} +

+
+

${msg("recovery-code-config-warning-message")}

+
+
+ +
    + <#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code> +
  1. ${code?counter}: ${code[0..3]}-${code[4..7]}-${code[8..]}
  2. + +
+ + +
+ + + +
+ + +
+ + +
+ +
+ + + + + <#if isAppInitiatedAction??> + + + <#else> + + +
+ + + + diff --git a/themes/src/main/resources/theme/base/login/login-recovery-authn-code-input.ftl b/themes/src/main/resources/theme/base/login/login-recovery-authn-code-input.ftl new file mode 100644 index 0000000000..f6cad6764a --- /dev/null +++ b/themes/src/main/resources/theme/base/login/login-recovery-authn-code-input.ftl @@ -0,0 +1,32 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + + <#if section = "header"> + ${msg("auth-recovery-code-header")} + <#elseif section = "form"> +
+
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+
+ + \ No newline at end of file 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 f2593f9f9b..13350e88c9 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 @@ -261,6 +261,7 @@ confirmLinkIdpReviewProfile=Review profile confirmLinkIdpContinue=Add to existing account configureTotpMessage=You need to set up Mobile Authenticator to activate your account. +configureBackupCodesMessage=You need to set up Backup Codes to activate your account. updateProfileMessage=You need to update your user profile to activate your account. updatePasswordMessage=You need to change your password to activate your account. resetPasswordMessage=You need to change your password. @@ -376,6 +377,7 @@ requiredAction.terms_and_conditions=Terms and Conditions requiredAction.UPDATE_PASSWORD=Update Password requiredAction.UPDATE_PROFILE=Update Profile requiredAction.VERIFY_EMAIL=Verify Email +requiredAction.CONFIGURE_RECOVERY_AUTHN_CODES=Generate Recovery Codes doX509Login=You will be logged in as\: clientCertificate=X509 client certificate\: @@ -418,6 +420,27 @@ auth-username-form-help-text=Start sign in by entering your username auth-username-password-form-display-name=Username and password auth-username-password-form-help-text=Sign in by entering your username and password. +# Recovery Codes +auth-recovery-authn-code-form-display-name=Recovery Authentication Code +auth-recovery-authn-code-form-help-text=Enter a recovery authentication code from a previously generated list. +auth-recovery-code-info-message=Enter the specified recovery code. +auth-recovery-code-prompt=Recovery code #{0} +auth-recovery-code-header=Login with a recovery authentication code +recovery-codes-error-invalid=Invalid recovery authentication code +recovery-code-config-header=Recovery Authentication Codes +recovery-code-config-warning-title=These recovery codes won't appear again after leaving this page +recovery-code-config-warning-message=Make sure to print, download, or copy them to a password manager and keep them save. Canceling this setup will remove these recovery codes from your account. +recovery-codes-print=Print +recovery-codes-download=Download +recovery-codes-copy=Copy +recovery-codes-copied=Copied +recovery-codes-confirmation-message=I have saved these codes somewhere safe +recovery-codes-action-complete=Complete setup +recovery-codes-action-cancel=Cancel setup +recovery-codes-download-file-header=Keep these recovery codes somewhere safe. +recovery-codes-download-file-description=Recovery codes are single-use passcodes that allow you to log in to your account if you do not have access to your authenticator. +recovery-codes-download-file-date= These codes were generated on + # WebAuthn webauthn-display-name=Security Key webauthn-help-text=Use your security key to sign in. diff --git a/themes/src/main/resources/theme/keycloak.v2/account/messages/messages_en.properties b/themes/src/main/resources/theme/keycloak.v2/account/messages/messages_en.properties index e59fe1680f..a2479e510d 100644 --- a/themes/src/main/resources/theme/keycloak.v2/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/keycloak.v2/account/messages/messages_en.properties @@ -89,6 +89,12 @@ password-help-text=Log in by entering your password. password=My Password otp-display-name=Authenticator Application otp-help-text=Enter a verification code from authenticator application. +recovery-authn-code=My Recovery Authentication Codes +recovery-authn-codes-display-name=Recovery Authentication Codes +recovery-authn-codes-help-text=These codes can be used to regain your access in case your other 2FA means are not available. +recovery-codes-number-used={0} recovery codes used +recovery-codes-number-remaining={0} recovery codes remaining +recovery-codes-generate-new-codes=Generate new codes to ensure access to your account webauthn-display-name=Security Key webauthn-help-text=Use your security key to sign in. webauthn-passwordless-display-name=Security Key diff --git a/themes/src/main/resources/theme/keycloak.v2/account/src/app/content/signingin-page/SigningInPage.tsx b/themes/src/main/resources/theme/keycloak.v2/account/src/app/content/signingin-page/SigningInPage.tsx index 9dd5dae639..7f8793fabd 100644 --- a/themes/src/main/resources/theme/keycloak.v2/account/src/app/content/signingin-page/SigningInPage.tsx +++ b/themes/src/main/resources/theme/keycloak.v2/account/src/app/content/signingin-page/SigningInPage.tsx @@ -60,12 +60,20 @@ type CredType = string; type CredTypeMap = Map; type CredContainerMap = Map; +interface CredMetadata { + infoMessage?: string; + warningMessageTitle?: string; + warningMessageDescription?: string; + credential: UserCredential; +} + interface UserCredential { id: string; type: string; userLabel: string; createdDate?: number; strCreatedDate?: string; + credentialData?: string; } // A CredentialContainer is unique by combo of credential type and credential category @@ -77,7 +85,7 @@ interface CredentialContainer { createAction?: string; updateAction?: string; removeable: boolean; - userCredentials: UserCredential[]; + userCredentialMetadatas: CredMetadata[]; open: boolean; } @@ -196,28 +204,30 @@ class SigningInPage extends React.Component , - , - - ]}/> + dataListCells={[ + , + , + + ]} + /> ); } - userCredentials.forEach(credential => { + userCredentialMetadatas.forEach(credentialMetadata => { + let credential = credentialMetadata.credential; if (!credential.userLabel) credential.userLabel = Msg.localize(credential.type); if (credential.hasOwnProperty('createdDate') && credential.createdDate && credential.createdDate! > 0) { credential.strCreatedDate = TimeUtil.format(credential.createdDate as number); @@ -230,16 +240,17 @@ class SigningInPage extends React.Component { - userCredentials.map(credential => ( - - - - - + { + userCredentialMetadatas.map(credentialMetadata => ( + + + + )) @@ -247,9 +258,39 @@ class SigningInPage extends React.Component) } - private credentialRowCells(credential: UserCredential, type: string): React.ReactNode[] { + private credentialRowCells(credMetadata: CredMetadata, type: string): React.ReactNode[] { const credRowCells: React.ReactNode[] = []; - credRowCells.push({credential.userLabel}); + const credential = credMetadata.credential; + const infoMessage = credMetadata.infoMessage ? JSON.parse(credMetadata.infoMessage) : null; + const warningMessageTitle = credMetadata.warningMessageTitle ? JSON.parse(credMetadata.warningMessageTitle) : null; + const warningMessageDescription = credMetadata.warningMessageDescription ? JSON.parse(credMetadata.warningMessageDescription) : null; + credRowCells.push( + + {credential.userLabel} + {infoMessage && +
{Msg.localize(infoMessage.key, infoMessage.parameters)}
+ } + {warningMessageTitle && + <> +
+
+
+ +
+

+ Warning alert: + {Msg.localize(warningMessageTitle.key, warningMessageTitle.parameters)} +

+ {credMetadata.warningMessageDescription && +
+ {Msg.localize(warningMessageDescription.key, warningMessageDescription.parameters)} +
+ } +
+ + } +
+ ); if (credential.strCreatedDate) { credRowCells.push(: {credential.strCreatedDate}); credRowCells.push(); @@ -330,12 +371,15 @@ class SigningInPage extends React.Component void; -interface CredentialActionProps {credential: UserCredential; - removeable: boolean; - updateAction: AIACommand; - credRemover: CredRemover;}; -class CredentialAction extends React.Component { +interface CredentialActionProps { + credential: UserCredential; + removeable: boolean; + updateAction: AIACommand; + credRemover: CredRemover; +}; + +class CredentialAction extends React.Component { render(): React.ReactNode { if (this.props.updateAction) { return ( @@ -350,11 +394,11 @@ class CredentialAction extends React.Component { return ( this.props.credRemover(this.props.credential.id, userLabel)} - /> + buttonId={`${SigningInPage.credElementId(this.props.credential.type, this.props.credential.id, 'remove')}`} + modalTitle={Msg.localize('removeCred', [userLabel])} + modalMessage={Msg.localize('stopUsingCred', [userLabel])} + onContinue={() => this.props.credRemover(this.props.credential.id, userLabel)} + /> ) } @@ -364,4 +408,4 @@ class CredentialAction extends React.Component { } const SigningInPageWithRouter = withRouter(SigningInPage); -export { SigningInPageWithRouter as SigningInPage}; \ No newline at end of file +export { SigningInPageWithRouter as SigningInPage}; 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 4cee5b9e26..85c241b600 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 @@ -725,3 +725,45 @@ ul#kc-totp-supported-apps { #kc-back { margin-top: 5px; } + +/* Recovery codes */ +.kc-recovery-codes-warning { + margin-bottom: 32px; +} +.kc-recovery-codes-warning .pf-c-alert__description p { + font-size: 0.875rem; +} +.kc-recovery-codes-list { + list-style: none; + columns: 2; + margin: 16px 0; + padding: 16px 16px 8px 16px; + border: 1px solid #D2D2D2; +} +.kc-recovery-codes-list li { + margin-bottom: 8px; + font-size: 11px; +} +.kc-recovery-codes-list li span { + color: #6A6E73; + width: 16px; + text-align: right; + display: inline-block; + margin-right: 1px; +} + +.kc-recovery-codes-actions { + margin-bottom: 24px; +} +.kc-recovery-codes-actions button { + padding-left: 0; +} +.kc-recovery-codes-actions button i { + margin-right: 8px; +} + +.kc-recovery-codes-confirmation { + align-items: baseline; + margin-bottom: 16px; +} +/* End Recovery codes */ diff --git a/themes/src/main/resources/theme/keycloak/login/theme.properties b/themes/src/main/resources/theme/keycloak/login/theme.properties index 21147989b0..c12357904f 100644 --- a/themes/src/main/resources/theme/keycloak/login/theme.properties +++ b/themes/src/main/resources/theme/keycloak/login/theme.properties @@ -118,7 +118,7 @@ kcSelectAuthListItemArrowIconClass=fa fa-angle-right fa-lg kcSelectAuthListItemTitle=select-auth-box-paragraph ##### css classes for the authenticators -kcAuthenticatorDefaultClass=fa list-view-pf-icon-lg +kcAuthenticatorDefaultClass=fa fa-list 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 @@ -149,4 +149,13 @@ kcLogoIdP-paypal=fa fa-paypal kcLogoIdP-stackoverflow=fa fa-stack-overflow kcLogoIdP-twitter=fa fa-twitter kcLogoIdP-openshift-v4=pf-icon pf-icon-openshift -kcLogoIdP-openshift-v3=pf-icon pf-icon-openshift \ No newline at end of file +kcLogoIdP-openshift-v3=pf-icon pf-icon-openshift + +## Recovery codes +kcRecoveryCodesWarning=kc-recovery-codes-warning +kcRecoveryCodesList=kc-recovery-codes-list +kcRecoveryCodesActions=kc-recovery-codes-actions +kcRecoveryCodesConfirmation=kc-recovery-codes-confirmation +kcCheckClass=pf-c-check +kcCheckInputClass=pf-c-check__input +kcCheckLabelClass=pf-c-check__label