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:
parent
897c44bd1f
commit
c427e65354
44 changed files with 1545 additions and 140 deletions
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -42,7 +42,6 @@ export { SharedWith } from "./resources/SharedWith";
|
|||
export { ShareTheResource } from "./resources/ShareTheResource";
|
||||
export {
|
||||
deleteConsent,
|
||||
deleteCredentials,
|
||||
deleteSession,
|
||||
getApplications,
|
||||
getCredentials,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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 {
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue