Secondary factor bypass in step-up authentication

closes #34

Signed-off-by: mposolda <mposolda@gmail.com>
(cherry picked from commit e632c03ec4dbfbb7c74c65b0627027390b2e605d)
This commit is contained in:
mposolda 2024-03-18 15:40:39 +01:00 committed by Marek Posolda
parent 897c44bd1f
commit c427e65354
44 changed files with 1545 additions and 140 deletions

View file

@ -19,12 +19,10 @@ import {
} from "@patternfly/react-core/deprecated";
import { CSSProperties, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { ContinueCancelModal, useAlerts } from "ui-shared";
import { deleteCredentials, getCredentials } from "../api/methods";
import { getCredentials } from "../api/methods";
import {
CredentialContainer,
CredentialMetadataRepresentation,
CredentialRepresentation,
} from "../api/representations";
import { EmptyRow } from "../components/datalist/EmptyRow";
import { Page } from "../components/page/Page";
@ -70,16 +68,15 @@ const MobileLink = ({ title, onClick, testid }: MobileLinkProps) => {
export const SigningIn = () => {
const { t } = useTranslation();
const context = useEnvironment();
const { addAlert, addError } = useAlerts();
const { login } = context.keycloak;
const [credentials, setCredentials] = useState<CredentialContainer[]>();
const [key, setKey] = useState(1);
const refresh = () => setKey(key + 1);
usePromise((signal) => getCredentials({ signal, context }), setCredentials, [
key,
]);
usePromise(
(signal) => getCredentials({ signal, context }),
setCredentials,
[],
);
const credentialRowCells = (
credMetadata: CredentialMetadataRepresentation,
@ -115,9 +112,6 @@ export const SigningIn = () => {
return items;
};
const label = (credential: CredentialRepresentation) =>
credential.userLabel || t(credential.type as TFuncKey);
if (!credentials) {
return <Spinner />;
}
@ -205,41 +199,19 @@ export const SigningIn = () => {
aria-labelledby={`cred-${meta.credential.id}`}
>
{container.removeable ? (
<ContinueCancelModal
buttonTitle={t("delete")}
buttonTestRole="remove"
modalTitle={t("removeCred", {
name: label(meta.credential),
})}
continueLabel={t("confirm")}
cancelLabel={t("cancel")}
buttonVariant="danger"
onContinue={async () => {
try {
await deleteCredentials(
context,
meta.credential,
);
addAlert(
t("successRemovedMessage", {
userLabel: label(meta.credential),
}),
);
refresh();
} catch (error) {
addError(
t("errorRemovedMessage", {
userLabel: label(meta.credential),
error,
}).toString(),
);
}
<Button
variant="danger"
data-testrole="remove"
onClick={() => {
login({
action:
"delete_credential:" +
meta.credential.id,
});
}}
>
{t("stopUsingCred", {
name: label(meta.credential),
})}
</ContinueCancelModal>
{t("delete")}
</Button>
) : (
<Button
variant="secondary"

View file

@ -4,7 +4,6 @@ import { parseResponse } from "./parse-response";
import {
ClientRepresentation,
CredentialContainer,
CredentialRepresentation,
DeviceRepresentation,
Group,
LinkedAccountRepresentation,
@ -99,15 +98,6 @@ export async function getCredentials({ signal, context }: CallOptions) {
return parseResponse<CredentialContainer[]>(response);
}
export async function deleteCredentials(
context: KeycloakContext,
credential: CredentialRepresentation,
) {
return request("/credentials/" + credential.id, context, {
method: "DELETE",
});
}
export async function getLinkedAccounts({ signal, context }: CallOptions) {
const response = await request("/linked-accounts", context, { signal });
return parseResponse<LinkedAccountRepresentation[]>(response);

View file

@ -42,7 +42,6 @@ export { SharedWith } from "./resources/SharedWith";
export { ShareTheResource } from "./resources/ShareTheResource";
export {
deleteConsent,
deleteCredentials,
deleteSession,
getApplications,
getCredentials,

View file

@ -0,0 +1,56 @@
/*
* Copyright 2024 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.migration.migrators;
import org.jboss.logging.Logger;
import org.keycloak.migration.ModelVersion;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.DefaultRequiredActions;
import org.keycloak.representations.idm.RealmRepresentation;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class MigrateTo24_0_3 implements Migration {
private static final Logger LOG = Logger.getLogger(MigrateTo24_0_3.class);
public static final ModelVersion VERSION = new ModelVersion("24.0.3");
@Override
public void migrate(KeycloakSession session) {
session.realms().getRealmsStream().forEach(realm -> migrateRealm(session, realm));
}
@Override
public void migrateImport(KeycloakSession session, RealmModel realm, RealmRepresentation rep, boolean skipUserDependent) {
migrateRealm(session, realm);
}
@Override
public ModelVersion getVersion() {
return VERSION;
}
private void migrateRealm(KeycloakSession session, RealmModel realm) {
DefaultRequiredActions.addDeleteCredentialAction(realm);
}
}

View file

@ -38,6 +38,7 @@ import org.keycloak.migration.migrators.MigrateTo21_0_0;
import org.keycloak.migration.migrators.MigrateTo22_0_0;
import org.keycloak.migration.migrators.MigrateTo23_0_0;
import org.keycloak.migration.migrators.MigrateTo24_0_0;
import org.keycloak.migration.migrators.MigrateTo24_0_3;
import org.keycloak.migration.migrators.MigrateTo25_0_0;
import org.keycloak.migration.migrators.MigrateTo2_0_0;
import org.keycloak.migration.migrators.MigrateTo2_1_0;
@ -115,6 +116,7 @@ public class DefaultMigrationManager implements MigrationManager {
new MigrateTo22_0_0(),
new MigrateTo23_0_0(),
new MigrateTo24_0_0(),
new MigrateTo24_0_3(),
new MigrateTo25_0_0()
};

View file

@ -21,6 +21,7 @@ import org.keycloak.http.HttpRequest;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@ -57,6 +58,11 @@ public interface AbstractAuthenticationFlowContext {
*/
AuthenticationExecutionModel getExecution();
/**
* @return the top level flow (root flow) of this authentication
*/
AuthenticationFlowModel getTopLevelFlow();
/**
* Current realm
*

View file

@ -18,6 +18,8 @@
package org.keycloak.authentication;
import org.keycloak.models.AuthenticationFlowModel;
/**
* Callback to be triggered during various lifecycle events of authentication flow.
*
@ -40,7 +42,9 @@ public interface AuthenticationFlowCallback extends Authenticator {
/**
* Triggered after the top authentication flow is successfully finished.
* It is really suitable for last verification of successful authentication
*
* @param topFlow which was successfully finished
*/
default void onTopFlowSuccess() {
default void onTopFlowSuccess(AuthenticationFlowModel topFlow) {
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright 2024 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.authentication;
import org.keycloak.models.KeycloakSession;
import org.keycloak.sessions.AuthenticationSessionModel;
/**
* Marking any required action implementation, that is supposed to work with user credentials
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public interface CredentialAction {
/**
* @return credential type, which this action is able to register. This should refer to the same value as returned by {@link org.keycloak.credential.CredentialProvider#getType} of the
* corresponding credential provider and {@link AuthenticatorFactory#getReferenceCategory()} of the corresponding authenticator
*/
String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession);
}

View file

@ -1,4 +1,7 @@
package org.keycloak.authentication;
public interface CredentialRegistrator {
/**
* Marking implementation of the action, which is able to register credential of the particular type
*/
public interface CredentialRegistrator extends CredentialAction {
}

View file

@ -38,14 +38,14 @@ public interface RequiredActionProvider extends Provider {
default InitiatedActionSupport initiatedActionSupport() {
return InitiatedActionSupport.NOT_SUPPORTED;
}
/**
* Callback to let the action know that an application-initiated action
* was canceled.
*
*
* @param session The Keycloak session.
* @param authSession The authentication session.
*
*
*/
default void initiatedActionCanceled(KeycloakSession session, AuthenticationSessionModel authSession) {
return;

View file

@ -89,6 +89,7 @@ public interface Details {
String CREDENTIAL_TYPE = "credential_type";
String SELECTED_CREDENTIAL_ID = "selected_credential_id";
String CREDENTIAL_ID = "credential_id";
String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail";
String CREDENTIAL_USER_LABEL = "credential_user_label";

View file

@ -120,4 +120,8 @@ public interface Errors {
String SLOW_DOWN = "slow_down";
String GENERIC_AUTHENTICATION_ERROR= "generic_authentication_error";
String CREDENTIAL_NOT_FOUND = "credential_not_found";
String MISSING_CREDENTIAL_ID = "missing_credential_id";
String DELETE_CREDENTIAL_FAILED = "delete_credential_failed";
}

View file

@ -86,6 +86,8 @@ public final class Constants {
public static final String KEY = "key";
public static final String KC_ACTION = "kc_action";
public static final String KC_ACTION_PARAMETER = "kc_action_parameter";
public static final String KC_ACTION_STATUS = "kc_action_status";
public static final String KC_ACTION_EXECUTING = "kc_action_executing";
public static final int KC_ACTION_MAX_AGE = 300;

View file

@ -77,6 +77,7 @@ public class DefaultRequiredActions {
UPDATE_PASSWORD(UserModel.RequiredAction.UPDATE_PASSWORD.name(), DefaultRequiredActions::addUpdatePasswordAction),
TERMS_AND_CONDITIONS(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name(), DefaultRequiredActions::addTermsAndConditionsAction),
DELETE_ACCOUNT("delete_account", DefaultRequiredActions::addDeleteAccountAction),
DELETE_CREDENTIAL("delete_credential", DefaultRequiredActions::addDeleteCredentialAction),
UPDATE_USER_LOCALE("update_user_locale", DefaultRequiredActions::addUpdateLocaleAction),
UPDATE_EMAIL(UserModel.RequiredAction.UPDATE_EMAIL.name(), DefaultRequiredActions::addUpdateEmailAction, () -> isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)),
CONFIGURE_RECOVERY_AUTHN_CODES(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name(), DefaultRequiredActions::addRecoveryAuthnCodesAction, () -> isFeatureEnabled(Profile.Feature.RECOVERY_CODES)),
@ -209,6 +210,19 @@ public class DefaultRequiredActions {
}
}
public static void addDeleteCredentialAction(RealmModel realm) {
if (realm.getRequiredActionProviderByAlias("delete_credential") == null) {
RequiredActionProviderModel deleteCredential = new RequiredActionProviderModel();
deleteCredential.setEnabled(true);
deleteCredential.setAlias("delete_credential");
deleteCredential.setName("Delete Credential");
deleteCredential.setProviderId("delete_credential");
deleteCredential.setDefaultAction(false);
deleteCredential.setPriority(100);
realm.addRequiredActionProvider(deleteCredential);
}
}
public static void addUpdateLocaleAction(RealmModel realm) {
if (realm.getRequiredActionProviderByAlias("update_user_locale") == null) {
RequiredActionProviderModel updateUserLocale = new RequiredActionProviderModel();

View file

@ -357,6 +357,11 @@ public class AuthenticationProcessor {
return execution;
}
@Override
public AuthenticationFlowModel getTopLevelFlow() {
return AuthenticatorUtil.getTopParentFlow(realm, execution);
}
@Override
public AuthenticatorConfigModel getAuthenticatorConfig() {
if (execution.getAuthenticatorConfig() == null) return null;

View file

@ -24,8 +24,12 @@ import org.keycloak.common.ClientConnection;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.common.util.reflections.Types;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialProviderFactory;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@ -41,6 +45,7 @@ import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.keycloak.services.managers.AuthenticationManager.FORCED_REAUTHENTICATION;
import static org.keycloak.services.managers.AuthenticationManager.SSO_AUTH;
@ -132,6 +137,29 @@ public class AuthenticatorUtil {
return executions;
}
/**
* Useful if we need to find top-level flow from executionModel
*
* @param realm
* @param executionModel
* @return Top parent flow corresponding to given executionModel.
*/
public static AuthenticationFlowModel getTopParentFlow(RealmModel realm, AuthenticationExecutionModel executionModel) {
if (executionModel.getParentFlow() != null) {
AuthenticationFlowModel flow = realm.getAuthenticationFlowById(executionModel.getParentFlow());
if (flow == null) throw new IllegalStateException("Flow '" + executionModel.getParentFlow() + "' referenced from execution '" + executionModel.getId() + "' not found in realm " + realm.getName());
if (flow.isTopLevel()) return flow;
AuthenticationExecutionModel execution = realm.getAuthenticationExecutionByFlowId(flow.getId());
if (execution == null) throw new IllegalStateException("Not found execution referenced by flow '" + flow.getId() + "' in realm " + realm.getName());
return getTopParentFlow(realm, execution);
} else {
throw new IllegalStateException("Execution '" + executionModel.getId() + "' does not have parent flow in realm " + realm.getName());
}
}
/**
* Logouts all sessions that are different to the current authentication session
* managed in the action context.
@ -174,4 +202,14 @@ public class AuthenticatorUtil {
.success();
});
}
/**
* @param session
* @return all credential providers available
*/
public static Stream<CredentialProvider> getCredentialProviders(KeycloakSession session) {
return session.getKeycloakSessionFactory().getProviderFactoriesStream(CredentialProvider.class)
.filter(f -> Types.supports(CredentialProvider.class, f, CredentialProviderFactory.class))
.map(f -> session.getProvider(CredentialProvider.class, f.getId()));
}
}

View file

@ -586,6 +586,6 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
.filter(Objects::nonNull)
.filter(AuthenticationFlowCallback.class::isInstance)
.map(AuthenticationFlowCallback.class::cast)
.forEach(AuthenticationFlowCallback::onTopFlowSuccess);
.forEach(callback -> callback.onTopFlowSuccess(flow));
}
}

View file

@ -52,7 +52,7 @@ public class CookieAuthenticator implements Authenticator {
LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, authSession.getProtocol());
authSession.setAuthNote(Constants.LOA_MAP, authResult.getSession().getNote(Constants.LOA_MAP));
context.setUser(authResult.getUser());
AcrStore acrStore = new AcrStore(authSession);
AcrStore acrStore = new AcrStore(context.getSession(), authSession);
// Cookie re-authentication is skipped if re-authentication is required
if (protocol.requireReauthentication(authResult.getSession(), authSession)) {
@ -65,10 +65,15 @@ public class CookieAuthenticator implements Authenticator {
int previouslyAuthenticatedLevel = acrStore.getHighestAuthenticatedLevelFromPreviousAuthentication();
AuthenticatorUtils.updateCompletedExecutions(context.getAuthenticationSession(), authResult.getSession(), context.getExecution().getId());
if (acrStore.getRequestedLevelOfAuthentication() > previouslyAuthenticatedLevel) {
if (acrStore.getRequestedLevelOfAuthentication(context.getTopLevelFlow()) > previouslyAuthenticatedLevel) {
// Step-up authentication, we keep the loa from the existing user session.
// The cookie alone is not enough and other authentications must follow.
acrStore.setLevelAuthenticatedToCurrentRequest(previouslyAuthenticatedLevel);
if (authSession.getClientNote(Constants.KC_ACTION) != null) {
context.setForwardedInfoMessage(Messages.AUTHENTICATE_STRONG);
}
context.attempted();
} else {
// Cookie only authentication

View file

@ -22,9 +22,9 @@ import org.keycloak.authentication.AuthenticationFlowCallback;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.authenticators.util.AcrStore;
import org.keycloak.authentication.authenticators.util.LoAUtil;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@ -52,11 +52,11 @@ public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, Au
@Override
public boolean matchCondition(AuthenticationFlowContext context) {
AuthenticationSessionModel authSession = context.getAuthenticationSession();
AcrStore acrStore = new AcrStore(authSession);
AcrStore acrStore = new AcrStore(context.getSession(), authSession);
int currentAuthenticationLoa = acrStore.getLevelOfAuthenticationFromCurrentAuthentication();
Integer configuredLoa = getConfiguredLoa(context);
if (configuredLoa == null) configuredLoa = Constants.MINIMUM_LOA;
int requestedLoa = acrStore.getRequestedLevelOfAuthentication();
int requestedLoa = acrStore.getRequestedLevelOfAuthentication(context.getTopLevelFlow());
if (currentAuthenticationLoa < Constants.MINIMUM_LOA) {
logger.tracef("Condition '%s' evaluated to true due the user not yet reached any authentication level in this session, configuredLoa: %d, requestedLoa: %d",
context.getAuthenticatorConfig().getAlias(), configuredLoa, requestedLoa);
@ -84,7 +84,7 @@ public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, Au
@Override
public void onParentFlowSuccess(AuthenticationFlowContext context) {
AuthenticationSessionModel authSession = context.getAuthenticationSession();
AcrStore acrStore = new AcrStore(authSession);
AcrStore acrStore = new AcrStore(context.getSession(), authSession);
Integer newLoa = getConfiguredLoa(context);
if (newLoa == null) {
@ -102,14 +102,14 @@ public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, Au
}
@Override
public void onTopFlowSuccess() {
public void onTopFlowSuccess(AuthenticationFlowModel topFlow) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
AcrStore acrStore = new AcrStore(authSession);
AcrStore acrStore = new AcrStore(session, authSession);
logger.tracef("Finished authentication at level %d when authenticating authSession '%s'.", acrStore.getLevelOfAuthenticationFromCurrentAuthentication(), authSession.getParentSession().getId());
if (acrStore.isLevelOfAuthenticationForced() && !acrStore.isLevelOfAuthenticationSatisfiedFromCurrentAuthentication()) {
if (acrStore.isLevelOfAuthenticationForced() && !acrStore.isLevelOfAuthenticationSatisfiedFromCurrentAuthentication(topFlow)) {
String details = String.format("Forced level of authentication did not meet the requirements. Requested level: %d, Fulfilled level: %d",
acrStore.getRequestedLevelOfAuthentication(), acrStore.getLevelOfAuthenticationFromCurrentAuthentication());
acrStore.getRequestedLevelOfAuthentication(topFlow), acrStore.getLevelOfAuthenticationFromCurrentAuthentication());
throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, details, Messages.ACR_NOT_FULFILLED);
}

View file

@ -20,18 +20,28 @@ package org.keycloak.authentication.authenticators.util;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.fasterxml.jackson.core.type.TypeReference;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.CredentialAction;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.Time;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.JsonSerialization;
import static org.keycloak.models.Constants.NO_LOA;
/**
* CRUD data in the authentication session, which are related to step-up authentication
*
@ -41,9 +51,11 @@ public class AcrStore {
private static final Logger logger = Logger.getLogger(AcrStore.class);
private final KeycloakSession session;
private final AuthenticationSessionModel authSession;
public AcrStore(AuthenticationSessionModel authSession) {
public AcrStore(KeycloakSession session, AuthenticationSessionModel authSession) {
this.session = session;
this.authSession = authSession;
}
@ -53,21 +65,73 @@ public class AcrStore {
}
public int getRequestedLevelOfAuthentication() {
public int getRequestedLevelOfAuthentication(AuthenticationFlowModel executionModel) {
String requiredLoa = authSession.getClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION);
return requiredLoa == null ? Constants.NO_LOA : Integer.parseInt(requiredLoa);
int requestedLoaByClient = requiredLoa == null ? NO_LOA : Integer.parseInt(requiredLoa);
int requestedLoaByKcAction = getRequestedLevelOfAuthenticationByKcAction(executionModel);
logger.tracef("Level requested by client: %d, level requested by kc_action parameter: %d", requestedLoaByClient, requestedLoaByKcAction);
return Math.max(requestedLoaByClient, requestedLoaByKcAction);
}
//
private int getRequestedLevelOfAuthenticationByKcAction(AuthenticationFlowModel topLevelFlow) {
RealmModel realm = authSession.getRealm();
UserModel user = authSession.getAuthenticatedUser();
String kcAction = authSession.getClientNote(Constants.KC_ACTION);
if (user != null && kcAction != null) {
RequiredActionProvider reqAction = session.getProvider(RequiredActionProvider.class, kcAction);
if (reqAction instanceof CredentialAction) {
String credentialType = ((CredentialAction) reqAction).getCredentialType(session, authSession);
if (credentialType != null) {
Map<String, Integer> credentialTypesToLoa = LoAUtil.getCredentialTypesToLoAMap(session, realm, topLevelFlow);
public boolean isLevelOfAuthenticationSatisfiedFromCurrentAuthentication() {
return getRequestedLevelOfAuthentication()
Integer credentialTypeLevel = credentialTypesToLoa.get(credentialType);
if (credentialTypeLevel != null) {
// We check if user has any credentials of given type available. For instance if user doesn't yet have any 2nd-factor configured, we don't request level2 from him
MultivaluedHashMap<Integer, String> loaToCredentialTypes = reverse(credentialTypesToLoa);
return getHighestLevelAvailableForUser(user, loaToCredentialTypes, credentialTypeLevel);
}
}
}
}
return NO_LOA;
}
private MultivaluedHashMap<Integer, String> reverse(Map<String, Integer> orig) {
MultivaluedHashMap<Integer, String> reverse = new MultivaluedHashMap<>();
orig.forEach((key, value) -> reverse.add(value, key));
return reverse;
}
private Integer getHighestLevelAvailableForUser(UserModel user, MultivaluedHashMap<Integer, String> loaToCredentialTypes, int levelToTry) {
if (levelToTry <= NO_LOA) return levelToTry;
List<String> currentLevelCredentialTypes = loaToCredentialTypes.get(levelToTry);
if (currentLevelCredentialTypes == null || currentLevelCredentialTypes.isEmpty()) {
// No credentials required for authentication on this level
return levelToTry;
}
boolean hasCredentialOfLevel = user.credentialManager().getStoredCredentialsStream()
.anyMatch(credentialModel -> currentLevelCredentialTypes.contains(credentialModel.getType()));
if (hasCredentialOfLevel) {
logger.tracef("User %s has credential of level %d available", user.getUsername(), levelToTry);
return levelToTry;
} else {
// Fallback to lower level
return getHighestLevelAvailableForUser(user, loaToCredentialTypes, levelToTry - 1);
}
}
public boolean isLevelOfAuthenticationSatisfiedFromCurrentAuthentication(AuthenticationFlowModel topFlow) {
return getRequestedLevelOfAuthentication(topFlow)
<= getAuthenticatedLevelCurrentAuthentication();
}
public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) {
String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION);
return clientSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(clientSessionLoaNote);
return clientSessionLoaNote == null ? NO_LOA : Integer.parseInt(clientSessionLoaNote);
}
@ -101,7 +165,7 @@ public class AcrStore {
*/
public int getLevelOfAuthenticationFromCurrentAuthentication() {
String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION);
return authSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(authSessionLoaNote);
return authSessionLoaNote == null ? NO_LOA : Integer.parseInt(authSessionLoaNote);
}
@ -137,7 +201,7 @@ public class AcrStore {
private int getAuthenticatedLevelCurrentAuthentication() {
String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION);
return authSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(authSessionLoaNote);
return authSessionLoaNote == null ? NO_LOA : Integer.parseInt(authSessionLoaNote);
}
/**
@ -146,7 +210,7 @@ public class AcrStore {
public int getHighestAuthenticatedLevelFromPreviousAuthentication() {
// No map found. User was not yet authenticated in this session
Map<Integer, Integer> levels = getCurrentAuthenticatedLevelsMap();
if (levels == null || levels.isEmpty()) return Constants.NO_LOA;
if (levels == null || levels.isEmpty()) return NO_LOA;
// Map was already saved, so it is SSO authentication at minimum. Using "0" level as the minimum level in this case
int maxLevel = Constants.MINIMUM_LOA;

View file

@ -19,21 +19,33 @@
package org.keycloak.authentication.authenticators.util;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.jboss.logging.Logger;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.cache.CachedRealmModel;
import static org.keycloak.models.Constants.NO_LOA;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -48,7 +60,7 @@ public class LoAUtil {
*/
public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) {
String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION);
return clientSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(clientSessionLoaNote);
return clientSessionLoaNote == null ? NO_LOA : Integer.parseInt(clientSessionLoaNote);
}
@ -119,4 +131,75 @@ public class LoAUtil {
return 0;
}
}
/**
* Return map where:
* - keys are credential types corresponding to authenticators available in given authentication flow
* - values are LoA levels of those credentials in the given flow (If not step-up authentication is used, values will be always Constants.NO_LOA)
*
* For instance if we have password as level1 and OTP or WebAuthn as available level2 authenticators it can return map like:
* { "password" -> 1,
* "otp" -> 2
* "webauthn" -> 2
* }
*
* @param session
* @param realm
* @param topFlow
* @return map as described above. Never returns null, but can return empty map.
*/
public static Map<String, Integer> getCredentialTypesToLoAMap(KeycloakSession session, RealmModel realm, AuthenticationFlowModel topFlow) {
// Attempt to cache mapping, so it is not needed to compute it multiple times at every authentication
String cacheKey = "flow:" + topFlow.getId();
if (realm instanceof CachedRealmModel) {
ConcurrentHashMap cachedWith = ((CachedRealmModel) realm).getCachedWith();
Map<String, Integer> result = (Map<String, Integer>) cachedWith.get(cacheKey);
if (result != null) return result;
}
Map<String, Integer> result = new HashMap<>();
AtomicReference<Integer> currentLevel = new AtomicReference<>(NO_LOA);
Set<String> availableCredentialTypes = AuthenticatorUtil.getCredentialProviders(session)
.map(CredentialProvider::getType)
.collect(Collectors.toSet());
fillCredentialsToLoAMap(session, realm, topFlow, availableCredentialTypes, currentLevel, result);
logger.tracef("Computed credential types to LoA map for authentication flow '%s' in realm '%s'. Mapping: %s", topFlow.getAlias(), realm.getName(), result);
if (realm instanceof CachedRealmModel) {
ConcurrentHashMap cachedWith = ((CachedRealmModel) realm).getCachedWith();
cachedWith.put(cacheKey, result);
}
return result;
}
private static void fillCredentialsToLoAMap(KeycloakSession session, RealmModel realm, AuthenticationFlowModel authFlow, Set<String> availableCredentialTypes, AtomicReference<Integer> currentLevel, Map<String, Integer> result) {
realm.getAuthenticationExecutionsStream(authFlow.getId()).forEachOrdered(execution -> {
if (execution.isAuthenticatorFlow()) {
AuthenticationFlowModel subFlow = realm.getAuthenticationFlowById(execution.getFlowId());
int levelWhenExecuted = currentLevel.get();
fillCredentialsToLoAMap(session, realm, subFlow, availableCredentialTypes, currentLevel, result);
currentLevel.set(levelWhenExecuted); // Subflow is finished. We should "reset" current level and set it to the same value before we started to process the subflow
} else {
if (ConditionalLoaAuthenticatorFactory.PROVIDER_ID.equals(execution.getAuthenticator())) {
AuthenticatorConfigModel loaConditionConfig = realm.getAuthenticatorConfigById(execution.getAuthenticatorConfig());
Integer level = getLevelFromLoaConditionConfiguration(loaConditionConfig);
if (level != null) {
currentLevel.set(level);
}
} else {
AuthenticatorFactory factory = (AuthenticatorFactory) session.getKeycloakSessionFactory().getProviderFactory(Authenticator.class, execution.getAuthenticator());
if (factory == null) return;
// reference-category points to the credentialType
if (factory.getReferenceCategory() != null && availableCredentialTypes.contains(factory.getReferenceCategory())) {
result.put(factory.getReferenceCategory(), currentLevel.get());
}
}
}
});
}
}

View file

@ -0,0 +1,194 @@
/*
* Copyright 2024 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.authentication.requiredactions;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.authentication.CredentialAction;
import org.keycloak.authentication.InitiatedActionSupport;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.authentication.authenticators.util.AcrStore;
import org.keycloak.authentication.requiredactions.util.CredentialDeleteHelper;
import org.keycloak.credential.CredentialModel;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.utils.StringUtil;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class DeleteCredentialAction implements RequiredActionProvider, RequiredActionFactory, CredentialAction {
public static final String PROVIDER_ID = "delete_credential";
@Override
public RequiredActionProvider create(KeycloakSession session) {
return this;
}
@Override
public InitiatedActionSupport initiatedActionSupport() {
return InitiatedActionSupport.SUPPORTED;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public void evaluateTriggers(RequiredActionContext context) {
}
@Override
public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
String credentialId = authenticationSession.getClientNote(Constants.KC_ACTION_PARAMETER);
if (credentialId == null) {
return null;
}
UserModel user = authenticationSession.getAuthenticatedUser();
if (user == null) {
return null;
}
CredentialModel credential = user.credentialManager().getStoredCredentialById(credentialId);
if (credential == null) {
if (credentialId.endsWith("-id")) {
return credentialId.substring(0, credentialId.length() - 3);
} else {
return null;
}
} else {
return credential.getType();
}
}
@Override
public void requiredActionChallenge(RequiredActionContext context) {
String credentialId = context.getAuthenticationSession().getClientNote(Constants.KC_ACTION_PARAMETER);
UserModel user = context.getUser();
if (credentialId == null) {
context.getEvent()
.error(Errors.MISSING_CREDENTIAL_ID);
context.ignore();
return;
}
String credentialLabel;
CredentialModel credential = user.credentialManager().getStoredCredentialById(credentialId);
if (credential == null) {
// Backwards compatibility with account console 1 - When stored credential is not found, it may be federated credential.
// In this case, it's ID needs to be something like "otp-id", which is returned by account REST GET endpoint as a placeholder
// for federated credentials (See CredentialHelper.createUserStorageCredentialRepresentation )
if (credentialId.endsWith("-id")) {
credentialLabel = credentialId.substring(0, credentialId.length() - 3);
} else {
context.getEvent()
.detail(Details.CREDENTIAL_ID, credentialId)
.error(Errors.CREDENTIAL_NOT_FOUND);
context.ignore();
return;
}
} else {
credentialLabel = StringUtil.isNotBlank(credential.getUserLabel()) ? credential.getUserLabel() : credential.getType();
}
Response challenge = context.form()
.setAttribute("credentialLabel", credentialLabel)
.createForm("delete-credential.ftl");
context.challenge(challenge);
}
private void setupEvent(CredentialModel credential, EventBuilder event) {
if (credential != null) {
if (OTPCredentialModel.TYPE.equals(credential.getType())) {
event.event(EventType.REMOVE_TOTP);
}
event.detail(Details.CREDENTIAL_TYPE, credential.getType())
.detail(Details.CREDENTIAL_ID, credential.getId())
.detail(Details.CREDENTIAL_USER_LABEL, credential.getUserLabel());
}
}
@Override
public void processAction(RequiredActionContext context) {
EventBuilder event = context.getEvent();
String credentialId = context.getAuthenticationSession().getClientNote(Constants.KC_ACTION_PARAMETER);
CredentialModel credential = context.getUser().credentialManager().getStoredCredentialById(credentialId);
setupEvent(credential, event);
try {
CredentialDeleteHelper.removeCredential(context.getSession(), context.getUser(), credentialId, () -> getCurrentLoa(context.getSession(), context.getAuthenticationSession()));
context.success();
} catch (WebApplicationException wae) {
Response response = context.getSession().getProvider(LoginFormsProvider.class)
.setAuthenticationSession(context.getAuthenticationSession())
.setUser(context.getUser())
.setError(wae.getMessage())
.createErrorPage(Response.Status.BAD_REQUEST);
event.detail(Details.REASON, wae.getMessage())
.error(Errors.DELETE_CREDENTIAL_FAILED);
context.challenge(response);
}
}
private int getCurrentLoa(KeycloakSession session, AuthenticationSessionModel authSession) {
return new AcrStore(session, authSession).getLevelOfAuthenticationFromCurrentAuthentication();
}
@Override
public String getDisplayText() {
return "Delete Credential";
}
@Override
public void close() {
}
}

View file

@ -5,6 +5,7 @@ import java.util.Arrays;
import java.util.List;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.CredentialRegistrator;
import org.keycloak.authentication.InitiatedActionSupport;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
@ -21,8 +22,9 @@ import org.keycloak.provider.EnvironmentDependentProviderFactory;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.keycloak.sessions.AuthenticationSessionModel;
public class RecoveryAuthnCodesAction implements RequiredActionProvider, RequiredActionFactory, EnvironmentDependentProviderFactory {
public class RecoveryAuthnCodesAction implements RequiredActionProvider, RequiredActionFactory, EnvironmentDependentProviderFactory, CredentialRegistrator {
private static final String FIELD_GENERATED_RECOVERY_AUTHN_CODES_HIDDEN = "generatedRecoveryAuthnCodes";
private static final String FIELD_GENERATED_AT_HIDDEN = "generatedAt";
@ -35,6 +37,11 @@ public class RecoveryAuthnCodesAction implements RequiredActionProvider, Require
return PROVIDER_ID;
}
@Override
public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
return RecoveryAuthnCodesCredentialModel.TYPE;
}
@Override
public String getDisplayText() {
return "Recovery Authentication Codes";

View file

@ -39,6 +39,7 @@ import org.keycloak.models.utils.CredentialValidation;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.utils.CredentialHelper;
import jakarta.ws.rs.core.MultivaluedMap;
@ -161,6 +162,11 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
return UserModel.RequiredAction.CONFIGURE_TOTP.name();
}
@Override
public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
return OTPCredentialModel.TYPE;
}
@Override
public boolean isOneTimeAction() {
return true;

View file

@ -54,6 +54,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.WebAuthnPolicy;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.utils.StringUtil;
import com.webauthn4j.converter.util.ObjectConverter;
@ -170,6 +171,11 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis
return context.getRealm().getWebAuthnPolicy();
}
@Override
public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
return getCredentialType();
}
protected String getCredentialType() {
return WebAuthnCredentialModel.TYPE_TWOFACTOR;
}

View file

@ -0,0 +1,111 @@
/*
* Copyright 2024 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.authentication.requiredactions.util;
import java.util.Map;
import java.util.function.Supplier;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.NotFoundException;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.authenticators.util.LoAUtil;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.credential.CredentialTypeMetadataContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import static org.keycloak.models.Constants.NO_LOA;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class CredentialDeleteHelper {
private static final Logger logger = Logger.getLogger(CredentialDeleteHelper.class);
/**
* Removing credential of given ID of specified user. It does the necessary validation to validate if specified credential can be removed.
* In case of step-up authentication enabled, it verifies if user authenticated with corresponding level in order to be able to remove this credential.
*
* For instance removing 2nd-factor credential require authentication with 2nd-factor as well for security reasons.
*
* @param session
* @param user
* @param credentialId
* @param currentLoAProvider supplier of current authenticated level. Can be retrieved for instance from session or from the token
* @return removed credential. It can return null if credential was not found or if it was legacy format of federated credential ID
*/
public static CredentialModel removeCredential(KeycloakSession session, UserModel user, String credentialId, Supplier<Integer> currentLoAProvider) {
CredentialModel credential = user.credentialManager().getStoredCredentialById(credentialId);
if (credential == null) {
// Backwards compatibility with account console 1 - When stored credential is not found, it may be federated credential.
// In this case, it's ID needs to be something like "otp-id", which is returned by account REST GET endpoint as a placeholder
// for federated credentials (See CredentialHelper.createUserStorageCredentialRepresentation )
if (credentialId.endsWith("-id")) {
String credentialType = credentialId.substring(0, credentialId.length() - 3);
checkIfCanBeRemoved(session, user, credentialType, currentLoAProvider);
user.credentialManager().disableCredentialType(credentialType);
return null;
}
throw new NotFoundException("Credential not found");
}
checkIfCanBeRemoved(session, user, credential.getType(), currentLoAProvider);
user.credentialManager().removeStoredCredentialById(credentialId);
return credential;
}
private static void checkIfCanBeRemoved(KeycloakSession session, UserModel user, String credentialType, Supplier<Integer> currentLoAProvider) {
CredentialProvider credentialProvider = AuthenticatorUtil.getCredentialProviders(session)
.filter(credentialProvider1 -> credentialType.equals(credentialProvider1.getType()))
.findAny().orElse(null);
if (credentialProvider == null) {
logger.warnf("Credential provider %s not found", credentialType);
throw new NotFoundException("Credential provider not found");
}
CredentialTypeMetadataContext ctx = CredentialTypeMetadataContext.builder().user(user).build(session);
CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata(ctx);
if (!metadata.isRemoveable()) {
logger.warnf("Credential type %s cannot be removed", credentialType);
throw new BadRequestException("Credential type cannot be removed");
}
// Check if current accessToken has permission to remove credential in case of step-up authentication was used
checkAuthenticatedLoASufficientForCredentialRemove(session, credentialType, currentLoAProvider);
}
private static void checkAuthenticatedLoASufficientForCredentialRemove(KeycloakSession session, String credentialType, Supplier<Integer> currentLoAProvider) {
int requestedLoaForCredentialRemove = getRequestedLoaForCredential(session, session.getContext().getRealm(), credentialType);
int currentAuthenticatedLevel = currentLoAProvider.get();
if (currentAuthenticatedLevel < requestedLoaForCredentialRemove) {
throw new ForbiddenException("Insufficient level of authentication for removing credential of type '" + credentialType + "'.");
}
}
private static int getRequestedLoaForCredential(KeycloakSession session, RealmModel realm, String credentialType) {
Map<String, Integer> credentialTypesToLoa = LoAUtil.getCredentialTypesToLoAMap(session, realm, realm.getBrowserFlow());
return credentialTypesToLoa.getOrDefault(credentialType, NO_LOA);
}
}

View file

@ -595,7 +595,7 @@ public class TokenManager {
userSession.setNote(entry.getKey(), entry.getValue());
}
clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(new AcrStore(authSession).getLevelOfAuthenticationFromCurrentAuthentication()));
clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(new AcrStore(session, authSession).getLevelOfAuthenticationFromCurrentAuthentication()));
clientSession.setTimestamp(userSession.getLastSessionRefresh());
// Remove authentication session now (just current tab, not whole "rootAuthenticationSession" in case we have more browser tabs with "authentications in progress")

View file

@ -363,7 +363,17 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
public static void performActionOnParameters(AuthorizationEndpointRequest request, BiConsumer<String, String> paramAction) {
paramAction.accept(AdapterConstants.KC_IDP_HINT, request.getIdpHint());
paramAction.accept(Constants.KC_ACTION, request.getAction());
String kcAction = request.getAction();
String kcActionParameter = null;
if (kcAction != null && kcAction.contains(":")) {
String[] splits = kcAction.split(":");
kcAction = splits[0];
kcActionParameter = splits[1];
}
paramAction.accept(Constants.KC_ACTION, kcAction);
paramAction.accept(Constants.KC_ACTION_PARAMETER, kcActionParameter);
paramAction.accept(OAuth2Constants.DISPLAY, request.getDisplay());
paramAction.accept(OIDCLoginProtocol.ACR_PARAM, request.getAcr());
paramAction.accept(OIDCLoginProtocol.CLAIMS_PARAM, request.getClaims());

View file

@ -102,7 +102,7 @@ public class AcrUtils {
try {
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
} catch (IOException e) {
LOGGER.warnf("Invalid client configuration (ACR-LOA map) for client '%s'", client.getClientId());
LOGGER.warnf("Invalid client configuration (ACR-LOA map) for client '%s'. Error details: %s", client.getClientId(), e.getMessage());
return Collections.emptyMap();
}
}
@ -119,7 +119,7 @@ public class AcrUtils {
try {
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
} catch (IOException e) {
LOGGER.warn("Invalid realm configuration (ACR-LOA map)");
LOGGER.warnf("Invalid realm configuration (ACR-LOA map). Details: %s", e.getMessage());
return Collections.emptyMap();
}
}

View file

@ -25,6 +25,7 @@ public class Messages {
public static final String LOGIN_TIMEOUT = "loginTimeout";
public static final String REAUTHENTICATE = "reauthenticate";
public static final String AUTHENTICATE_STRONG = "authenticateStrong";
public static final String INVALID_USER = "invalidUserMessage";

View file

@ -1,14 +1,16 @@
package org.keycloak.services.resources.account;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.ws.rs.ForbiddenException;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.common.util.reflections.Types;
import org.keycloak.authentication.AuthenticatorUtil;
import org.keycloak.authentication.requiredactions.util.CredentialDeleteHelper;
import org.keycloak.credential.CredentialMetadata;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.CredentialProviderFactory;
import org.keycloak.credential.CredentialTypeMetadata;
import org.keycloak.credential.CredentialTypeMetadataContext;
import org.keycloak.events.Details;
@ -17,11 +19,13 @@ import org.keycloak.events.EventType;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.protocol.oidc.utils.AcrUtils;
import org.keycloak.representations.account.CredentialMetadataRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.ErrorResponse;
@ -30,7 +34,6 @@ import org.keycloak.services.messages.Messages;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
@ -45,6 +48,7 @@ import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
@ -60,6 +64,8 @@ public class AccountCredentialResource {
public static final String TYPE = "type";
public static final String USER_CREDENTIALS = "user-credentials";
private static final Logger logger = Logger.getLogger(AccountCredentialResource.class);
private final KeycloakSession session;
private final UserModel user;
@ -221,7 +227,7 @@ public class AccountCredentialResource {
return new CredentialContainer(metadata, userCredentialMetadataModels);
};
return getCredentialProviders()
return AuthenticatorUtil.getCredentialProviders(session)
.filter(p -> type == null || Objects.equals(p.getType(), type))
.filter(p -> enabledCredentialTypes.contains(p.getType()))
.map(toCredentialContainer)
@ -229,12 +235,6 @@ public class AccountCredentialResource {
.sorted(Comparator.comparing(CredentialContainer::getMetadata));
}
private Stream<CredentialProvider> getCredentialProviders() {
return session.getKeycloakSessionFactory().getProviderFactoriesStream(CredentialProvider.class)
.filter(f -> Types.supports(CredentialProvider.class, f, CredentialProviderFactory.class))
.map(f -> session.getProvider(CredentialProvider.class, f.getId()));
}
// Going through all authentication flows and their authentication executions to see if there is any authenticator of the corresponding
// credential type.
private Set<String> getEnabledCredentialTypes() {
@ -267,18 +267,25 @@ public class AccountCredentialResource {
return false;
}
private void checkIfCanBeRemoved(String credentialType) {
Set<String> enabledCredentialTypes = getEnabledCredentialTypes();
CredentialProvider credentialProvider = getCredentialProviders()
.filter(p -> credentialType.equals(p.getType()) && enabledCredentialTypes.contains(p.getType()))
.findAny().orElse(null);
if (credentialProvider == null) {
throw new NotFoundException("Credential provider " + credentialType + " not found");
private Integer getCurrentAuthenticatedLevel() {
ClientModel client = realm.getClientByClientId(auth.getToken().getIssuedFor());
Map<String, Integer> acrLoaMap = AcrUtils.getAcrLoaMap(client);
String tokenAcr = auth.getToken().getAcr();
if (tokenAcr == null) {
logger.warnf("Not able to remove credential of user '%s' as no acr claim on the token", user.getUsername());
throw new ForbiddenException("No LoA on the token");
}
CredentialTypeMetadataContext ctx = CredentialTypeMetadataContext.builder().user(user).build(session);
CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata(ctx);
if (!metadata.isRemoveable()) {
throw new BadRequestException("Credential type " + credentialType + " cannot be removed");
Integer currentAuthenticatedLevel = acrLoaMap.get(tokenAcr);
if (currentAuthenticatedLevel != null) {
return currentAuthenticatedLevel;
} else {
try {
return Integer.parseInt(tokenAcr);
} catch (NumberFormatException nfe) {
logger.warnf("Token acr '%s' not found in acrLoaMap of client '%s' or realm '%s'. Not able to remove credential of user '%s'",
tokenAcr, client.getClientId(), realm.getName(), user.getUsername());
throw new ForbiddenException("Unsupported acr on the token");
}
}
}
@ -286,29 +293,18 @@ public class AccountCredentialResource {
* Remove a credential of current user
*
* @param credentialId ID of the credential, which will be removed
* @deprecated It is recommended to delete credentials with the use of "delete_credential" kc_action.
* Action can be used for instance by adding parameter like "kc_action=delete_credential:123" to the login URL where 123 is ID of the credential to delete.
*/
@Path("{credentialId}")
@DELETE
@NoCache
@Deprecated
public void removeCredential(final @PathParam("credentialId") String credentialId) {
auth.require(AccountRoles.MANAGE_ACCOUNT);
CredentialModel credential = user.credentialManager().getStoredCredentialById(credentialId);
if (credential == null) {
// Backwards compatibility with account console 1 - When stored credential is not found, it may be federated credential.
// In this case, it's ID needs to be something like "otp-id", which is returned by account REST GET endpoint as a placeholder
// for federated credentials (See CredentialHelper.createUserStorageCredentialRepresentation )
if (credentialId.endsWith("-id")) {
String credentialType = credentialId.substring(0, credentialId.length() - 3);
checkIfCanBeRemoved(credentialType);
user.credentialManager().disableCredentialType(credentialType);
return;
}
throw new NotFoundException("Credential not found");
}
checkIfCanBeRemoved(credential.getType());
user.credentialManager().removeStoredCredentialById(credentialId);
CredentialModel credential = CredentialDeleteHelper.removeCredential(session, user, credentialId, this::getCurrentAuthenticatedLevel);
if (OTPCredentialModel.TYPE.equals(credential.getType())) {
if (credential != null && OTPCredentialModel.TYPE.equals(credential.getType())) {
event.event(EventType.REMOVE_TOTP)
.detail(Details.SELECTED_CREDENTIAL_ID, credentialId)
.detail(Details.CREDENTIAL_USER_LABEL, credential.getUserLabel());

View file

@ -24,6 +24,7 @@ org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory
org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory
org.keycloak.authentication.requiredactions.UpdateUserLocaleAction
org.keycloak.authentication.requiredactions.DeleteAccount
org.keycloak.authentication.requiredactions.DeleteCredentialAction
org.keycloak.authentication.requiredactions.VerifyUserProfile
org.keycloak.authentication.requiredactions.RecoveryAuthnCodesAction
org.keycloak.authentication.requiredactions.UpdateEmail

View file

@ -21,6 +21,7 @@ import org.keycloak.authentication.AuthenticationFlowCallback;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.AuthenticationFlowException;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
@ -33,7 +34,7 @@ public class CustomAuthenticationFlowCallback implements AuthenticationFlowCallb
public static final String EXPECTED_ERROR_MESSAGE = "Custom Authentication Flow Callback message";
@Override
public void onTopFlowSuccess() {
public void onTopFlowSuccess(AuthenticationFlowModel topFlow) {
throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, "detail", EXPECTED_ERROR_MESSAGE);
}

View file

@ -0,0 +1,173 @@
/*
* Copyright 2024 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.testsuite.account;
import java.io.IOException;
import java.util.List;
import java.util.function.Supplier;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.representations.account.CredentialMetadataRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.resources.account.AccountCredentialResource;
import org.keycloak.testsuite.arquillian.SuiteContext;
import org.keycloak.testsuite.broker.util.SimpleHttpDefault;
import org.keycloak.testsuite.util.TokenUtil;
/**
* Helper client for account REST API
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class AccountRestClient implements AutoCloseable {
private final SuiteContext suiteContext;
private final CloseableHttpClient httpClient;
private final Supplier<String> tokenProvider;
private final String apiVersion;
private final String realmName;
private AccountRestClient(SuiteContext suiteContext, CloseableHttpClient httpClient, Supplier<String> tokenProvider, String apiVersion, String realmName) {
this.suiteContext = suiteContext;
this.httpClient = httpClient;
this.tokenProvider = tokenProvider;
this.apiVersion = apiVersion;
this.realmName = realmName;
}
public List<AccountCredentialResource.CredentialContainer> getCredentials() {
try {
return SimpleHttpDefault.doGet(getAccountUrl("credentials"), httpClient)
.auth(tokenProvider.get()).asJson(new TypeReference<List<AccountCredentialResource.CredentialContainer>>() {
});
} catch (IOException ioe) {
throw new RuntimeException("Failed to get credentials", ioe);
}
}
public CredentialRepresentation getCredentialByUserLabel(String userLabel) {
return getCredentials().stream()
.flatMap(credentialContainer -> credentialContainer.getUserCredentialMetadatas().stream())
.map(CredentialMetadataRepresentation::getCredential)
.filter(credentialRep -> userLabel.equals(credentialRep.getUserLabel()))
.findFirst()
.orElse(null);
}
public SimpleHttp.Response removeCredential(String credentialId) {
try {
return SimpleHttpDefault
.doDelete(getAccountUrl("credentials/" + credentialId), httpClient)
.acceptJson()
.auth(tokenProvider.get())
.asResponse();
} catch (IOException ioe) {
throw new RuntimeException("Failed to delete credential", ioe);
}
}
// TODO: Other objects...
@Override
public void close() {
if (httpClient != null) {
try {
httpClient.close();
} catch (IOException ioe) {
throw new RuntimeException("Error closing httpClient", ioe);
}
}
}
private String getAccountUrl(String resource) {
String url = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + realmName + "/account";
if (apiVersion != null) {
url += "/" + apiVersion;
}
if (resource != null) {
url += "/" + resource;
}
return url;
}
public static AccountRestClientBuilder builder(SuiteContext suiteContext) {
return new AccountRestClientBuilder(suiteContext);
}
public static class AccountRestClientBuilder {
private SuiteContext suiteContext;
private CloseableHttpClient httpClient;
private Supplier<String> tokenProvider;
private String apiVersion;
private String realmName;
private AccountRestClientBuilder(SuiteContext suiteContext) {
this.suiteContext = suiteContext;
}
public AccountRestClientBuilder httpClient(CloseableHttpClient httpClient) {
this.httpClient = httpClient;
return this;
}
public AccountRestClientBuilder tokenUtil(TokenUtil tokenUtil) {
this.tokenProvider = tokenUtil::getToken;
return this;
}
public AccountRestClientBuilder accessToken(String accessToken) {
this.tokenProvider = () -> accessToken;
return this;
}
public AccountRestClientBuilder apiVersion(String apiVersion) {
this.apiVersion = apiVersion;
return this;
}
public AccountRestClientBuilder realmName(String realmName) {
this.realmName = realmName;
return this;
}
public AccountRestClient build() {
if (httpClient == null) {
httpClient = HttpClientBuilder.create().build();
}
if (realmName == null) {
realmName = "test";
}
if (tokenProvider == null) {
TokenUtil tokenUtil = new TokenUtil();
tokenProvider = tokenUtil::getToken;
}
return new AccountRestClient(suiteContext, httpClient, tokenProvider, apiVersion, realmName);
}
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright 2024 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.testsuite.pages;
import org.junit.Assert;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class DeleteCredentialPage extends AbstractPage {
@FindBy(id = "kc-accept")
private WebElement submitButton;
@FindBy(id = "kc-decline")
private WebElement cancelButton;
@FindBy(id = "kc-delete-text")
private WebElement message;
public boolean isCurrent() {
return PageUtils.getPageTitle(driver).startsWith("Delete ");
}
public void confirm() {
submitButton.click();
}
public void cancel() {
cancelButton.click();
}
public void assertCredentialInMessage(String expectedLabel) {
Assert.assertEquals("Do you want to delete " + expectedLabel + "?", message.getText());
}
@Override
public void open() {
throw new UnsupportedOperationException();
}
}

View file

@ -989,7 +989,7 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
.auth(tokenUtil.getToken())
.asResponse()) {
assertEquals(400, response.getStatus());
Assert.assertEquals("Credential type password cannot be removed", response.asJson(OAuth2ErrorRepresentation.class).getError());
Assert.assertEquals("Credential type cannot be removed", response.asJson(OAuth2ErrorRepresentation.class).getError());
}
// Remove password from the user now

View file

@ -0,0 +1,267 @@
/*
* Copyright 2024 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.testsuite.actions;
import java.util.List;
import jakarta.ws.rs.core.Response;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.authentication.requiredactions.DeleteCredentialAction;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.DeleteCredentialPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.util.UserBuilder;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class AppInitiatedActionDeleteCredentialTest extends AbstractAppInitiatedActionTest {
@Override
protected String getAiaAction() {
return DeleteCredentialAction.PROVIDER_ID;
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
testRealm.setResetPasswordAllowed(Boolean.TRUE);
}
@Page
protected LoginTotpPage loginTotpPage;
@Page
protected LoginConfigTotpPage totpPage;
@Page
protected DeleteCredentialPage deleteCredentialPage;
@Page
protected ErrorPage errorPage;
protected TimeBasedOTP totp = new TimeBasedOTP();
private String userId;
@Before
public void beforeTest() {
UserRepresentation user = UserBuilder.create()
.username("john")
.email("john@email.cz")
.firstName("John")
.lastName("Bar")
.enabled(true)
.password("password")
.totpSecret("mySecret").build();
Response response = testRealm().users().create(user);
userId = ApiUtil.getCreatedId(response);
response.close();
getCleanup().addUserId(userId);
}
@Test
public void removeTotpSuccess() throws Exception {
String credentialId = getCredentialIdByType(OTPCredentialModel.TYPE);
oauth.kcAction(getKcActionParamForDeleteCredential(credentialId));
loginPasswordAndOtp();
deleteCredentialPage.assertCurrent();
deleteCredentialPage.assertCredentialInMessage(OTPCredentialModel.TYPE);
deleteCredentialPage.confirm();
appPage.assertCurrent();
assertKcActionStatus("success");
Assert.assertNull(getCredentialIdByType(OTPCredentialModel.TYPE));
events.expect(EventType.REMOVE_TOTP)
.user(userId)
.detail(Details.CREDENTIAL_TYPE, OTPCredentialModel.TYPE)
.detail(Details.CREDENTIAL_ID, credentialId)
.detail(Details.CUSTOM_REQUIRED_ACTION, DeleteCredentialAction.PROVIDER_ID)
.assertEvent();
}
@Test
public void removeTotpCancel() throws Exception {
String credentialId = getCredentialIdByType(OTPCredentialModel.TYPE);
loginPasswordAndOtp();
appPage.assertCurrent();
events.clear();
oauth.kcAction(getKcActionParamForDeleteCredential(credentialId));
oauth.openLoginForm();
// Cancel on the confirmation page
deleteCredentialPage.assertCurrent();
deleteCredentialPage.assertCredentialInMessage(OTPCredentialModel.TYPE);
deleteCredentialPage.cancel();
appPage.assertCurrent();
Assert.assertNotNull(getCredentialIdByType(OTPCredentialModel.TYPE));
}
@Test
public void removePasswordShouldFail() throws Exception {
String credentialId = getCredentialIdByType(PasswordCredentialModel.TYPE);
loginPasswordAndOtp();
appPage.assertCurrent();
events.clear();
oauth.kcAction(getKcActionParamForDeleteCredential(credentialId));
oauth.openLoginForm();
// Cancel on the confirmation page
deleteCredentialPage.assertCurrent();
deleteCredentialPage.assertCredentialInMessage(PasswordCredentialModel.TYPE);
deleteCredentialPage.confirm();
errorPage.assertCurrent();
events.expect(EventType.CUSTOM_REQUIRED_ACTION)
.user(userId)
.detail(Details.CREDENTIAL_TYPE, PasswordCredentialModel.TYPE)
.detail(Details.CREDENTIAL_ID, credentialId)
.detail(Details.CUSTOM_REQUIRED_ACTION, DeleteCredentialAction.PROVIDER_ID)
.detail(Details.REASON, "Credential type cannot be removed")
.error(Errors.DELETE_CREDENTIAL_FAILED)
.assertEvent();
}
@Test
public void missingActionId() throws Exception {
loginPasswordAndOtp();
appPage.assertCurrent();
events.clear();
oauth.kcAction(DeleteCredentialAction.PROVIDER_ID);
oauth.openLoginForm();
events.expect(EventType.CUSTOM_REQUIRED_ACTION)
.user(userId)
.error(Errors.MISSING_CREDENTIAL_ID);
// Redirected to the application. Action will be ignored
appPage.assertCurrent();
}
@Test
public void incorrectId() throws Exception {
loginPasswordAndOtp();
appPage.assertCurrent();
events.clear();
oauth.kcAction(getKcActionParamForDeleteCredential("incorrect"));
oauth.openLoginForm();
// Redirected to the application. Action will be ignored
appPage.assertCurrent();
events.expect(EventType.CUSTOM_REQUIRED_ACTION)
.user(userId)
.detail(Details.CREDENTIAL_ID, "incorrect")
.error(Errors.CREDENTIAL_NOT_FOUND);
}
@Test
public void requiredActionByAdmin() throws Exception {
// Add required action by admin. It will be ignored as there is no credentialId
UserRepresentation user = testRealm().users().get(userId).toRepresentation();
user.setRequiredActions(List.of(DeleteCredentialAction.PROVIDER_ID));
testRealm().users().get(userId).update(user);
loginPasswordAndOtp();
appPage.assertCurrent();
events.expect(EventType.CUSTOM_REQUIRED_ACTION)
.user(userId)
.error(Errors.MISSING_CREDENTIAL_ID);
}
@Test
public void removeTotpCustomLabel() throws Exception {
String credentialId = getCredentialIdByType(OTPCredentialModel.TYPE);
testRealm().users().get(userId).setCredentialUserLabel(credentialId, "custom-otp-authenticator");
oauth.kcAction(getKcActionParamForDeleteCredential(credentialId));
loginPasswordAndOtp();
deleteCredentialPage.assertCurrent();
deleteCredentialPage.assertCredentialInMessage("custom-otp-authenticator");
deleteCredentialPage.confirm();
appPage.assertCurrent();
assertKcActionStatus("success");
Assert.assertNull(getCredentialIdByType(OTPCredentialModel.TYPE));
events.expect(EventType.REMOVE_TOTP)
.user(userId)
.detail(Details.CREDENTIAL_TYPE, OTPCredentialModel.TYPE)
.detail(Details.CREDENTIAL_ID, credentialId)
.detail(Details.CREDENTIAL_USER_LABEL, "custom-otp-authenticator")
.detail(Details.CUSTOM_REQUIRED_ACTION, DeleteCredentialAction.PROVIDER_ID)
.assertEvent();
}
private String getCredentialIdByType(String type) {
List<CredentialRepresentation> credentials = testRealm().users().get(userId).credentials();
return credentials.stream()
.filter(credential -> type.equals(credential.getType()))
.findFirst()
.map(CredentialRepresentation::getId)
.orElse(null);
}
public static String getKcActionParamForDeleteCredential(String credentialId) {
return DeleteCredentialAction.PROVIDER_ID + ":" + credentialId;
}
private void loginPasswordAndOtp() {
oauth.openLoginForm();
loginPage.login("john", "password");
loginTotpPage.assertCurrent();
loginTotpPage.login(totp.generateTOTP("mySecret"));
}
}

View file

@ -58,6 +58,7 @@ public class RequiredActionsTest extends AbstractAuthenticationTest {
addRequiredAction(expected, "VERIFY_EMAIL", "Verify Email", true, false, null);
addRequiredAction(expected, "VERIFY_PROFILE", "Verify Profile", true, false, null);
addRequiredAction(expected, "delete_account", "Delete Account", false, false, null);
addRequiredAction(expected, "delete_credential", "Delete Credential", true, false, null);
addRequiredAction(expected, "update_user_locale", "Update User Locale", true, false, null);
addRequiredAction(expected, "webauthn-register", "Webauthn Register", true, false, null);
addRequiredAction(expected, "webauthn-register-passwordless", "Webauthn Register Passwordless", true, false, null);

View file

@ -21,26 +21,36 @@ import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.AuthenticationFlow;
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticator;
import org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationExecutionModel.Requirement;
import org.keycloak.models.Constants;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@ -48,19 +58,27 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.representations.ClaimsRepresentation;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.account.AccountRestClient;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory;
import org.keycloak.testsuite.client.KeycloakTestingClient;
import org.keycloak.testsuite.pages.DeleteCredentialPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.pages.PushTheButtonPage;
import org.keycloak.testsuite.pages.SelectAuthenticatorPage;
import org.keycloak.testsuite.pages.SetupRecoveryAuthnCodesPage;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.util.OAuthClient;
@ -70,14 +88,19 @@ import org.keycloak.util.JsonSerialization;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.keycloak.common.Profile.Feature.RECOVERY_CODES;
import static org.keycloak.testsuite.actions.AppInitiatedActionDeleteCredentialTest.getKcActionParamForDeleteCredential;
/**
* Tests for Level Of Assurance conditions in authentication flow.
*
* @author <a href="mailto:sebastian.zoescher@prime-sign.com">Sebastian Zoescher</a>
*/
@EnableFeature(value = RECOVERY_CODES, skipRestart = true)
public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
private static final String FLOW_NAME = "";
@Rule
public AssertEvents events = new AssertEvents(this);
@ -87,6 +110,15 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
@Page
protected LoginTotpPage loginTotpPage;
@Page
protected LoginConfigTotpPage totpSetupPage;
@Page
protected SetupRecoveryAuthnCodesPage setupRecoveryAuthnCodesPage;
@Page
protected SelectAuthenticatorPage selectAuthenticatorPage;
private TimeBasedOTP totp = new TimeBasedOTP();
@Page
@ -95,6 +127,9 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
@Page
protected ErrorPage errorPage;
@Page
protected DeleteCredentialPage deleteCredentialPage;
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
try {
@ -109,6 +144,24 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
}
}
@Before
public void beforeTest() {
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost");
UserRepresentation userRep = user.toRepresentation();
user.remove();
userRep.setId(null);
UserBuilder.edit(userRep)
.password("password")
.totpSecret("totpSecret")
.otpEnabled();
Response response = testRealm().users().create(userRep);
Assert.assertEquals(201, response.getStatus());
response.close();
oauth.kcAction(null);
}
private String getAcrToLoaMappingForClient() throws IOException {
Map<String, Integer> acrLoaMap = new HashMap<>();
acrLoaMap.put("copper", 0);
@ -145,7 +198,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
testingClient.server(TEST_REALM_NAME)
.run(session -> FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms -> forms.clear()
// level 1 authentication
.addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
.addSubFlowExecution("level1-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
config -> {
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1");
@ -157,7 +210,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
})
// level 2 authentication
.addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
.addSubFlowExecution("level2-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
config -> {
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2");
@ -169,7 +222,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
})
// level 3 authentication
.addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
.addSubFlowExecution("level3-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
config -> {
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "3");
@ -188,6 +241,21 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
configureStepUpFlow(testingClient, maxAge1, maxAge2, maxAge3);
}
private static void configureFlowsWithRecoveryCodes(KeycloakTestingClient testingClient) {
final String newFlowAlias = "browser - Level of Authentication FLow";
testingClient.server(TEST_REALM_NAME)
.run(session -> {
FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms ->
// Remove "OTP" required execution
forms.selectFlow("level2-subflow")
.removeExecution(1)
.addAuthenticatorExecution(Requirement.ALTERNATIVE, OTPFormAuthenticatorFactory.PROVIDER_ID)
.addAuthenticatorExecution(Requirement.ALTERNATIVE, RecoveryAuthnCodesFormAuthenticatorFactory.PROVIDER_ID)
);
});
}
@After
public void after() {
BrowserFlowTest.revertFlows(testRealm(), "browser - Level of Authentication FLow");
@ -669,6 +737,172 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
}
}
@Test
public void testWithMultipleOTPCodes() throws Exception {
// Get regular authentication. Only level1 required.
oauth.openLoginForm();
// Authentication without specific LOA results in level 1 authentication
authenticateWithUsernamePassword();
TokenCtx token1 = assertLoggedInWithAcr("silver");
// Add "kc_action" for setup another OTP. Existing OTP authentication should be required. No offer for recovery-codes as they are different level
oauth.kcAction(UserModel.RequiredAction.CONFIGURE_TOTP.name());
oauth.openLoginForm();
loginTotpPage.assertCurrent();
loginTotpPage.assertOtpCredentialSelectorAvailability(false);
authenticateWithTotp();
totpSetupPage.assertCurrent();
totpSetupPage.configure(totp.generateTOTP(totpSetupPage.getTotpSecret()), "totp2-label");
events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent();
TokenCtx token2 = assertLoggedInWithAcr("gold");
// Trying to add another OTP by "kc_action". Level 2 should be required and user can choose between 2 OTP codes
oauth.kcAction(UserModel.RequiredAction.CONFIGURE_TOTP.name());
oauth.openLoginForm();
loginTotpPage.assertCurrent();
loginTotpPage.assertOtpCredentialSelectorAvailability(true);
List<String> availableOtps = loginTotpPage.getAvailableOtpCredentials();
Assert.assertNames(availableOtps, OTPFormAuthenticator.UNNAMED, "totp2-label");
// Removing 2nd OTP by account REST API with regular token. Should fail as acr=2 is required
String otpCredentialId;
try (AccountRestClient accountRestClient = AccountRestClient
.builder(suiteContext)
.accessToken(token1.accessToken)
.build()) {
otpCredentialId = accountRestClient.getCredentialByUserLabel("totp2-label").getId();
try (SimpleHttp.Response response = accountRestClient.removeCredential(otpCredentialId)) {
Assert.assertEquals(403, response.getStatus());
}
}
// Removing 2nd OTP by account REST API with level2 token. Should work as acr=2 is required
try (AccountRestClient accountRestClient = AccountRestClient
.builder(suiteContext)
.accessToken(token2.accessToken)
.build()) {
otpCredentialId = accountRestClient.getCredentialByUserLabel("totp2-label").getId();
try (SimpleHttp.Response response = accountRestClient.removeCredential(otpCredentialId)) {
Assert.assertEquals(204, response.getStatus());
}
Assert.assertNull(accountRestClient.getCredentialByUserLabel("totp2-label"));
}
}
@Test
public void testDeleteCredentialAction() throws Exception {
// Login level1
oauth.openLoginForm();
authenticateWithUsernamePassword();
TokenCtx token1 = assertLoggedInWithAcr("silver");
// Setup another OTP (requires login with existing OTP)
oauth.kcAction(UserModel.RequiredAction.CONFIGURE_TOTP.name());
oauth.openLoginForm();
authenticateWithTotp();
totpSetupPage.assertCurrent();
totpSetupPage.configure(totp.generateTOTP(totpSetupPage.getTotpSecret()), "totp2-label");
events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent();
TokenCtx token2 = assertLoggedInWithAcr("gold");
String otp2CredentialId = getCredentialIdByLabel("totp2-label");
// Delete OTP credential requires level2. Re-authentication is required (because of max_age=0 for level2 evaluated during re-authentication)
oauth.kcAction(getKcActionParamForDeleteCredential(otp2CredentialId));
oauth.openLoginForm();
loginTotpPage.assertCurrent();
authenticateWithTotp();
deleteCredentialPage.assertCurrent();
deleteCredentialPage.assertCredentialInMessage("totp2-label");
deleteCredentialPage.confirm();
events.expectRequiredAction(EventType.REMOVE_TOTP).assertEvent();
assertLoggedInWithAcr("gold");
}
@Test
public void testWithOTPAndRecoveryCodesAtLevel2() {
configureFlowsWithRecoveryCodes(testingClient);
try {
// Get regular authentication. Only level1 required.
oauth.openLoginForm();
authenticateWithUsernamePassword();
TokenCtx token1 = assertLoggedInWithAcr("silver");
// Trying to delete existing OTP. Should require authentication with this OTP
String otpCredentialId = getCredentialIdByType(OTPCredentialModel.TYPE);
oauth.kcAction(getKcActionParamForDeleteCredential(otpCredentialId));
oauth.openLoginForm();
Assert.assertEquals("Strong authentication required to continue", loginPage.getInfoMessage());
authenticateWithTotp();
deleteCredentialPage.assertCurrent();
deleteCredentialPage.assertCredentialInMessage("otp");
deleteCredentialPage.confirm();
events.expectRequiredAction(EventType.REMOVE_TOTP).assertEvent();
assertLoggedInWithAcr("gold");
// Trying to add OTP. No 2nd factor should be required as user doesn't have any
oauth.kcAction(UserModel.RequiredAction.CONFIGURE_TOTP.name());
oauth.openLoginForm();
totpSetupPage.assertCurrent();
String totp2Secret = totpSetupPage.getTotpSecret();
totpSetupPage.configure(totp.generateTOTP(totp2Secret), "totp2-label");
events.expectRequiredAction(EventType.UPDATE_TOTP).assertEvent();
assertLoggedInWithAcr("silver");
// set time offset for OTP as it is not permitted to authenticate with same OTP code multiple times
setOtpTimeOffset(TimeBasedOTP.DEFAULT_INTERVAL_SECONDS, totp);
// Add "kc_action" for setup recovery codes. OTP should be required
oauth.kcAction(UserModel.RequiredAction.CONFIGURE_RECOVERY_AUTHN_CODES.name());
oauth.openLoginForm();
loginTotpPage.assertCurrent();
loginTotpPage.login(totp.generateTOTP(totp2Secret));
setupRecoveryAuthnCodesPage.assertCurrent();
setupRecoveryAuthnCodesPage.clickSaveRecoveryAuthnCodesButton();
events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION).assertEvent();
assertLoggedInWithAcr("gold");
// Removing recovery-code credential. User required to authenticate with 2nd-factor. He can choose between OTP or recovery-codes
String recoveryCodesId = getCredentialIdByType(RecoveryAuthnCodesCredentialModel.TYPE);
oauth.kcAction(getKcActionParamForDeleteCredential(recoveryCodesId));
oauth.openLoginForm();
loginTotpPage.assertCurrent();
loginTotpPage.clickTryAnotherWayLink();
selectAuthenticatorPage.assertCurrent();
Assert.assertEquals(Arrays.asList(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION, SelectAuthenticatorPage.RECOVERY_AUTHN_CODES), selectAuthenticatorPage.getAvailableLoginMethods());
selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.AUTHENTICATOR_APPLICATION);
loginTotpPage.assertCurrent();
loginTotpPage.login(totp.generateTOTP(totp2Secret));
deleteCredentialPage.assertCurrent();
deleteCredentialPage.assertCredentialInMessage("Recovery codes");
deleteCredentialPage.confirm();
events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION).assertEvent();
assertLoggedInWithAcr("gold");
} finally {
setOtpTimeOffset(0, totp);
}
}
private String getCredentialIdByLabel(String credentialLabel) {
return ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").credentials()
.stream()
.filter(credential -> "totp2-label".equals(credential.getUserLabel()))
.map(CredentialRepresentation::getId)
.findFirst().orElseThrow(() -> new IllegalStateException("Did not found credential with label " + credentialLabel));
}
private String getCredentialIdByType(String credentialType) {
return ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").credentials()
.stream()
.filter(credential -> credentialType.equals(credential.getType()))
.map(CredentialRepresentation::getId)
.findFirst().orElseThrow(() -> new IllegalStateException("Did not found credential with OTP type on the user"));
}
public void openLoginFormWithAcrClaim(boolean essential, String... acrValues) {
openLoginFormWithAcrClaim(oauth, essential, acrValues);
@ -707,10 +941,12 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
pushTheButtonPage.submit();
}
private void assertLoggedInWithAcr(String acr) {
private TokenCtx assertLoggedInWithAcr(String acr) {
EventRepresentation loginEvent = events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent();
IDToken idToken = sendTokenRequestAndGetIDToken(loginEvent);
OAuthClient.AccessTokenResponse tokenResponse = sendTokenRequestAndGetResponse(loginEvent);
IDToken idToken = oauth.verifyIDToken(tokenResponse.getIdToken());
Assert.assertEquals(acr, idToken.getAcr());
return new TokenCtx(tokenResponse.getAccessToken(), idToken);
}
private void assertErrorPage(String expectedError) {
@ -718,4 +954,14 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
Assert.assertEquals(expectedError, errorPage.getError());
events.clear();
}
private class TokenCtx {
private String accessToken;
private IDToken idToken;
private TokenCtx(String accessToken, IDToken idToken) {
this.accessToken = accessToken;
this.idToken = idToken;
}
}
}

View file

@ -412,6 +412,7 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
testHS512KeyCreated(migrationRealm);
testHS512KeyCreated(migrationRealm2);
testClientAttributes(migrationRealm);
testDeleteCredentialActionAvailable(migrationRealm);
}
if (testLdapUseTruststoreSpiMigration) {
testLdapUseTruststoreSpiMigration(migrationRealm2);
@ -937,6 +938,8 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
for (RequiredActionProviderRepresentation action : actions) {
if (action.getAlias().equals("update_user_locale")) {
assertEquals(1000, action.getPriority());
} else if (action.getAlias().equals("delete_credential")) {
assertEquals(100, action.getPriority());
} else {
assertEquals(priority, action.getPriority());
}
@ -1282,4 +1285,15 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
.collect(Collectors.toList());
Assert.assertEquals(Collections.singletonList(client.getClientId()), clientIds);
}
private void testDeleteCredentialActionAvailable(RealmResource realm) {
RequiredActionProviderRepresentation rep = realm.flows().getRequiredAction("delete_credential");
assertNotNull(rep);
assertEquals("delete_credential", rep.getAlias());
assertEquals("delete_credential", rep.getProviderId());
assertEquals("Delete Credential", rep.getName());
assertEquals(100, rep.getPriority());
assertTrue(rep.isEnabled());
assertFalse(rep.isDefaultAction());
}
}

View file

@ -23,7 +23,7 @@ import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.testsuite.webauthn.pages.AbstractLoggedInPage;
import org.keycloak.testsuite.webauthn.pages.SigningInPage;
import org.keycloak.testsuite.pages.DeleteCredentialPage;
import java.time.LocalDateTime;
import java.util.List;
@ -78,14 +78,18 @@ public class SigningInPageUtils {
assertThat("Set up link for \"" + credentialType.getType() + "\" is visible", credentialType.isNotSetUpLabelVisible(), is(false));
}
public static void testRemoveCredential(AbstractLoggedInPage accountPage, SigningInPage.UserCredential userCredential) {
public static void testRemoveCredential(AbstractLoggedInPage accountPage, DeleteCredentialPage deleteCredentialPage, SigningInPage.UserCredential userCredential) {
int countBeforeRemove = userCredential.getCredentialType().getUserCredentialsCount();
userCredential.clickRemoveBtn();
testModalDialog(accountPage, userCredential::clickRemoveBtn, () -> {
assertThat(userCredential.isPresent(), is(true));
assertThat(userCredential.getCredentialType().getUserCredentialsCount(), is(countBeforeRemove));
});
accountPage.alert().assertSuccess();
deleteCredentialPage.assertCurrent();
deleteCredentialPage.cancel();
accountPage.assertCurrent();
assertThat(userCredential.isPresent(), is(true));
assertThat(userCredential.getCredentialType().getUserCredentialsCount(), is(countBeforeRemove));
userCredential.clickRemoveBtn();
deleteCredentialPage.assertCurrent();
deleteCredentialPage.confirm();
assertThat(userCredential.isPresent(), is(false));
assertThat(userCredential.getCredentialType().getUserCredentialsCount(), is(countBeforeRemove - 1));

View file

@ -35,6 +35,7 @@ import org.keycloak.testsuite.AbstractAuthTest;
import org.keycloak.testsuite.page.AbstractPatternFlyAlert;
import org.keycloak.testsuite.webauthn.pages.SigningInPage;
import org.keycloak.testsuite.webauthn.utils.SigningInPageUtils;
import org.keycloak.testsuite.pages.DeleteCredentialPage;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.FlowUtil;
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
@ -65,6 +66,9 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple
@Page
protected WebAuthnLoginPage webAuthnLoginPage;
@Page
private DeleteCredentialPage deleteCredentialPage;
private VirtualAuthenticatorManager webAuthnManager;
protected SigningInPage.CredentialType webAuthnCredentialType;
protected SigningInPage.CredentialType webAuthnPwdlessCredentialType;
@ -186,7 +190,7 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple
protected void testRemoveCredential(SigningInPage.UserCredential userCredential) {
AbstractPatternFlyAlert.waitUntilHidden();
SigningInPageUtils.testRemoveCredential(signingInPage, userCredential);
SigningInPageUtils.testRemoveCredential(signingInPage, deleteCredentialPage, userCredential);
}
protected SigningInPage.UserCredential getNewestUserCredential(SigningInPage.CredentialType credentialType) {

View file

@ -0,0 +1,15 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=false; section>
<#if section = "header">
${msg("deleteCredentialTitle", credentialLabel)}
<#elseif section = "form">
<div id="kc-delete-text">
${msg("deleteCredentialMessage", credentialLabel)}
</div>
<form class="form-actions" action="${url.loginAction}" method="POST">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="accept" id="kc-accept" type="submit" value="${msg("doConfirmDelete")}"/>
<input class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="cancel-aia" value="${msg("doCancel")}" id="kc-decline" type="submit" />
</form>
<div class="clearfix"></div>
</#if>
</@layout.registrationLayout>

View file

@ -35,6 +35,7 @@ loginProfileTitle=Update Account Information
loginIdpReviewProfileTitle=Update Account Information
loginTimeout=Your login attempt timed out. Login will start from the beginning.
reauthenticate=Please re-authenticate to continue
authenticateStrong=Strong authentication required to continue
oauthGrantTitle=Grant Access to {0}
oauthGrantTitleHtml={0}
oauthGrantInformation=Make sure you trust {0} by learning how {0} will handle your data.
@ -72,6 +73,9 @@ termsPlainText=Terms and conditions to be defined.
termsAcceptanceRequired=You must agree to our terms and conditions.
acceptTerms=I agree to the terms and conditions
deleteCredentialTitle=Delete {0}
deleteCredentialMessage=Do you want to delete {0}?
recaptchaFailed=Invalid Recaptcha
recaptchaNotConfigured=Recaptcha is required, but not configured
consentDenied=Consent denied.