Support for the Recovery codes (#8730)

Closes #9540


Co-authored-by: Zachary Witter <torquekma@gmail.com>
Co-authored-by: stelewis-redhat <91681638+stelewis-redhat@users.noreply.github.com>
This commit is contained in:
Ivan Atanasov 2022-03-10 09:49:25 -05:00 committed by GitHub
parent 8a0f1ccb34
commit 5c6b123aff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 1934 additions and 135 deletions

View file

@ -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;

View file

@ -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());

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -68,6 +68,8 @@ public interface LoginFormsProvider extends Provider {
Response createLoginTotp();
Response createLoginRecoveryAuthnCode();
Response createLoginWebAuthn();
Response createRegistration();

View file

@ -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);

View file

@ -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());

View file

@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
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);
}
}

View file

@ -29,3 +29,4 @@ org.keycloak.policy.SpecialCharsPasswordPolicyProviderFactory
org.keycloak.policy.UpperCasePasswordPolicyProviderFactory
org.keycloak.policy.BlacklistPasswordPolicyProviderFactory
org.keycloak.policy.NotEmailPasswordPolicyProviderFactory
org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory

View file

@ -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;
}
}
}

View file

@ -21,6 +21,8 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider;
import java.io.IOException;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
@ -47,4 +49,10 @@ public interface CredentialProvider<T extends CredentialModel> extends Provider
}
CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext);
default CredentialMetadata getCredentialMetadata(T credentialModel, CredentialTypeMetadata credentialTypeMetadata) {
CredentialMetadata credentialMetadata = new CredentialMetadata();
credentialMetadata.setCredentialModel(credentialModel);
return credentialMetadata;
}
}

View file

@ -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<String, Object> 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();

View file

@ -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;

View file

@ -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
}

View file

@ -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());

View file

@ -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<RecoveryAuthnCodeRepresentation> 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<String> originalGeneratedCodes, long generatedAt,
String userLabel) {
RecoveryAuthnCodesSecretData secretData;
RecoveryAuthnCodesCredentialData credentialData;
RecoveryAuthnCodesCredentialModel model;
try {
List<RecoveryAuthnCodeRepresentation> 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);
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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<RecoveryAuthnCodeRepresentation> codes;
@JsonCreator
public RecoveryAuthnCodesSecretData(@JsonProperty("codes") List<RecoveryAuthnCodeRepresentation> codes) {
this.codes = codes;
}
public List<RecoveryAuthnCodeRepresentation> getCodes() {
return this.codes;
}
public void removeNextBackupCode() {
this.codes.remove(0);
}
}

View file

@ -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<String> generateRawCodes() {
Supplier<String> code = () -> SECRET_GENERATOR.randomString(CODE_LENGTH,UPPERNUM);
return Stream.generate(code).limit(QUANTITY_OF_CODES_TO_GENERATE).collect(Collectors.toList());
}
}

View file

@ -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);

View file

@ -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<String, String> 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<CredentialModel> 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() {
}
}

View file

@ -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<ProviderConfigProperty> 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);
}
}

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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<String, String> 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<String> 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<String> 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);
}
}

View file

@ -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<RecoveryAuthnCodesCredentialModel>, 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<CredentialModel> credential = session.userCredentialManager()
.getStoredCredentialsByTypeStream(realm, user, getType()).findFirst();
if (credential.isPresent()) {
RecoveryAuthnCodesCredentialModel credentialModel = RecoveryAuthnCodesCredentialModel
.createFromCredentialModel(credential.get());
if (!credentialModel.allCodesUsed()) {
Optional<RecoveryAuthnCodeRepresentation> 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();
}
}

View file

@ -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<RecoveryAuthnCodesCredentialProvider>, 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);
}
}

View file

@ -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);

View file

@ -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:

View file

@ -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;
}
}

View file

@ -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<String> generatedRecoveryAuthnCodesList;
private final long generatedAt;
public RecoveryAuthnCodesBean() {
this.generatedRecoveryAuthnCodesList = RecoveryAuthnCodesUtils.generateRawCodes();
this.generatedAt = Time.currentTimeMillis();
}
public List<String> getGeneratedRecoveryAuthnCodesList() {
return this.generatedRecoveryAuthnCodesList;
}
public String getGeneratedRecoveryAuthnCodesAsString() {
return String.join(",", this.generatedRecoveryAuthnCodesList);
}
public long getGeneratedAt() {
return generatedAt;
}
}

