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:
parent
8a0f1ccb34
commit
5c6b123aff
63 changed files with 1934 additions and 135 deletions
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
|
@ -68,6 +68,8 @@ public interface LoginFormsProvider extends Provider {
|
|||
|
||||
Response createLoginTotp();
|
||||
|
||||
Response createLoginRecoveryAuthnCode();
|
||||
|
||||
Response createLoginWebAuthn();
|
||||
|
||||
Response createRegistration();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -29,3 +29,4 @@ org.keycloak.policy.SpecialCharsPasswordPolicyProviderFactory
|
|||
org.keycloak.policy.UpperCasePasswordPolicyProviderFactory
|
||||
org.keycloak.policy.BlacklistPasswordPolicyProviderFactory
|
||||
org.keycloak.policy.NotEmailPasswordPolicyProviderFactory
|
||||
org.keycloak.policy.RecoveryCodesWarningThresholdPasswordPolicyProviderFactory
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -1,3 +1,4 @@
|
|||
org.keycloak.credential.RecoveryAuthnCodesCredentialProviderFactory
|
||||
org.keycloak.credential.OTPCredentialProviderFactory
|
||||
org.keycloak.credential.PasswordCredentialProviderFactory
|
||||
org.keycloak.credential.WebAuthnCredentialProviderFactory
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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=オーセンティケーター・ページに戻る
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue