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";
|
} from "@patternfly/react-core/deprecated";
|
||||||
import { CSSProperties, useState } from "react";
|
import { CSSProperties, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { ContinueCancelModal, useAlerts } from "ui-shared";
|
import { getCredentials } from "../api/methods";
|
||||||
import { deleteCredentials, getCredentials } from "../api/methods";
|
|
||||||
import {
|
import {
|
||||||
CredentialContainer,
|
CredentialContainer,
|
||||||
CredentialMetadataRepresentation,
|
CredentialMetadataRepresentation,
|
||||||
CredentialRepresentation,
|
|
||||||
} from "../api/representations";
|
} from "../api/representations";
|
||||||
import { EmptyRow } from "../components/datalist/EmptyRow";
|
import { EmptyRow } from "../components/datalist/EmptyRow";
|
||||||
import { Page } from "../components/page/Page";
|
import { Page } from "../components/page/Page";
|
||||||
|
@ -70,16 +68,15 @@ const MobileLink = ({ title, onClick, testid }: MobileLinkProps) => {
|
||||||
export const SigningIn = () => {
|
export const SigningIn = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const context = useEnvironment();
|
const context = useEnvironment();
|
||||||
const { addAlert, addError } = useAlerts();
|
|
||||||
const { login } = context.keycloak;
|
const { login } = context.keycloak;
|
||||||
|
|
||||||
const [credentials, setCredentials] = useState<CredentialContainer[]>();
|
const [credentials, setCredentials] = useState<CredentialContainer[]>();
|
||||||
const [key, setKey] = useState(1);
|
|
||||||
const refresh = () => setKey(key + 1);
|
|
||||||
|
|
||||||
usePromise((signal) => getCredentials({ signal, context }), setCredentials, [
|
usePromise(
|
||||||
key,
|
(signal) => getCredentials({ signal, context }),
|
||||||
]);
|
setCredentials,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const credentialRowCells = (
|
const credentialRowCells = (
|
||||||
credMetadata: CredentialMetadataRepresentation,
|
credMetadata: CredentialMetadataRepresentation,
|
||||||
|
@ -115,9 +112,6 @@ export const SigningIn = () => {
|
||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
const label = (credential: CredentialRepresentation) =>
|
|
||||||
credential.userLabel || t(credential.type as TFuncKey);
|
|
||||||
|
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
}
|
}
|
||||||
|
@ -205,41 +199,19 @@ export const SigningIn = () => {
|
||||||
aria-labelledby={`cred-${meta.credential.id}`}
|
aria-labelledby={`cred-${meta.credential.id}`}
|
||||||
>
|
>
|
||||||
{container.removeable ? (
|
{container.removeable ? (
|
||||||
<ContinueCancelModal
|
<Button
|
||||||
buttonTitle={t("delete")}
|
variant="danger"
|
||||||
buttonTestRole="remove"
|
data-testrole="remove"
|
||||||
modalTitle={t("removeCred", {
|
onClick={() => {
|
||||||
name: label(meta.credential),
|
login({
|
||||||
})}
|
action:
|
||||||
continueLabel={t("confirm")}
|
"delete_credential:" +
|
||||||
cancelLabel={t("cancel")}
|
meta.credential.id,
|
||||||
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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("stopUsingCred", {
|
{t("delete")}
|
||||||
name: label(meta.credential),
|
</Button>
|
||||||
})}
|
|
||||||
</ContinueCancelModal>
|
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { parseResponse } from "./parse-response";
|
||||||
import {
|
import {
|
||||||
ClientRepresentation,
|
ClientRepresentation,
|
||||||
CredentialContainer,
|
CredentialContainer,
|
||||||
CredentialRepresentation,
|
|
||||||
DeviceRepresentation,
|
DeviceRepresentation,
|
||||||
Group,
|
Group,
|
||||||
LinkedAccountRepresentation,
|
LinkedAccountRepresentation,
|
||||||
|
@ -99,15 +98,6 @@ export async function getCredentials({ signal, context }: CallOptions) {
|
||||||
return parseResponse<CredentialContainer[]>(response);
|
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) {
|
export async function getLinkedAccounts({ signal, context }: CallOptions) {
|
||||||
const response = await request("/linked-accounts", context, { signal });
|
const response = await request("/linked-accounts", context, { signal });
|
||||||
return parseResponse<LinkedAccountRepresentation[]>(response);
|
return parseResponse<LinkedAccountRepresentation[]>(response);
|
||||||
|
|
|
@ -42,7 +42,6 @@ export { SharedWith } from "./resources/SharedWith";
|
||||||
export { ShareTheResource } from "./resources/ShareTheResource";
|
export { ShareTheResource } from "./resources/ShareTheResource";
|
||||||
export {
|
export {
|
||||||
deleteConsent,
|
deleteConsent,
|
||||||
deleteCredentials,
|
|
||||||
deleteSession,
|
deleteSession,
|
||||||
getApplications,
|
getApplications,
|
||||||
getCredentials,
|
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.MigrateTo22_0_0;
|
||||||
import org.keycloak.migration.migrators.MigrateTo23_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_0;
|
||||||
|
import org.keycloak.migration.migrators.MigrateTo24_0_3;
|
||||||
import org.keycloak.migration.migrators.MigrateTo25_0_0;
|
import org.keycloak.migration.migrators.MigrateTo25_0_0;
|
||||||
import org.keycloak.migration.migrators.MigrateTo2_0_0;
|
import org.keycloak.migration.migrators.MigrateTo2_0_0;
|
||||||
import org.keycloak.migration.migrators.MigrateTo2_1_0;
|
import org.keycloak.migration.migrators.MigrateTo2_1_0;
|
||||||
|
@ -115,6 +116,7 @@ public class DefaultMigrationManager implements MigrationManager {
|
||||||
new MigrateTo22_0_0(),
|
new MigrateTo22_0_0(),
|
||||||
new MigrateTo23_0_0(),
|
new MigrateTo23_0_0(),
|
||||||
new MigrateTo24_0_0(),
|
new MigrateTo24_0_0(),
|
||||||
|
new MigrateTo24_0_3(),
|
||||||
new MigrateTo25_0_0()
|
new MigrateTo25_0_0()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ import org.keycloak.http.HttpRequest;
|
||||||
import org.keycloak.common.ClientConnection;
|
import org.keycloak.common.ClientConnection;
|
||||||
import org.keycloak.events.EventBuilder;
|
import org.keycloak.events.EventBuilder;
|
||||||
import org.keycloak.models.AuthenticationExecutionModel;
|
import org.keycloak.models.AuthenticationExecutionModel;
|
||||||
|
import org.keycloak.models.AuthenticationFlowModel;
|
||||||
import org.keycloak.models.AuthenticatorConfigModel;
|
import org.keycloak.models.AuthenticatorConfigModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
@ -57,6 +58,11 @@ public interface AbstractAuthenticationFlowContext {
|
||||||
*/
|
*/
|
||||||
AuthenticationExecutionModel getExecution();
|
AuthenticationExecutionModel getExecution();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the top level flow (root flow) of this authentication
|
||||||
|
*/
|
||||||
|
AuthenticationFlowModel getTopLevelFlow();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current realm
|
* Current realm
|
||||||
*
|
*
|
||||||
|
|
|
@ -18,6 +18,8 @@
|
||||||
|
|
||||||
package org.keycloak.authentication;
|
package org.keycloak.authentication;
|
||||||
|
|
||||||
|
import org.keycloak.models.AuthenticationFlowModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback to be triggered during various lifecycle events of authentication flow.
|
* 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.
|
* Triggered after the top authentication flow is successfully finished.
|
||||||
* It is really suitable for last verification of successful authentication
|
* 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;
|
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() {
|
default InitiatedActionSupport initiatedActionSupport() {
|
||||||
return InitiatedActionSupport.NOT_SUPPORTED;
|
return InitiatedActionSupport.NOT_SUPPORTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback to let the action know that an application-initiated action
|
* Callback to let the action know that an application-initiated action
|
||||||
* was canceled.
|
* was canceled.
|
||||||
*
|
*
|
||||||
* @param session The Keycloak session.
|
* @param session The Keycloak session.
|
||||||
* @param authSession The authentication session.
|
* @param authSession The authentication session.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
default void initiatedActionCanceled(KeycloakSession session, AuthenticationSessionModel authSession) {
|
default void initiatedActionCanceled(KeycloakSession session, AuthenticationSessionModel authSession) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -89,6 +89,7 @@ public interface Details {
|
||||||
|
|
||||||
String CREDENTIAL_TYPE = "credential_type";
|
String CREDENTIAL_TYPE = "credential_type";
|
||||||
String SELECTED_CREDENTIAL_ID = "selected_credential_id";
|
String SELECTED_CREDENTIAL_ID = "selected_credential_id";
|
||||||
|
String CREDENTIAL_ID = "credential_id";
|
||||||
String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail";
|
String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail";
|
||||||
String CREDENTIAL_USER_LABEL = "credential_user_label";
|
String CREDENTIAL_USER_LABEL = "credential_user_label";
|
||||||
|
|
||||||
|
|
|
@ -120,4 +120,8 @@ public interface Errors {
|
||||||
String SLOW_DOWN = "slow_down";
|
String SLOW_DOWN = "slow_down";
|
||||||
String GENERIC_AUTHENTICATION_ERROR= "generic_authentication_error";
|
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 KEY = "key";
|
||||||
|
|
||||||
public static final String KC_ACTION = "kc_action";
|
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_STATUS = "kc_action_status";
|
||||||
public static final String KC_ACTION_EXECUTING = "kc_action_executing";
|
public static final String KC_ACTION_EXECUTING = "kc_action_executing";
|
||||||
public static final int KC_ACTION_MAX_AGE = 300;
|
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),
|
UPDATE_PASSWORD(UserModel.RequiredAction.UPDATE_PASSWORD.name(), DefaultRequiredActions::addUpdatePasswordAction),
|
||||||
TERMS_AND_CONDITIONS(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name(), DefaultRequiredActions::addTermsAndConditionsAction),
|
TERMS_AND_CONDITIONS(UserModel.RequiredAction.TERMS_AND_CONDITIONS.name(), DefaultRequiredActions::addTermsAndConditionsAction),
|
||||||
DELETE_ACCOUNT("delete_account", DefaultRequiredActions::addDeleteAccountAction),
|
DELETE_ACCOUNT("delete_account", DefaultRequiredActions::addDeleteAccountAction),
|
||||||
|
DELETE_CREDENTIAL("delete_credential", DefaultRequiredActions::addDeleteCredentialAction),
|
||||||
UPDATE_USER_LOCALE("update_user_locale", DefaultRequiredActions::addUpdateLocaleAction),
|
UPDATE_USER_LOCALE("update_user_locale", DefaultRequiredActions::addUpdateLocaleAction),
|
||||||
UPDATE_EMAIL(UserModel.RequiredAction.UPDATE_EMAIL.name(), DefaultRequiredActions::addUpdateEmailAction, () -> isFeatureEnabled(Profile.Feature.UPDATE_EMAIL)),
|
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)),
|
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) {
|
public static void addUpdateLocaleAction(RealmModel realm) {
|
||||||
if (realm.getRequiredActionProviderByAlias("update_user_locale") == null) {
|
if (realm.getRequiredActionProviderByAlias("update_user_locale") == null) {
|
||||||
RequiredActionProviderModel updateUserLocale = new RequiredActionProviderModel();
|
RequiredActionProviderModel updateUserLocale = new RequiredActionProviderModel();
|
||||||
|
|
|
@ -357,6 +357,11 @@ public class AuthenticationProcessor {
|
||||||
return execution;
|
return execution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AuthenticationFlowModel getTopLevelFlow() {
|
||||||
|
return AuthenticatorUtil.getTopParentFlow(realm, execution);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AuthenticatorConfigModel getAuthenticatorConfig() {
|
public AuthenticatorConfigModel getAuthenticatorConfig() {
|
||||||
if (execution.getAuthenticatorConfig() == null) return null;
|
if (execution.getAuthenticatorConfig() == null) return null;
|
||||||
|
|
|
@ -24,8 +24,12 @@ import org.keycloak.common.ClientConnection;
|
||||||
import org.keycloak.events.Details;
|
import org.keycloak.events.Details;
|
||||||
import org.keycloak.events.EventBuilder;
|
import org.keycloak.events.EventBuilder;
|
||||||
import org.keycloak.events.EventType;
|
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.http.HttpRequest;
|
||||||
import org.keycloak.models.AuthenticationExecutionModel;
|
import org.keycloak.models.AuthenticationExecutionModel;
|
||||||
|
import org.keycloak.models.AuthenticationFlowModel;
|
||||||
import org.keycloak.models.Constants;
|
import org.keycloak.models.Constants;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
@ -41,6 +45,7 @@ import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
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.FORCED_REAUTHENTICATION;
|
||||||
import static org.keycloak.services.managers.AuthenticationManager.SSO_AUTH;
|
import static org.keycloak.services.managers.AuthenticationManager.SSO_AUTH;
|
||||||
|
@ -132,6 +137,29 @@ public class AuthenticatorUtil {
|
||||||
return executions;
|
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
|
* Logouts all sessions that are different to the current authentication session
|
||||||
* managed in the action context.
|
* managed in the action context.
|
||||||
|
@ -174,4 +202,14 @@ public class AuthenticatorUtil {
|
||||||
.success();
|
.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(Objects::nonNull)
|
||||||
.filter(AuthenticationFlowCallback.class::isInstance)
|
.filter(AuthenticationFlowCallback.class::isInstance)
|
||||||
.map(AuthenticationFlowCallback.class::cast)
|
.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());
|
LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, authSession.getProtocol());
|
||||||
authSession.setAuthNote(Constants.LOA_MAP, authResult.getSession().getNote(Constants.LOA_MAP));
|
authSession.setAuthNote(Constants.LOA_MAP, authResult.getSession().getNote(Constants.LOA_MAP));
|
||||||
context.setUser(authResult.getUser());
|
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
|
// Cookie re-authentication is skipped if re-authentication is required
|
||||||
if (protocol.requireReauthentication(authResult.getSession(), authSession)) {
|
if (protocol.requireReauthentication(authResult.getSession(), authSession)) {
|
||||||
|
@ -65,10 +65,15 @@ public class CookieAuthenticator implements Authenticator {
|
||||||
int previouslyAuthenticatedLevel = acrStore.getHighestAuthenticatedLevelFromPreviousAuthentication();
|
int previouslyAuthenticatedLevel = acrStore.getHighestAuthenticatedLevelFromPreviousAuthentication();
|
||||||
AuthenticatorUtils.updateCompletedExecutions(context.getAuthenticationSession(), authResult.getSession(), context.getExecution().getId());
|
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.
|
// Step-up authentication, we keep the loa from the existing user session.
|
||||||
// The cookie alone is not enough and other authentications must follow.
|
// The cookie alone is not enough and other authentications must follow.
|
||||||
acrStore.setLevelAuthenticatedToCurrentRequest(previouslyAuthenticatedLevel);
|
acrStore.setLevelAuthenticatedToCurrentRequest(previouslyAuthenticatedLevel);
|
||||||
|
|
||||||
|
if (authSession.getClientNote(Constants.KC_ACTION) != null) {
|
||||||
|
context.setForwardedInfoMessage(Messages.AUTHENTICATE_STRONG);
|
||||||
|
}
|
||||||
|
|
||||||
context.attempted();
|
context.attempted();
|
||||||
} else {
|
} else {
|
||||||
// Cookie only authentication
|
// Cookie only authentication
|
||||||
|
|
|
@ -22,9 +22,9 @@ import org.keycloak.authentication.AuthenticationFlowCallback;
|
||||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||||
import org.keycloak.authentication.AuthenticationFlowError;
|
import org.keycloak.authentication.AuthenticationFlowError;
|
||||||
import org.keycloak.authentication.AuthenticationFlowException;
|
import org.keycloak.authentication.AuthenticationFlowException;
|
||||||
import org.keycloak.authentication.AuthenticatorUtil;
|
|
||||||
import org.keycloak.authentication.authenticators.util.AcrStore;
|
import org.keycloak.authentication.authenticators.util.AcrStore;
|
||||||
import org.keycloak.authentication.authenticators.util.LoAUtil;
|
import org.keycloak.authentication.authenticators.util.LoAUtil;
|
||||||
|
import org.keycloak.models.AuthenticationFlowModel;
|
||||||
import org.keycloak.models.Constants;
|
import org.keycloak.models.Constants;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
@ -52,11 +52,11 @@ public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, Au
|
||||||
@Override
|
@Override
|
||||||
public boolean matchCondition(AuthenticationFlowContext context) {
|
public boolean matchCondition(AuthenticationFlowContext context) {
|
||||||
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
||||||
AcrStore acrStore = new AcrStore(authSession);
|
AcrStore acrStore = new AcrStore(context.getSession(), authSession);
|
||||||
int currentAuthenticationLoa = acrStore.getLevelOfAuthenticationFromCurrentAuthentication();
|
int currentAuthenticationLoa = acrStore.getLevelOfAuthenticationFromCurrentAuthentication();
|
||||||
Integer configuredLoa = getConfiguredLoa(context);
|
Integer configuredLoa = getConfiguredLoa(context);
|
||||||
if (configuredLoa == null) configuredLoa = Constants.MINIMUM_LOA;
|
if (configuredLoa == null) configuredLoa = Constants.MINIMUM_LOA;
|
||||||
int requestedLoa = acrStore.getRequestedLevelOfAuthentication();
|
int requestedLoa = acrStore.getRequestedLevelOfAuthentication(context.getTopLevelFlow());
|
||||||
if (currentAuthenticationLoa < Constants.MINIMUM_LOA) {
|
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",
|
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);
|
context.getAuthenticatorConfig().getAlias(), configuredLoa, requestedLoa);
|
||||||
|
@ -84,7 +84,7 @@ public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, Au
|
||||||
@Override
|
@Override
|
||||||
public void onParentFlowSuccess(AuthenticationFlowContext context) {
|
public void onParentFlowSuccess(AuthenticationFlowContext context) {
|
||||||
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
||||||
AcrStore acrStore = new AcrStore(authSession);
|
AcrStore acrStore = new AcrStore(context.getSession(), authSession);
|
||||||
|
|
||||||
Integer newLoa = getConfiguredLoa(context);
|
Integer newLoa = getConfiguredLoa(context);
|
||||||
if (newLoa == null) {
|
if (newLoa == null) {
|
||||||
|
@ -102,14 +102,14 @@ public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, Au
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onTopFlowSuccess() {
|
public void onTopFlowSuccess(AuthenticationFlowModel topFlow) {
|
||||||
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
|
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());
|
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",
|
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);
|
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.io.IOException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.TreeMap;
|
import java.util.TreeMap;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.keycloak.authentication.AuthenticatorUtil;
|
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.common.util.Time;
|
||||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||||
|
import org.keycloak.models.AuthenticationFlowModel;
|
||||||
import org.keycloak.models.Constants;
|
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.sessions.AuthenticationSessionModel;
|
||||||
import org.keycloak.util.JsonSerialization;
|
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
|
* 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 static final Logger logger = Logger.getLogger(AcrStore.class);
|
||||||
|
|
||||||
|
private final KeycloakSession session;
|
||||||
private final AuthenticationSessionModel authSession;
|
private final AuthenticationSessionModel authSession;
|
||||||
|
|
||||||
public AcrStore(AuthenticationSessionModel authSession) {
|
public AcrStore(KeycloakSession session, AuthenticationSessionModel authSession) {
|
||||||
|
this.session = session;
|
||||||
this.authSession = authSession;
|
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);
|
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() {
|
Integer credentialTypeLevel = credentialTypesToLoa.get(credentialType);
|
||||||
return getRequestedLevelOfAuthentication()
|
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();
|
<= getAuthenticatedLevelCurrentAuthentication();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) {
|
public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) {
|
||||||
String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION);
|
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() {
|
public int getLevelOfAuthenticationFromCurrentAuthentication() {
|
||||||
String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION);
|
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() {
|
private int getAuthenticatedLevelCurrentAuthentication() {
|
||||||
String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION);
|
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() {
|
public int getHighestAuthenticatedLevelFromPreviousAuthentication() {
|
||||||
// No map found. User was not yet authenticated in this session
|
// No map found. User was not yet authenticated in this session
|
||||||
Map<Integer, Integer> levels = getCurrentAuthenticatedLevelsMap();
|
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
|
// 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;
|
int maxLevel = Constants.MINIMUM_LOA;
|
||||||
|
|
|
@ -19,21 +19,33 @@
|
||||||
package org.keycloak.authentication.authenticators.util;
|
package org.keycloak.authentication.authenticators.util;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
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.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.authentication.Authenticator;
|
||||||
|
import org.keycloak.authentication.AuthenticatorFactory;
|
||||||
import org.keycloak.authentication.AuthenticatorUtil;
|
import org.keycloak.authentication.AuthenticatorUtil;
|
||||||
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
|
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
|
||||||
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
|
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
|
||||||
|
import org.keycloak.credential.CredentialProvider;
|
||||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||||
import org.keycloak.models.AuthenticationExecutionModel;
|
import org.keycloak.models.AuthenticationExecutionModel;
|
||||||
|
import org.keycloak.models.AuthenticationFlowModel;
|
||||||
import org.keycloak.models.AuthenticatorConfigModel;
|
import org.keycloak.models.AuthenticatorConfigModel;
|
||||||
import org.keycloak.models.Constants;
|
import org.keycloak.models.Constants;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
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>
|
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||||
|
@ -48,7 +60,7 @@ public class LoAUtil {
|
||||||
*/
|
*/
|
||||||
public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) {
|
public static int getCurrentLevelOfAuthentication(AuthenticatedClientSessionModel clientSession) {
|
||||||
String clientSessionLoaNote = clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION);
|
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 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 java.util.List;
|
||||||
import org.keycloak.Config;
|
import org.keycloak.Config;
|
||||||
import org.keycloak.authentication.AuthenticatorUtil;
|
import org.keycloak.authentication.AuthenticatorUtil;
|
||||||
|
import org.keycloak.authentication.CredentialRegistrator;
|
||||||
import org.keycloak.authentication.InitiatedActionSupport;
|
import org.keycloak.authentication.InitiatedActionSupport;
|
||||||
import org.keycloak.authentication.RequiredActionContext;
|
import org.keycloak.authentication.RequiredActionContext;
|
||||||
import org.keycloak.authentication.RequiredActionFactory;
|
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.MultivaluedMap;
|
||||||
import jakarta.ws.rs.core.Response;
|
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_RECOVERY_AUTHN_CODES_HIDDEN = "generatedRecoveryAuthnCodes";
|
||||||
private static final String FIELD_GENERATED_AT_HIDDEN = "generatedAt";
|
private static final String FIELD_GENERATED_AT_HIDDEN = "generatedAt";
|
||||||
|
@ -35,6 +37,11 @@ public class RecoveryAuthnCodesAction implements RequiredActionProvider, Require
|
||||||
return PROVIDER_ID;
|
return PROVIDER_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
|
||||||
|
return RecoveryAuthnCodesCredentialModel.TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getDisplayText() {
|
public String getDisplayText() {
|
||||||
return "Recovery Authentication Codes";
|
return "Recovery Authentication Codes";
|
||||||
|
|
|
@ -39,6 +39,7 @@ import org.keycloak.models.utils.CredentialValidation;
|
||||||
import org.keycloak.models.utils.FormMessage;
|
import org.keycloak.models.utils.FormMessage;
|
||||||
import org.keycloak.services.messages.Messages;
|
import org.keycloak.services.messages.Messages;
|
||||||
import org.keycloak.services.validation.Validation;
|
import org.keycloak.services.validation.Validation;
|
||||||
|
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||||
import org.keycloak.utils.CredentialHelper;
|
import org.keycloak.utils.CredentialHelper;
|
||||||
|
|
||||||
import jakarta.ws.rs.core.MultivaluedMap;
|
import jakarta.ws.rs.core.MultivaluedMap;
|
||||||
|
@ -161,6 +162,11 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
|
||||||
return UserModel.RequiredAction.CONFIGURE_TOTP.name();
|
return UserModel.RequiredAction.CONFIGURE_TOTP.name();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
|
||||||
|
return OTPCredentialModel.TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isOneTimeAction() {
|
public boolean isOneTimeAction() {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -54,6 +54,7 @@ import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.models.WebAuthnPolicy;
|
import org.keycloak.models.WebAuthnPolicy;
|
||||||
import org.keycloak.models.credential.WebAuthnCredentialModel;
|
import org.keycloak.models.credential.WebAuthnCredentialModel;
|
||||||
|
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||||
import org.keycloak.utils.StringUtil;
|
import org.keycloak.utils.StringUtil;
|
||||||
|
|
||||||
import com.webauthn4j.converter.util.ObjectConverter;
|
import com.webauthn4j.converter.util.ObjectConverter;
|
||||||
|
@ -170,6 +171,11 @@ public class WebAuthnRegister implements RequiredActionProvider, CredentialRegis
|
||||||
return context.getRealm().getWebAuthnPolicy();
|
return context.getRealm().getWebAuthnPolicy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCredentialType(KeycloakSession session, AuthenticationSessionModel authenticationSession) {
|
||||||
|
return getCredentialType();
|
||||||
|
}
|
||||||
|
|
||||||
protected String getCredentialType() {
|
protected String getCredentialType() {
|
||||||
return WebAuthnCredentialModel.TYPE_TWOFACTOR;
|
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());
|
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());
|
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")
|
// 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) {
|
public static void performActionOnParameters(AuthorizationEndpointRequest request, BiConsumer<String, String> paramAction) {
|
||||||
paramAction.accept(AdapterConstants.KC_IDP_HINT, request.getIdpHint());
|
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(OAuth2Constants.DISPLAY, request.getDisplay());
|
||||||
paramAction.accept(OIDCLoginProtocol.ACR_PARAM, request.getAcr());
|
paramAction.accept(OIDCLoginProtocol.ACR_PARAM, request.getAcr());
|
||||||
paramAction.accept(OIDCLoginProtocol.CLAIMS_PARAM, request.getClaims());
|
paramAction.accept(OIDCLoginProtocol.CLAIMS_PARAM, request.getClaims());
|
||||||
|
|
|
@ -102,7 +102,7 @@ public class AcrUtils {
|
||||||
try {
|
try {
|
||||||
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
|
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
|
||||||
} catch (IOException e) {
|
} 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();
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -119,7 +119,7 @@ public class AcrUtils {
|
||||||
try {
|
try {
|
||||||
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
|
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
|
||||||
} catch (IOException e) {
|
} 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();
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ public class Messages {
|
||||||
public static final String LOGIN_TIMEOUT = "loginTimeout";
|
public static final String LOGIN_TIMEOUT = "loginTimeout";
|
||||||
|
|
||||||
public static final String REAUTHENTICATE = "reauthenticate";
|
public static final String REAUTHENTICATE = "reauthenticate";
|
||||||
|
public static final String AUTHENTICATE_STRONG = "authenticateStrong";
|
||||||
|
|
||||||
public static final String INVALID_USER = "invalidUserMessage";
|
public static final String INVALID_USER = "invalidUserMessage";
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
package org.keycloak.services.resources.account;
|
package org.keycloak.services.resources.account;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.ws.rs.ForbiddenException;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
import org.jboss.resteasy.reactive.NoCache;
|
import org.jboss.resteasy.reactive.NoCache;
|
||||||
import org.keycloak.authentication.Authenticator;
|
import org.keycloak.authentication.Authenticator;
|
||||||
import org.keycloak.authentication.AuthenticatorFactory;
|
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.CredentialMetadata;
|
||||||
import org.keycloak.credential.CredentialModel;
|
import org.keycloak.credential.CredentialModel;
|
||||||
import org.keycloak.credential.CredentialProvider;
|
import org.keycloak.credential.CredentialProvider;
|
||||||
import org.keycloak.credential.CredentialProviderFactory;
|
|
||||||
import org.keycloak.credential.CredentialTypeMetadata;
|
import org.keycloak.credential.CredentialTypeMetadata;
|
||||||
import org.keycloak.credential.CredentialTypeMetadataContext;
|
import org.keycloak.credential.CredentialTypeMetadataContext;
|
||||||
import org.keycloak.events.Details;
|
import org.keycloak.events.Details;
|
||||||
|
@ -17,11 +19,13 @@ import org.keycloak.events.EventType;
|
||||||
import org.keycloak.models.AccountRoles;
|
import org.keycloak.models.AccountRoles;
|
||||||
import org.keycloak.models.AuthenticationExecutionModel;
|
import org.keycloak.models.AuthenticationExecutionModel;
|
||||||
import org.keycloak.models.AuthenticationFlowModel;
|
import org.keycloak.models.AuthenticationFlowModel;
|
||||||
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.models.credential.OTPCredentialModel;
|
import org.keycloak.models.credential.OTPCredentialModel;
|
||||||
import org.keycloak.models.utils.ModelToRepresentation;
|
import org.keycloak.models.utils.ModelToRepresentation;
|
||||||
|
import org.keycloak.protocol.oidc.utils.AcrUtils;
|
||||||
import org.keycloak.representations.account.CredentialMetadataRepresentation;
|
import org.keycloak.representations.account.CredentialMetadataRepresentation;
|
||||||
import org.keycloak.representations.idm.CredentialRepresentation;
|
import org.keycloak.representations.idm.CredentialRepresentation;
|
||||||
import org.keycloak.services.ErrorResponse;
|
import org.keycloak.services.ErrorResponse;
|
||||||
|
@ -30,7 +34,6 @@ import org.keycloak.services.messages.Messages;
|
||||||
import org.keycloak.util.JsonSerialization;
|
import org.keycloak.util.JsonSerialization;
|
||||||
import org.keycloak.utils.MediaType;
|
import org.keycloak.utils.MediaType;
|
||||||
|
|
||||||
import jakarta.ws.rs.BadRequestException;
|
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.DELETE;
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
@ -45,6 +48,7 @@ import java.io.IOException;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
@ -60,6 +64,8 @@ public class AccountCredentialResource {
|
||||||
public static final String TYPE = "type";
|
public static final String TYPE = "type";
|
||||||
public static final String USER_CREDENTIALS = "user-credentials";
|
public static final String USER_CREDENTIALS = "user-credentials";
|
||||||
|
|
||||||
|
private static final Logger logger = Logger.getLogger(AccountCredentialResource.class);
|
||||||
|
|
||||||
|
|
||||||
private final KeycloakSession session;
|
private final KeycloakSession session;
|
||||||
private final UserModel user;
|
private final UserModel user;
|
||||||
|
@ -221,7 +227,7 @@ public class AccountCredentialResource {
|
||||||
return new CredentialContainer(metadata, userCredentialMetadataModels);
|
return new CredentialContainer(metadata, userCredentialMetadataModels);
|
||||||
};
|
};
|
||||||
|
|
||||||
return getCredentialProviders()
|
return AuthenticatorUtil.getCredentialProviders(session)
|
||||||
.filter(p -> type == null || Objects.equals(p.getType(), type))
|
.filter(p -> type == null || Objects.equals(p.getType(), type))
|
||||||
.filter(p -> enabledCredentialTypes.contains(p.getType()))
|
.filter(p -> enabledCredentialTypes.contains(p.getType()))
|
||||||
.map(toCredentialContainer)
|
.map(toCredentialContainer)
|
||||||
|
@ -229,12 +235,6 @@ public class AccountCredentialResource {
|
||||||
.sorted(Comparator.comparing(CredentialContainer::getMetadata));
|
.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
|
// Going through all authentication flows and their authentication executions to see if there is any authenticator of the corresponding
|
||||||
// credential type.
|
// credential type.
|
||||||
private Set<String> getEnabledCredentialTypes() {
|
private Set<String> getEnabledCredentialTypes() {
|
||||||
|
@ -267,18 +267,25 @@ public class AccountCredentialResource {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkIfCanBeRemoved(String credentialType) {
|
private Integer getCurrentAuthenticatedLevel() {
|
||||||
Set<String> enabledCredentialTypes = getEnabledCredentialTypes();
|
ClientModel client = realm.getClientByClientId(auth.getToken().getIssuedFor());
|
||||||
CredentialProvider credentialProvider = getCredentialProviders()
|
Map<String, Integer> acrLoaMap = AcrUtils.getAcrLoaMap(client);
|
||||||
.filter(p -> credentialType.equals(p.getType()) && enabledCredentialTypes.contains(p.getType()))
|
String tokenAcr = auth.getToken().getAcr();
|
||||||
.findAny().orElse(null);
|
if (tokenAcr == null) {
|
||||||
if (credentialProvider == null) {
|
logger.warnf("Not able to remove credential of user '%s' as no acr claim on the token", user.getUsername());
|
||||||
throw new NotFoundException("Credential provider " + credentialType + " not found");
|
throw new ForbiddenException("No LoA on the token");
|
||||||
}
|
}
|
||||||
CredentialTypeMetadataContext ctx = CredentialTypeMetadataContext.builder().user(user).build(session);
|
Integer currentAuthenticatedLevel = acrLoaMap.get(tokenAcr);
|
||||||
CredentialTypeMetadata metadata = credentialProvider.getCredentialTypeMetadata(ctx);
|
if (currentAuthenticatedLevel != null) {
|
||||||
if (!metadata.isRemoveable()) {
|
return currentAuthenticatedLevel;
|
||||||
throw new BadRequestException("Credential type " + credentialType + " cannot be removed");
|
} 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
|
* Remove a credential of current user
|
||||||
*
|
*
|
||||||
* @param credentialId ID of the credential, which will be removed
|
* @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}")
|
@Path("{credentialId}")
|
||||||
@DELETE
|
@DELETE
|
||||||
@NoCache
|
@NoCache
|
||||||
|
@Deprecated
|
||||||
public void removeCredential(final @PathParam("credentialId") String credentialId) {
|
public void removeCredential(final @PathParam("credentialId") String credentialId) {
|
||||||
auth.require(AccountRoles.MANAGE_ACCOUNT);
|
auth.require(AccountRoles.MANAGE_ACCOUNT);
|
||||||
CredentialModel credential = user.credentialManager().getStoredCredentialById(credentialId);
|
CredentialModel credential = CredentialDeleteHelper.removeCredential(session, user, credentialId, this::getCurrentAuthenticatedLevel);
|
||||||
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);
|
|
||||||
|
|
||||||
if (OTPCredentialModel.TYPE.equals(credential.getType())) {
|
if (credential != null && OTPCredentialModel.TYPE.equals(credential.getType())) {
|
||||||
event.event(EventType.REMOVE_TOTP)
|
event.event(EventType.REMOVE_TOTP)
|
||||||
.detail(Details.SELECTED_CREDENTIAL_ID, credentialId)
|
.detail(Details.SELECTED_CREDENTIAL_ID, credentialId)
|
||||||
.detail(Details.CREDENTIAL_USER_LABEL, credential.getUserLabel());
|
.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.WebAuthnPasswordlessRegisterFactory
|
||||||
org.keycloak.authentication.requiredactions.UpdateUserLocaleAction
|
org.keycloak.authentication.requiredactions.UpdateUserLocaleAction
|
||||||
org.keycloak.authentication.requiredactions.DeleteAccount
|
org.keycloak.authentication.requiredactions.DeleteAccount
|
||||||
|
org.keycloak.authentication.requiredactions.DeleteCredentialAction
|
||||||
org.keycloak.authentication.requiredactions.VerifyUserProfile
|
org.keycloak.authentication.requiredactions.VerifyUserProfile
|
||||||
org.keycloak.authentication.requiredactions.RecoveryAuthnCodesAction
|
org.keycloak.authentication.requiredactions.RecoveryAuthnCodesAction
|
||||||
org.keycloak.authentication.requiredactions.UpdateEmail
|
org.keycloak.authentication.requiredactions.UpdateEmail
|
||||||
|
|
|
@ -21,6 +21,7 @@ import org.keycloak.authentication.AuthenticationFlowCallback;
|
||||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||||
import org.keycloak.authentication.AuthenticationFlowError;
|
import org.keycloak.authentication.AuthenticationFlowError;
|
||||||
import org.keycloak.authentication.AuthenticationFlowException;
|
import org.keycloak.authentication.AuthenticationFlowException;
|
||||||
|
import org.keycloak.models.AuthenticationFlowModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserModel;
|
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";
|
public static final String EXPECTED_ERROR_MESSAGE = "Custom Authentication Flow Callback message";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onTopFlowSuccess() {
|
public void onTopFlowSuccess(AuthenticationFlowModel topFlow) {
|
||||||
throw new AuthenticationFlowException(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, "detail", EXPECTED_ERROR_MESSAGE);
|
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())
|
.auth(tokenUtil.getToken())
|
||||||
.asResponse()) {
|
.asResponse()) {
|
||||||
assertEquals(400, response.getStatus());
|
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
|
// 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_EMAIL", "Verify Email", true, false, null);
|
||||||
addRequiredAction(expected, "VERIFY_PROFILE", "Verify Profile", true, false, null);
|
addRequiredAction(expected, "VERIFY_PROFILE", "Verify Profile", true, false, null);
|
||||||
addRequiredAction(expected, "delete_account", "Delete Account", false, 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, "update_user_locale", "Update User Locale", true, false, null);
|
||||||
addRequiredAction(expected, "webauthn-register", "Webauthn Register", true, false, null);
|
addRequiredAction(expected, "webauthn-register", "Webauthn Register", true, false, null);
|
||||||
addRequiredAction(expected, "webauthn-register-passwordless", "Webauthn Register Passwordless", 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.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import jakarta.ws.rs.BadRequestException;
|
import jakarta.ws.rs.BadRequestException;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
import jakarta.ws.rs.core.UriBuilder;
|
import jakarta.ws.rs.core.UriBuilder;
|
||||||
import org.jboss.arquillian.graphene.page.Page;
|
import org.jboss.arquillian.graphene.page.Page;
|
||||||
import org.junit.After;
|
import org.junit.After;
|
||||||
import org.junit.Assert;
|
|
||||||
import org.junit.Before;
|
import org.junit.Before;
|
||||||
import org.junit.Rule;
|
import org.junit.Rule;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.keycloak.admin.client.resource.ClientResource;
|
import org.keycloak.admin.client.resource.ClientResource;
|
||||||
import org.keycloak.admin.client.resource.RealmResource;
|
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.OTPFormAuthenticatorFactory;
|
||||||
|
import org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory;
|
||||||
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
|
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
|
||||||
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
|
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
|
||||||
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
|
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
|
||||||
|
import org.keycloak.broker.provider.util.SimpleHttp;
|
||||||
import org.keycloak.common.Profile;
|
import org.keycloak.common.Profile;
|
||||||
import org.keycloak.events.Details;
|
import org.keycloak.events.Details;
|
||||||
|
import org.keycloak.events.EventType;
|
||||||
import org.keycloak.models.AuthenticationExecutionModel.Requirement;
|
import org.keycloak.models.AuthenticationExecutionModel.Requirement;
|
||||||
import org.keycloak.models.Constants;
|
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.models.utils.TimeBasedOTP;
|
||||||
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
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.ClaimsRepresentation;
|
||||||
import org.keycloak.representations.IDToken;
|
import org.keycloak.representations.IDToken;
|
||||||
import org.keycloak.representations.idm.ClientRepresentation;
|
import org.keycloak.representations.idm.ClientRepresentation;
|
||||||
|
import org.keycloak.representations.idm.CredentialRepresentation;
|
||||||
import org.keycloak.representations.idm.EventRepresentation;
|
import org.keycloak.representations.idm.EventRepresentation;
|
||||||
import org.keycloak.representations.idm.RealmRepresentation;
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
import org.keycloak.representations.idm.UserRepresentation;
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||||
|
import org.keycloak.testsuite.Assert;
|
||||||
import org.keycloak.testsuite.AssertEvents;
|
import org.keycloak.testsuite.AssertEvents;
|
||||||
|
import org.keycloak.testsuite.account.AccountRestClient;
|
||||||
import org.keycloak.testsuite.admin.ApiUtil;
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
|
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
|
||||||
|
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||||
import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory;
|
import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory;
|
||||||
import org.keycloak.testsuite.client.KeycloakTestingClient;
|
import org.keycloak.testsuite.client.KeycloakTestingClient;
|
||||||
|
import org.keycloak.testsuite.pages.DeleteCredentialPage;
|
||||||
import org.keycloak.testsuite.pages.ErrorPage;
|
import org.keycloak.testsuite.pages.ErrorPage;
|
||||||
|
import org.keycloak.testsuite.pages.LoginConfigTotpPage;
|
||||||
import org.keycloak.testsuite.pages.LoginPage;
|
import org.keycloak.testsuite.pages.LoginPage;
|
||||||
import org.keycloak.testsuite.pages.LoginTotpPage;
|
import org.keycloak.testsuite.pages.LoginTotpPage;
|
||||||
import org.keycloak.testsuite.pages.PushTheButtonPage;
|
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.updaters.RealmAttributeUpdater;
|
||||||
import org.keycloak.testsuite.util.FlowUtil;
|
import org.keycloak.testsuite.util.FlowUtil;
|
||||||
import org.keycloak.testsuite.util.OAuthClient;
|
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.CoreMatchers.is;
|
||||||
import static org.hamcrest.MatcherAssert.assertThat;
|
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.
|
* Tests for Level Of Assurance conditions in authentication flow.
|
||||||
*
|
*
|
||||||
* @author <a href="mailto:sebastian.zoescher@prime-sign.com">Sebastian Zoescher</a>
|
* @author <a href="mailto:sebastian.zoescher@prime-sign.com">Sebastian Zoescher</a>
|
||||||
*/
|
*/
|
||||||
|
@EnableFeature(value = RECOVERY_CODES, skipRestart = true)
|
||||||
public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||||
|
|
||||||
|
private static final String FLOW_NAME = "";
|
||||||
|
|
||||||
@Rule
|
@Rule
|
||||||
public AssertEvents events = new AssertEvents(this);
|
public AssertEvents events = new AssertEvents(this);
|
||||||
|
|
||||||
|
@ -87,6 +110,15 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||||
@Page
|
@Page
|
||||||
protected LoginTotpPage loginTotpPage;
|
protected LoginTotpPage loginTotpPage;
|
||||||
|
|
||||||
|
@Page
|
||||||
|
protected LoginConfigTotpPage totpSetupPage;
|
||||||
|
|
||||||
|
@Page
|
||||||
|
protected SetupRecoveryAuthnCodesPage setupRecoveryAuthnCodesPage;
|
||||||
|
|
||||||
|
@Page
|
||||||
|
protected SelectAuthenticatorPage selectAuthenticatorPage;
|
||||||
|
|
||||||
private TimeBasedOTP totp = new TimeBasedOTP();
|
private TimeBasedOTP totp = new TimeBasedOTP();
|
||||||
|
|
||||||
@Page
|
@Page
|
||||||
|
@ -95,6 +127,9 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||||
@Page
|
@Page
|
||||||
protected ErrorPage errorPage;
|
protected ErrorPage errorPage;
|
||||||
|
|
||||||
|
@Page
|
||||||
|
protected DeleteCredentialPage deleteCredentialPage;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||||
try {
|
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 {
|
private String getAcrToLoaMappingForClient() throws IOException {
|
||||||
Map<String, Integer> acrLoaMap = new HashMap<>();
|
Map<String, Integer> acrLoaMap = new HashMap<>();
|
||||||
acrLoaMap.put("copper", 0);
|
acrLoaMap.put("copper", 0);
|
||||||
|
@ -145,7 +198,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||||
testingClient.server(TEST_REALM_NAME)
|
testingClient.server(TEST_REALM_NAME)
|
||||||
.run(session -> FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms -> forms.clear()
|
.run(session -> FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms -> forms.clear()
|
||||||
// level 1 authentication
|
// level 1 authentication
|
||||||
.addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
|
.addSubFlowExecution("level1-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
|
||||||
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
|
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
|
||||||
config -> {
|
config -> {
|
||||||
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1");
|
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1");
|
||||||
|
@ -157,7 +210,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||||
})
|
})
|
||||||
|
|
||||||
// level 2 authentication
|
// level 2 authentication
|
||||||
.addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
|
.addSubFlowExecution("level2-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
|
||||||
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
|
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
|
||||||
config -> {
|
config -> {
|
||||||
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2");
|
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2");
|
||||||
|
@ -169,7 +222,7 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||||
})
|
})
|
||||||
|
|
||||||
// level 3 authentication
|
// level 3 authentication
|
||||||
.addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
|
.addSubFlowExecution("level3-subflow", AuthenticationFlow.BASIC_FLOW, Requirement.CONDITIONAL, subFlow -> {
|
||||||
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
|
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
|
||||||
config -> {
|
config -> {
|
||||||
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "3");
|
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "3");
|
||||||
|
@ -188,6 +241,21 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||||
configureStepUpFlow(testingClient, maxAge1, maxAge2, maxAge3);
|
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
|
@After
|
||||||
public void after() {
|
public void after() {
|
||||||
BrowserFlowTest.revertFlows(testRealm(), "browser - Level of Authentication FLow");
|
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) {
|
public void openLoginFormWithAcrClaim(boolean essential, String... acrValues) {
|
||||||
openLoginFormWithAcrClaim(oauth, essential, acrValues);
|
openLoginFormWithAcrClaim(oauth, essential, acrValues);
|
||||||
|
@ -707,10 +941,12 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||||
pushTheButtonPage.submit();
|
pushTheButtonPage.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertLoggedInWithAcr(String acr) {
|
private TokenCtx assertLoggedInWithAcr(String acr) {
|
||||||
EventRepresentation loginEvent = events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent();
|
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());
|
Assert.assertEquals(acr, idToken.getAcr());
|
||||||
|
return new TokenCtx(tokenResponse.getAccessToken(), idToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertErrorPage(String expectedError) {
|
private void assertErrorPage(String expectedError) {
|
||||||
|
@ -718,4 +954,14 @@ public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||||
Assert.assertEquals(expectedError, errorPage.getError());
|
Assert.assertEquals(expectedError, errorPage.getError());
|
||||||
events.clear();
|
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(migrationRealm);
|
||||||
testHS512KeyCreated(migrationRealm2);
|
testHS512KeyCreated(migrationRealm2);
|
||||||
testClientAttributes(migrationRealm);
|
testClientAttributes(migrationRealm);
|
||||||
|
testDeleteCredentialActionAvailable(migrationRealm);
|
||||||
}
|
}
|
||||||
if (testLdapUseTruststoreSpiMigration) {
|
if (testLdapUseTruststoreSpiMigration) {
|
||||||
testLdapUseTruststoreSpiMigration(migrationRealm2);
|
testLdapUseTruststoreSpiMigration(migrationRealm2);
|
||||||
|
@ -937,6 +938,8 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
|
||||||
for (RequiredActionProviderRepresentation action : actions) {
|
for (RequiredActionProviderRepresentation action : actions) {
|
||||||
if (action.getAlias().equals("update_user_locale")) {
|
if (action.getAlias().equals("update_user_locale")) {
|
||||||
assertEquals(1000, action.getPriority());
|
assertEquals(1000, action.getPriority());
|
||||||
|
} else if (action.getAlias().equals("delete_credential")) {
|
||||||
|
assertEquals(100, action.getPriority());
|
||||||
} else {
|
} else {
|
||||||
assertEquals(priority, action.getPriority());
|
assertEquals(priority, action.getPriority());
|
||||||
}
|
}
|
||||||
|
@ -1282,4 +1285,15 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
Assert.assertEquals(Collections.singletonList(client.getClientId()), clientIds);
|
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.representations.idm.RequiredActionProviderRepresentation;
|
||||||
import org.keycloak.testsuite.webauthn.pages.AbstractLoggedInPage;
|
import org.keycloak.testsuite.webauthn.pages.AbstractLoggedInPage;
|
||||||
import org.keycloak.testsuite.webauthn.pages.SigningInPage;
|
import org.keycloak.testsuite.webauthn.pages.SigningInPage;
|
||||||
|
import org.keycloak.testsuite.pages.DeleteCredentialPage;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
@ -78,14 +78,18 @@ public class SigningInPageUtils {
|
||||||
assertThat("Set up link for \"" + credentialType.getType() + "\" is visible", credentialType.isNotSetUpLabelVisible(), is(false));
|
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();
|
int countBeforeRemove = userCredential.getCredentialType().getUserCredentialsCount();
|
||||||
|
userCredential.clickRemoveBtn();
|
||||||
|
|
||||||
testModalDialog(accountPage, userCredential::clickRemoveBtn, () -> {
|
deleteCredentialPage.assertCurrent();
|
||||||
assertThat(userCredential.isPresent(), is(true));
|
deleteCredentialPage.cancel();
|
||||||
assertThat(userCredential.getCredentialType().getUserCredentialsCount(), is(countBeforeRemove));
|
accountPage.assertCurrent();
|
||||||
});
|
assertThat(userCredential.isPresent(), is(true));
|
||||||
accountPage.alert().assertSuccess();
|
assertThat(userCredential.getCredentialType().getUserCredentialsCount(), is(countBeforeRemove));
|
||||||
|
userCredential.clickRemoveBtn();
|
||||||
|
deleteCredentialPage.assertCurrent();
|
||||||
|
deleteCredentialPage.confirm();
|
||||||
|
|
||||||
assertThat(userCredential.isPresent(), is(false));
|
assertThat(userCredential.isPresent(), is(false));
|
||||||
assertThat(userCredential.getCredentialType().getUserCredentialsCount(), is(countBeforeRemove - 1));
|
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.page.AbstractPatternFlyAlert;
|
||||||
import org.keycloak.testsuite.webauthn.pages.SigningInPage;
|
import org.keycloak.testsuite.webauthn.pages.SigningInPage;
|
||||||
import org.keycloak.testsuite.webauthn.utils.SigningInPageUtils;
|
import org.keycloak.testsuite.webauthn.utils.SigningInPageUtils;
|
||||||
|
import org.keycloak.testsuite.pages.DeleteCredentialPage;
|
||||||
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
|
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
|
||||||
import org.keycloak.testsuite.util.FlowUtil;
|
import org.keycloak.testsuite.util.FlowUtil;
|
||||||
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
|
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
|
||||||
|
@ -65,6 +66,9 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple
|
||||||
@Page
|
@Page
|
||||||
protected WebAuthnLoginPage webAuthnLoginPage;
|
protected WebAuthnLoginPage webAuthnLoginPage;
|
||||||
|
|
||||||
|
@Page
|
||||||
|
private DeleteCredentialPage deleteCredentialPage;
|
||||||
|
|
||||||
private VirtualAuthenticatorManager webAuthnManager;
|
private VirtualAuthenticatorManager webAuthnManager;
|
||||||
protected SigningInPage.CredentialType webAuthnCredentialType;
|
protected SigningInPage.CredentialType webAuthnCredentialType;
|
||||||
protected SigningInPage.CredentialType webAuthnPwdlessCredentialType;
|
protected SigningInPage.CredentialType webAuthnPwdlessCredentialType;
|
||||||
|
@ -186,7 +190,7 @@ public abstract class AbstractWebAuthnAccountTest extends AbstractAuthTest imple
|
||||||
|
|
||||||
protected void testRemoveCredential(SigningInPage.UserCredential userCredential) {
|
protected void testRemoveCredential(SigningInPage.UserCredential userCredential) {
|
||||||
AbstractPatternFlyAlert.waitUntilHidden();
|
AbstractPatternFlyAlert.waitUntilHidden();
|
||||||
SigningInPageUtils.testRemoveCredential(signingInPage, userCredential);
|
SigningInPageUtils.testRemoveCredential(signingInPage, deleteCredentialPage, userCredential);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected SigningInPage.UserCredential getNewestUserCredential(SigningInPage.CredentialType credentialType) {
|
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
|
loginIdpReviewProfileTitle=Update Account Information
|
||||||
loginTimeout=Your login attempt timed out. Login will start from the beginning.
|
loginTimeout=Your login attempt timed out. Login will start from the beginning.
|
||||||
reauthenticate=Please re-authenticate to continue
|
reauthenticate=Please re-authenticate to continue
|
||||||
|
authenticateStrong=Strong authentication required to continue
|
||||||
oauthGrantTitle=Grant Access to {0}
|
oauthGrantTitle=Grant Access to {0}
|
||||||
oauthGrantTitleHtml={0}
|
oauthGrantTitleHtml={0}
|
||||||
oauthGrantInformation=Make sure you trust {0} by learning how {0} will handle your data.
|
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.
|
termsAcceptanceRequired=You must agree to our terms and conditions.
|
||||||
acceptTerms=I agree to the terms and conditions
|
acceptTerms=I agree to the terms and conditions
|
||||||
|
|
||||||
|
deleteCredentialTitle=Delete {0}
|
||||||
|
deleteCredentialMessage=Do you want to delete {0}?
|
||||||
|
|
||||||
recaptchaFailed=Invalid Recaptcha
|
recaptchaFailed=Invalid Recaptcha
|
||||||
recaptchaNotConfigured=Recaptcha is required, but not configured
|
recaptchaNotConfigured=Recaptcha is required, but not configured
|
||||||
consentDenied=Consent denied.
|
consentDenied=Consent denied.
|
||||||
|
|
Loading…
Reference in a new issue