View file

@ -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";

View file

@ -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<CredentialRepresentation> userCredentials;
private List<CredentialMetadataRepresentation> userCredentialMetadatas;
private CredentialTypeMetadata metadata;
public CredentialContainer() {
}
public CredentialContainer(CredentialTypeMetadata metadata, List<CredentialRepresentation> userCredentials) {
public CredentialContainer(CredentialTypeMetadata metadata, List<CredentialMetadataRepresentation> 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<CredentialRepresentation> getUserCredentials() {
return userCredentials;
public List<CredentialMetadataRepresentation> getUserCredentialMetadatas() {
return userCredentialMetadatas;
}
@JsonIgnore
@ -171,8 +173,7 @@ public class AccountCredentialResource {
Set<String> enabledCredentialTypes = getEnabledCredentialTypes(credentialProviders);
Stream<CredentialModel> modelsStream = includeUserCredentials ? session.userCredentialManager().getStoredCredentialsStream(realm, user) : Stream.empty();
// Don't return secrets from REST endpoint
List<CredentialModel> models = modelsStream.peek(model -> model.setSecretData(null)).collect(Collectors.toList());
List<CredentialModel> models = modelsStream.collect(Collectors.toList());
Function<CredentialProvider, CredentialContainer> toCredentialContainer = (credentialProvider) -> {
CredentialTypeMetadataContext ctx = CredentialTypeMetadataContext.builder()
@ -180,29 +181,43 @@ public class AccountCredentialResource {
.build(session);
CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata(ctx);
List<CredentialRepresentation> userCredentialModels = null;
List<CredentialMetadataRepresentation> userCredentialMetadataModels = null;
if (includeUserCredentials) {
userCredentialModels = models.stream()
List<CredentialModel> modelsOfType = models.stream()
.filter(credentialModel -> credentialProvider.getType().equals(credentialModel.getType()))
.map(ModelToRepresentation::toRepresentation)
.collect(Collectors.toList());
if (userCredentialModels.isEmpty() &&
List<CredentialMetadata> 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()

View file

@ -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
org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory

View file

@ -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
org.keycloak.authentication.requiredactions.VerifyUserProfile
org.keycloak.authentication.requiredactions.RecoveryAuthnCodesAction

View file

@ -1,3 +1,4 @@
org.keycloak.credential.RecoveryAuthnCodesCredentialProviderFactory
org.keycloak.credential.OTPCredentialProviderFactory
org.keycloak.credential.PasswordCredentialProviderFactory
org.keycloak.credential.WebAuthnCredentialProviderFactory

View file

@ -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 <a href="mailto:vnukala@redhat.com">Venkata Nukala</a>
*/
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();
}
}

View file

@ -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();
}

View file

@ -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<String> getAvailableLoginMethods() {
List<WebElement> 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();
}
}

View file

@ -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<String> getRecoveryAuthnCodes() {
String recoveryAuthnCodesText = recoveryAuthnCodesList.getText();
List<String> 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();
}
}

View file

@ -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 {

View file

@ -85,7 +85,9 @@ public class RequiredActionsTest extends AbstractAuthenticationTest {
// Dummy RequiredAction is not registered in the realm and WebAuthn actions
List<RequiredActionProviderSimpleRepresentation> 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());

View file

@ -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());

View file

@ -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 <a href="mailto:vnukala@redhat.com">Venkata Nukala</a>
*/
@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<String> 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<String> 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);
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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=オーセンティケーター・ページに戻る

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,184 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "header">
${msg("recovery-code-config-header")}
<#elseif section = "form">
<!-- warning -->
<div class="pf-c-alert pf-m-warning pf-m-inline ${properties.kcRecoveryCodesWarning}" aria-label="Warning alert">
<div class="pf-c-alert__icon">
<i class="pficon-warning-triangle-o" aria-hidden="true"></i>
</div>
<h4 class="pf-c-alert__title">
<span class="pf-screen-reader">Warning alert:</span>
${msg("recovery-code-config-warning-title")}
</h4>
<div class="pf-c-alert__description">
<p>${msg("recovery-code-config-warning-message")}</p>
</div>
</div>
<ol id="kc-recovery-codes-list" class="${properties.kcRecoveryCodesList!}">
<#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code>
<li><span>${code?counter}:</span> ${code[0..3]}-${code[4..7]}-${code[8..]}</li>
</#list>
</ol>
<!-- actions -->
<div class="${properties.kcRecoveryCodesActions}">
<button id="printRecoveryCodes" class="pf-c-button pf-m-link" type="button">
<i class="pficon-print"></i> ${msg("recovery-codes-print")}
</button>
<button id="downloadRecoveryCodes" class="pf-c-button pf-m-link" type="button">
<i class="pficon-save"></i> ${msg("recovery-codes-download")}
</button>
<button id="copyRecoveryCodes" class="pf-c-button pf-m-link" type="button">
<i class="pficon-blueprint"></i> ${msg("recovery-codes-copy")}
</button>
</div>
<!-- confirmation checkbox -->
<div class="${properties.kcCheckClass} ${properties.kcRecoveryCodesConfirmation}">
<input class="${properties.kcCheckInputClass}" type="checkbox" id="kcRecoveryCodesConfirmationCheck" name="kcRecoveryCodesConfirmationCheck"
onchange="document.getElementById('saveRecoveryAuthnCodesBtn').disabled = !this.checked;"
/>
<label class="${properties.kcCheckLabelClass}" for="kcRecoveryCodesConfirmationCheck">${msg("recovery-codes-confirmation-message")}</label>
</div>
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-recovery-codes-settings-form" method="post">
<input type="hidden" name="generatedRecoveryAuthnCodes" value="${recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString}" />
<input type="hidden" name="generatedAt" value="${recoveryAuthnCodesConfigBean.generatedAt?c}" />
<input type="hidden" name="userLabel" value=" " />
<#if isAppInitiatedAction??>
<input type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}"
id="saveRecoveryAuthnCodesBtn" value="${msg("recovery-codes-action-complete")}"
disabled
/>
<button type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!} ${properties.kcButtonLargeClass!}"
id="cancelRecoveryAuthnCodesBtn" name="cancel-aia" value="true" />${msg("recovery-codes-action-cancel")}
</button>
<#else>
<input type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
id="saveRecoveryAuthnCodesBtn" value="${msg("recovery-codes-action-complete")}"
disabled
/>
</#if>
</form>
<script>
/* copy recovery codes */
function copyRecoveryCodes() {
var tmpTextarea = document.createElement("textarea");
var codes = document.getElementById("kc-recovery-codes-list").getElementsByTagName("li");
for (i = 0; i < codes.length; i++) {
tmpTextarea.value = tmpTextarea.value + codes[i].innerText + "\n";
}
document.body.appendChild(tmpTextarea);
tmpTextarea.select();
document.execCommand("copy");
document.body.removeChild(tmpTextarea);
}
var copyButton = document.getElementById("copyRecoveryCodes");
copyButton && copyButton.addEventListener("click", function () {
copyRecoveryCodes();
});
/* download recovery codes */
function formatCurrentDateTime() {
var dt = new Date();
var options = {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
};
return dt.toLocaleString('en-US', options);
}
function parseRecoveryCodeList() {
var recoveryCodes = document.querySelectorAll(".kc-recovery-codes-list li");
var recoveryCodeList = "";
for (var i = 0; i < recoveryCodes.length; i++) {
var recoveryCodeLiElement = recoveryCodes[i].innerText;
recoveryCodeList += recoveryCodeLiElement + "\r\n";
}
return recoveryCodeList;
}
function buildDownloadContent() {
var recoveryCodeList = parseRecoveryCodeList();
var dt = new Date();
var options = {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short'
};
return fileBodyContent =
"${msg("recovery-codes-download-file-header")}\n\n" +
recoveryCodeList + "\n" +
"${msg("recovery-codes-download-file-description")}\n\n" +
"${msg("recovery-codes-download-file-date")} " + formatCurrentDateTime();
}
function setUpDownloadLinkAndDownload(filename, text) {
var el = document.createElement('a');
el.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
el.setAttribute('download', filename);
el.style.display = 'none';
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
}
function downloadRecoveryCodes() {
setUpDownloadLinkAndDownload('kc-download-recovery-codes.txt', buildDownloadContent());
}
var downloadButton = document.getElementById("downloadRecoveryCodes");
downloadButton && downloadButton.addEventListener("click", downloadRecoveryCodes);
/* print recovery codes */
function buildPrintContent() {
var recoveryCodeListHTML = document.getElementById('kc-recovery-codes-list').innerHTML;
var styles =
`@page { size: auto; margin-top: 0; }
body { width: 480px; }
div { list-style-type: none; font-family: monospace }
p:first-of-type { margin-top: 48px }`
return printFileContent =
"<html><style>" + styles + "</style><body>" +
"<title>kc-download-recovery-codes</title>" +
"<p>${msg("recovery-codes-download-file-header")}</p>" +
"<div>" + recoveryCodeListHTML + "</div>" +
"<p>${msg("recovery-codes-download-file-description")}</p>" +
"<p>${msg("recovery-codes-download-file-date")} " + formatCurrentDateTime() + "</p>" +
"</body></html>";
}
function printRecoveryCodes() {
var w = window.open();
w.document.write(buildPrintContent());
w.print();
w.close();
}
var printButton = document.getElementById("printRecoveryCodes");
printButton && printButton.addEventListener("click", printRecoveryCodes);
</script>
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,32 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "header">
${msg("auth-recovery-code-header")}
<#elseif section = "form">
<form id="kc-recovery-code-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="recoveryCodeInput" class="${properties.kcLabelClass!}">${msg("auth-recovery-code-prompt", recoveryAuthnCodesInputBean.codeNumber?c)}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input id="recoveryCodeInput" name="recoveryCodeInput" autocomplete="off" type="text" class="${properties.kcInputClass!}" autofocus/>
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<input
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login" id="kc-login" type="submit" value="${msg("doLogIn")}" />
</div>
</div>
</form>
</#if>
</@layout.registrationLayout>

