Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
*/
package org.keycloak.representations.account;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

/**
*
* @author rmartinc
Expand All @@ -25,7 +28,8 @@ public class LocalizedMessage {
private final String key;
private final String[] parameters;

public LocalizedMessage(String key, String... parameters) {
@JsonCreator
public LocalizedMessage(@JsonProperty("key") String key, @JsonProperty("parameters") String... parameters) {
this.key = key;
this.parameters = parameters == null || parameters.length == 0? null : parameters;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@

/**
* @author <a href="mailto:[email protected]">Stian Thorgersen</a>
*
* @deprecated Please use rather the recovery codes required action to configure warning threshold for recovery codes. This password policy may be removed in the future versions.
*/
@Deprecated
public class RecoveryCodesWarningThresholdPasswordPolicyProviderFactory implements PasswordPolicyProviderFactory, PasswordPolicyProvider, EnvironmentDependentProviderFactory {

private KeycloakSession session;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package org.keycloak.models;

import org.jboss.logging.Logger;
import org.keycloak.policy.PasswordPolicyConfigException;
import org.keycloak.policy.PasswordPolicyProvider;

Expand All @@ -31,6 +32,8 @@
*/
public class PasswordPolicy implements Serializable {

protected static final Logger logger = Logger.getLogger(PasswordPolicy.class);

public static final String HASH_ALGORITHM_ID = "hashAlgorithm";

public static final String HASH_ITERATIONS_ID = "hashIterations";
Expand All @@ -39,8 +42,10 @@ public class PasswordPolicy implements Serializable {

public static final String FORCE_EXPIRED_ID = "forceExpiredPasswordChange";

@Deprecated
public static final int RECOVERY_CODES_WARNING_THRESHOLD_DEFAULT = 4;

@Deprecated
public static final String RECOVERY_CODES_WARNING_THRESHOLD_ID = "recoveryCodesWarningThreshold";

public static final String MAX_AUTH_AGE_ID = "maxAuthAge";
Expand Down Expand Up @@ -115,8 +120,10 @@ public int getDaysToExpirePassword() {
}
}

@Deprecated
public int getRecoveryCodesWarningThreshold() {
if (policyConfig.containsKey(RECOVERY_CODES_WARNING_THRESHOLD_ID)) {
logger.warnf("It is deprecated to use Warning Threshold password policy. Please use the configuration on Recovery Authentication Codes required action instead.");
return getPolicyConfig(RECOVERY_CODES_WARNING_THRESHOLD_ID);
} else {
return 4;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class RecoveryAuthnCodesUtils {

private static final Logger logger = Logger.getLogger(RecoveryAuthnCodesUtils.class);

private static final int QUANTITY_OF_CODES_TO_GENERATE = 12;
public 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@
import org.keycloak.events.EventType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionConfigModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel;
import org.keycloak.provider.EnvironmentDependentProviderFactory;

import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.userprofile.ValidationException;
import org.keycloak.validate.ValidationError;

import static org.keycloak.utils.CredentialHelper.createRecoveryCodesCredential;

Expand All @@ -35,6 +41,26 @@ public class RecoveryAuthnCodesAction implements RequiredActionProvider, Require
public static final String PROVIDER_ID = UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name();
private static final RecoveryAuthnCodesAction INSTANCE = new RecoveryAuthnCodesAction();

private static final List<ProviderConfigProperty> CONFIG_PROPERTIES;

public static final String WARNING_THRESHOLD = "warning_threshold";

public static final int RECOVERY_CODES_WARNING_THRESHOLD_DEFAULT = 4;

static {
List<ProviderConfigProperty> properties = ProviderConfigurationBuilder.create() //
.property() //
.name(WARNING_THRESHOLD) //
.label("Warning Threshold") //
.helpText("When user has smaller amount of remaining recovery codes on his account than the value configured here, account console will show warning to the user, which will recommend him to setup new set of recovery codes.")
.type(ProviderConfigProperty.INTEGER_TYPE) //
.defaultValue(RECOVERY_CODES_WARNING_THRESHOLD_DEFAULT) //
.add() //
.build();

CONFIG_PROPERTIES = properties;
}

@Override
public String getId() {
return PROVIDER_ID;
Expand Down Expand Up @@ -131,4 +157,28 @@ public void close() {
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.RECOVERY_CODES);
}

@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return List.copyOf(CONFIG_PROPERTIES);
}

@Override
public void validateConfig(KeycloakSession session, RealmModel realm, RequiredActionConfigModel model) {

int parsedMaxAuthAge;
try {
parsedMaxAuthAge = parseWarningThreshold(model);
} catch (Exception ex) {
throw new ValidationException(new ValidationError(getId(), WARNING_THRESHOLD, "error-invalid-value"));
}

if (parsedMaxAuthAge < 0) {
throw new ValidationException(new ValidationError(getId(), WARNING_THRESHOLD, "error-number-out-of-range-too-small", 0));
}
}

private int parseWarningThreshold(RequiredActionConfigModel model) throws NumberFormatException {
return Integer.parseInt(model.getConfigValue(WARNING_THRESHOLD));
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package org.keycloak.credential;

import org.jboss.logging.Logger;
import org.keycloak.authentication.requiredactions.RecoveryAuthnCodesAction;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionProviderModel;
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 org.keycloak.utils.RequiredActionHelper;

import java.io.IOException;
import java.util.Objects;
Expand Down Expand Up @@ -119,6 +122,12 @@ public boolean isValid(RealmModel realm, UserModel user, CredentialInput credent
}

protected int getWarningThreshold() {
return session.getContext().getRealm().getPasswordPolicy().getRecoveryCodesWarningThreshold();
RealmModel realm = session.getContext().getRealm();
RequiredActionProviderModel requiredAction = RequiredActionHelper.getRequiredActionByProviderId(realm, RecoveryAuthnCodesAction.PROVIDER_ID);
if (requiredAction != null && requiredAction.getConfig().containsKey(RecoveryAuthnCodesAction.WARNING_THRESHOLD)) {
return Integer.parseInt(requiredAction.getConfig().get(RecoveryAuthnCodesAction.WARNING_THRESHOLD));
} else {
return session.getContext().getRealm().getPasswordPolicy().getRecoveryCodesWarningThreshold();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -793,4 +793,8 @@ protected static String generatePassword() {
protected static String generatePassword(int length) {
return SecretGenerator.getInstance().randomString(length);
}

protected String getAccountRootUrl() {
return suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ public void configureTestRealm(RealmRepresentation testRealm) {
}

protected String getAccountUrl(String resource) {
String url = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/test/account";
String url = getAccountRootUrl();
if (apiVersion != null) {
url += "/" + apiVersion;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.keycloak.testsuite.forms;

import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.jboss.arquillian.drone.api.annotation.Drone;
Expand All @@ -26,15 +29,20 @@
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel;
import org.keycloak.models.utils.RecoveryAuthnCodesUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.account.CredentialMetadataRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.UserSessionRepresentation;
import org.keycloak.services.resources.account.AccountCredentialResource;
import org.keycloak.testsuite.AbstractChangeImportedUserPasswordsTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.client.KeycloakTestingClient;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.EnterRecoveryAuthnCodePage;
Expand All @@ -44,18 +52,21 @@
import org.keycloak.testsuite.pages.SelectAuthenticatorPage;
import org.keycloak.testsuite.pages.SetupRecoveryAuthnCodesPage;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.testsuite.util.SecondBrowser;
import org.openqa.selenium.WebDriver;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.keycloak.authentication.requiredactions.RecoveryAuthnCodesAction.WARNING_THRESHOLD;
import static org.keycloak.common.Profile.Feature.RECOVERY_CODES;

/**
Expand Down Expand Up @@ -415,7 +426,6 @@ public void test07SetupRecoveryAuthnCodes() {
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);
}
Expand Down Expand Up @@ -458,4 +468,71 @@ public void test08BruteforceProtectionRecoveryAuthnCodes() {
BrowserFlowTest.revertFlows(testRealm(), BROWSER_FLOW_WITH_RECOVERY_AUTHN_CODES);
}
}

@Test
public void test09recoveryAuthnCodesWithThresholdConfigured() throws Exception {
AuthenticationManagementResource authMgt = testRealm().flows();
RequiredActionProviderRepresentation requiredAction = authMgt.getRequiredActions().stream()
.filter(action -> UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name().equals(action.getAlias()))
.findAny().get();
Map<String, String> origReqActionConfig = new HashMap<>(requiredAction.getConfig());

try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
configureBrowserFlowWithRecoveryAuthnCodes(testingClient, 0);

// Configure required action with big threshold
requiredAction.getConfig().put(WARNING_THRESHOLD, String.valueOf(RecoveryAuthnCodesUtils.QUANTITY_OF_CODES_TO_GENERATE));
authMgt.updateRequiredAction(requiredAction.getAlias(), requiredAction);

// Add required action to the user
UserResource testUser = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost");
UserRepresentation userRepresentation = testUser.toRepresentation();
userRepresentation.setRequiredActions(Arrays.asList(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name()));
testUser.update(userRepresentation);

// Login and setup recovery-codes
oauth.openLoginForm();
loginUsername(loginUsernameOnlyPage, driver);
passwordPage.login(getPassword("test-user@localhost"));
setupRecoveryAuthnCodesPage.assertCurrent();
List<String> recoveryCodes = setupRecoveryAuthnCodesPage.getRecoveryAuthnCodes();
setupRecoveryAuthnCodesPage.clickSaveRecoveryAuthnCodesButton();

String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response = oauth.doAccessTokenRequest(code);

// Check account REST API that warning threshold not there on recovery-codes credential as user has full count of recovery codes
CredentialMetadataRepresentation recoveryCodesMetadata = getRecoveryCodeCredentialFromAccountRestApi(httpClient, response.getAccessToken());
Assert.assertNull("Expected not warning", recoveryCodesMetadata.getWarningMessageTitle());
Assert.assertEquals("0/12", recoveryCodesMetadata.getInfoMessage().getParameters()[0]);

// Re-authenticate with recovery codes
oauth.loginForm().prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN).open();
tryAnotherWay(passwordPage, driver);
selectRecoveryAuthnCodes(selectAuthenticatorPage, driver);
enterRecoveryCodes(enterRecoveryAuthnCodePage, driver, 0, recoveryCodes);
enterRecoveryAuthnCodePage.clickSignInButton();

// Check warning is there as only 11 recovery codes remaining
recoveryCodesMetadata = getRecoveryCodeCredentialFromAccountRestApi(httpClient, response.getAccessToken());
Assert.assertEquals("recovery-codes-number-remaining", recoveryCodesMetadata.getWarningMessageTitle().getKey());
Assert.assertEquals("1/12", recoveryCodesMetadata.getInfoMessage().getParameters()[0]);
} finally {
// Revert
requiredAction.setConfig(origReqActionConfig);
authMgt.updateRequiredAction(requiredAction.getAlias(), requiredAction);

BrowserFlowTest.revertFlows(testRealm(), BROWSER_FLOW_WITH_RECOVERY_AUTHN_CODES);
}
}

private CredentialMetadataRepresentation getRecoveryCodeCredentialFromAccountRestApi(CloseableHttpClient httpClient, String accessToken) throws Exception {
List<AccountCredentialResource.CredentialContainer> credentials = SimpleHttpDefault.doGet(getAccountRootUrl() + "/credentials", httpClient)
.auth(accessToken).asJson(new TypeReference<>() {});
AccountCredentialResource.CredentialContainer recoveryCode = credentials.stream()
.filter(credential -> RecoveryAuthnCodesCredentialModel.TYPE.equals(credential.getType()))
.findFirst().get();
return recoveryCode.getUserCredentialMetadatas().get(0);
}

}
Loading