View file

@ -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.

View file

@ -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

View file

@ -60,12 +60,20 @@ type CredType = string;
type CredTypeMap = Map<CredType, CredentialContainer>;
type CredContainerMap = Map<CredCategory, CredTypeMap>;
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<SigningInPageProps, SigningInPageSta
private renderUserCredentials(credTypeMap: CredTypeMap, credType: CredType, keycloak: KeycloakService): React.ReactNode {
const credContainer: CredentialContainer = credTypeMap.get(credType)!;
const userCredentials: UserCredential[] = credContainer.userCredentials;
const userCredentialMetadatas: CredMetadata[] = credContainer.userCredentialMetadatas;
const removeable: boolean = credContainer.removeable;
const type: string = credContainer.type;
const displayName: string = credContainer.displayName;
if (!userCredentials || userCredentials.length === 0) {
if (!userCredentialMetadatas || userCredentialMetadatas.length === 0) {
const localizedDisplayName = Msg.localize(displayName);
return (
<DataListItem key='no-credentials-list-item' aria-labelledby='no-credentials-list-item'>
<DataListItemRow key='no-credentials-list-item-row'>
<DataListItemCells
dataListCells={[
<DataListCell key={'no-credentials-cell-0'}/>,
<strong id={`${type}-not-set-up`} key={'no-credentials-cell-1'}><Msg msgKey='notSetUp' params={[localizedDisplayName]}/></strong>,
<DataListCell key={'no-credentials-cell-2'}/>
]}/>
dataListCells={[
<DataListCell key={'no-credentials-cell-0'}/>,
<strong id={`${type}-not-set-up`} key={'no-credentials-cell-1'}><Msg msgKey='notSetUp' params={[localizedDisplayName]}/></strong>,
<DataListCell key={'no-credentials-cell-2'}/>
]}
/>
</DataListItemRow>
</DataListItem>
);
}
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<SigningInPageProps, SigningInPageSta
}
return (
<React.Fragment key='userCredentials'> {
userCredentials.map(credential => (
<DataListItem id={`${SigningInPage.credElementId(type, credential.id, 'row')}`} key={'credential-list-item-' + credential.id} aria-labelledby={'credential-list-item-' + credential.userLabel}>
<DataListItemRow key={'userCredentialRow-' + credential.id}>
<DataListItemCells dataListCells={this.credentialRowCells(credential, type)}/>
<CredentialAction credential={credential}
removeable={removeable}
updateAction={updateAIA}
credRemover={this.handleRemove}/>
<React.Fragment key='userCredentialMetadatas'> {
userCredentialMetadatas.map(credentialMetadata => (
<DataListItem id={`${SigningInPage.credElementId(type, credentialMetadata.credential.id, 'row')}`} key={'credential-list-item-' + credentialMetadata.credential.id} aria-labelledby={'credential-list-item-' + credentialMetadata.credential.userLabel}>
<DataListItemRow key={'userCredentialRow-' + credentialMetadata.credential.id}>
<DataListItemCells dataListCells={this.credentialRowCells(credentialMetadata, type)}/>
<CredentialAction
credential={credentialMetadata.credential}
removeable={removeable}
updateAction={updateAIA}
credRemover={this.handleRemove}
/>
</DataListItemRow>
</DataListItem>
))
@ -247,9 +258,39 @@ class SigningInPage extends React.Component<SigningInPageProps, SigningInPageSta
</React.Fragment>)
}
private credentialRowCells(credential: UserCredential, type: string): React.ReactNode[] {
private credentialRowCells(credMetadata: CredMetadata, type: string): React.ReactNode[] {
const credRowCells: React.ReactNode[] = [];
credRowCells.push(<DataListCell id={`${SigningInPage.credElementId(type, credential.id, 'label')}`} key={'userLabel-' + credential.id}>{credential.userLabel}</DataListCell>);
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(
<DataListCell id={`${SigningInPage.credElementId(type, credential.id, 'label')}`} key={'userLabel-' + credential.id}>
{credential.userLabel}
{infoMessage &&
<div>{Msg.localize(infoMessage.key, infoMessage.parameters)}</div>
}
{warningMessageTitle &&
<>
<br />
<div className="pf-c-alert pf-m-warning pf-m-inline" aria-label="Success alert">
<div className="pf-c-alert__icon">
<i className="pficon-warning-triangle-o" aria-hidden="true"></i>
</div>
<h4 className="pf-c-alert__title">
<span className="pf-screen-reader">Warning alert:</span>
{Msg.localize(warningMessageTitle.key, warningMessageTitle.parameters)}
</h4>
{credMetadata.warningMessageDescription &&
<div className="pf-c-alert__description">
{Msg.localize(warningMessageDescription.key, warningMessageDescription.parameters)}
</div>
}
</div>
</>
}
</DataListCell>
);
if (credential.strCreatedDate) {
credRowCells.push(<DataListCell id={`${SigningInPage.credElementId(type, credential.id, 'created-at')}`} key={'created-' + credential.id}><strong><Msg msgKey='credentialCreatedAt'/>: </strong>{credential.strCreatedDate}</DataListCell>);
credRowCells.push(<DataListCell key={'spacer-' + credential.id}/>);
@ -330,12 +371,15 @@ class SigningInPage extends React.Component<SigningInPageProps, SigningInPageSta
};
type CredRemover = (credentialId: string, userLabel: string) => void;
interface CredentialActionProps {credential: UserCredential;
removeable: boolean;
updateAction: AIACommand;
credRemover: CredRemover;};
class CredentialAction extends React.Component<CredentialActionProps> {
interface CredentialActionProps {
credential: UserCredential;
removeable: boolean;
updateAction: AIACommand;
credRemover: CredRemover;
};
class CredentialAction extends React.Component<CredentialActionProps> {
render(): React.ReactNode {
if (this.props.updateAction) {
return (
@ -350,11 +394,11 @@ class CredentialAction extends React.Component<CredentialActionProps> {
return (
<DataListAction aria-labelledby='foo' aria-label='foo action' id={'removeAction-' + this.props.credential.id }>
<ContinueCancelModal buttonTitle='remove'
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)}
/>
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)}
/>
</DataListAction>
)
}
@ -364,4 +408,4 @@ class CredentialAction extends React.Component<CredentialActionProps> {
}
const SigningInPageWithRouter = withRouter(SigningInPage);
export { SigningInPageWithRouter as SigningInPage};
export { SigningInPageWithRouter as SigningInPage};

View file

@ -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 */

View file

@ -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
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