From 2724bfe81b860b1166d931659ef2bf750903015b Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Wed, 3 Apr 2024 00:42:32 +0200 Subject: [PATCH] Make required actions configurable (#28400) - Add tests for crud operations on configurable required actions - Add support exposing the required action configuration via RequiredActionContext - Make configSaveError message reusable in other contexts - Introduced admin-ui specific endpoint for retrieving required actions with config metadata Fixes #28400 Signed-off-by: Thomas Darimont Co-authored-by: Erik Jan de Wit --- ...equiredActionConfigInfoRepresentation.java | 37 ++++ .../RequiredActionConfigRepresentation.java | 38 ++++ .../RequiredActionProviderRepresentation.java | 4 +- ...redActionProviderSimpleRepresentation.java | 1 + .../AuthenticationManagementResource.java | 21 ++ .../admin/messages/messages_zh_CN.properties | 2 +- .../admin/messages/messages_en.properties | 6 +- .../src/authentication/RequiredActions.tsx | 183 +++++++++++------- .../components/DraggableTable.tsx | 10 +- .../components/RequiredActionConfigModal.tsx | 143 ++++++++++++++ .../requiredActionConfigInfoRepresentation.ts | 5 + .../requiredActionConfigRepresentation.ts | 3 + .../src/resources/authenticationManagement.ts | 40 ++++ .../test/authenticationManagement.spec.ts | 60 ++++++ .../models/cache/infinispan/RealmAdapter.java | 30 +++ .../infinispan/entities/CachedRealm.java | 19 +- .../org/keycloak/models/jpa/RealmAdapter.java | 114 +++++++++-- .../AuthenticationManagementResource.java | 46 ++++- ...eRequiredActionProviderRepresentation.java | 32 +++ .../authentication/RequiredActionContext.java | 7 + .../authentication/RequiredActionFactory.java | 29 ++- .../keycloak/events/admin/ResourceType.java | 5 + .../models/utils/ModelToRepresentation.java | 6 +- .../models/utils/RealmModelDelegate.java | 26 +++ .../userprofile/ValidationException.java | 6 + .../keycloak/utils/RequiredActionHelper.java | 69 +++++++ .../util/IdentityBrokerStateTestHelpers.java | 25 +++ .../java/org/keycloak/models/RealmModel.java | 6 + .../models/RequiredActionConfigModel.java | 87 +++++++++ .../RequiredActionContextResult.java | 8 +- .../requiredactions/UpdatePassword.java | 113 +++++++++-- .../AuthenticationManagementResource.java | 128 +++++++++++- ...ummyConfigurableRequiredActionFactory.java | 120 ++++++++++++ ...cloak.authentication.RequiredActionFactory | 3 +- .../authentication/RequiredActionsTest.java | 64 +++++- .../testsuite/util/AdminEventPaths.java | 6 + 36 files changed, 1389 insertions(+), 113 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/idm/RequiredActionConfigInfoRepresentation.java create mode 100755 core/src/main/java/org/keycloak/representations/idm/RequiredActionConfigRepresentation.java create mode 100644 js/apps/admin-ui/src/authentication/components/RequiredActionConfigModal.tsx create mode 100644 js/libs/keycloak-admin-client/src/defs/requiredActionConfigInfoRepresentation.ts create mode 100644 js/libs/keycloak-admin-client/src/defs/requiredActionConfigRepresentation.ts create mode 100644 rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/ConfigurableRequiredActionProviderRepresentation.java create mode 100644 server-spi-private/src/main/java/org/keycloak/utils/RequiredActionHelper.java create mode 100755 server-spi/src/main/java/org/keycloak/models/RequiredActionConfigModel.java create mode 100755 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/actions/DummyConfigurableRequiredActionFactory.java diff --git a/core/src/main/java/org/keycloak/representations/idm/RequiredActionConfigInfoRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RequiredActionConfigInfoRepresentation.java new file mode 100644 index 000000000000..856ff92f9340 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/RequiredActionConfigInfoRepresentation.java @@ -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.representations.idm; + +import java.util.List; + +/** + * Represents the configurable properties of a RequiredAction. + */ +public class RequiredActionConfigInfoRepresentation { + + private List properties; + + public List getProperties() { + return properties; + } + + public void setProperties(List properties) { + this.properties = properties; + } +} + diff --git a/core/src/main/java/org/keycloak/representations/idm/RequiredActionConfigRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RequiredActionConfigRepresentation.java new file mode 100755 index 000000000000..eb7a31d9fd02 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/RequiredActionConfigRepresentation.java @@ -0,0 +1,38 @@ +/* + * 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.representations.idm; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +/** + * Represents the configuration of a RequiredAction. + */ +public class RequiredActionConfigRepresentation implements Serializable { + + private Map config = new HashMap<>(); + + public Map getConfig() { + return config; + } + + public void setConfig(Map config) { + this.config = config; + } +} diff --git a/core/src/main/java/org/keycloak/representations/idm/RequiredActionProviderRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RequiredActionProviderRepresentation.java index 6f6085e9e65e..74d77683675e 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RequiredActionProviderRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RequiredActionProviderRepresentation.java @@ -17,7 +17,6 @@ package org.keycloak.representations.idm; -import java.util.HashMap; import java.util.Map; /** @@ -32,8 +31,7 @@ public class RequiredActionProviderRepresentation { private boolean enabled; private boolean defaultAction; private int priority; - private Map config = new HashMap<>(); - + private Map config; public String getAlias() { return alias; diff --git a/core/src/main/java/org/keycloak/representations/idm/RequiredActionProviderSimpleRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RequiredActionProviderSimpleRepresentation.java index 492204dd289d..88092efe488d 100644 --- a/core/src/main/java/org/keycloak/representations/idm/RequiredActionProviderSimpleRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RequiredActionProviderSimpleRepresentation.java @@ -52,4 +52,5 @@ public String getProviderId() { public void setProviderId(String providerId) { this.providerId = providerId; } + } diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/AuthenticationManagementResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/AuthenticationManagementResource.java index b549f4905014..79697ee8463f 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/AuthenticationManagementResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/AuthenticationManagementResource.java @@ -23,6 +23,8 @@ import org.keycloak.representations.idm.AuthenticatorConfigInfoRepresentation; import org.keycloak.representations.idm.AuthenticatorConfigRepresentation; import org.keycloak.representations.idm.ConfigPropertyRepresentation; +import org.keycloak.representations.idm.RequiredActionConfigInfoRepresentation; +import org.keycloak.representations.idm.RequiredActionConfigRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation; @@ -177,6 +179,25 @@ public interface AuthenticationManagementResource { @POST void lowerRequiredActionPriority(@PathParam("alias") String alias); + @Path("required-actions/{alias}/config-description") + @GET + @Produces(MediaType.APPLICATION_JSON) + RequiredActionConfigInfoRepresentation getRequiredActionConfigDescription(@PathParam("alias") String alias); + + @Path("required-actions/{alias}/config") + @GET + @Produces(MediaType.APPLICATION_JSON) + RequiredActionConfigRepresentation getRequiredActionConfig(@PathParam("alias") String alias); + + @Path("required-actions/{alias}/config") + @DELETE + void removeRequiredActionConfig(@PathParam("alias") String alias); + + @Path("required-actions/{alias}/config") + @PUT + @Consumes(MediaType.APPLICATION_JSON) + void updateRequiredActionConfig(@PathParam("alias") String alias, RequiredActionConfigRepresentation rep); + @Path("config-description/{providerId}") @GET @Produces(MediaType.APPLICATION_JSON) diff --git a/js/apps/admin-ui/maven-resources-community/theme/keycloak.v2/admin/messages/messages_zh_CN.properties b/js/apps/admin-ui/maven-resources-community/theme/keycloak.v2/admin/messages/messages_zh_CN.properties index ec56312f445e..3023a5790d57 100644 --- a/js/apps/admin-ui/maven-resources-community/theme/keycloak.v2/admin/messages/messages_zh_CN.properties +++ b/js/apps/admin-ui/maven-resources-community/theme/keycloak.v2/admin/messages/messages_zh_CN.properties @@ -556,7 +556,7 @@ syncModes.inherit=继承 masterSamlProcessingUrlHelp=如果配置,则此 URL 将用于每个绑定到 SP 的断言消费者和单点注销服务。这可以在 Fine Grain SAML 端点配置中为每个绑定和服务单独覆写。 addedGroupMembershipError=添加群组成员身份时出错 authenticatorAttachment.platform=平台 -configSaveSuccess=成功保存执行器的配置 +configSaveSuccess=成功保存配置 regenerate=重新生成 ignoreMissingGroups=忽略缺失的群组 sslType.external=外部请求 diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 869dc0eb640e..d19b5b5dc57a 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -578,7 +578,7 @@ syncModes.inherit=Inherit masterSamlProcessingUrlHelp=If configured, this URL will be used for every binding to both the SP's Assertion Consumer and Single Logout Services. This can be individually overridden for each binding and service in the Fine Grain SAML Endpoint Configuration. addedGroupMembershipError=Error adding group membership authenticatorAttachment.platform=Platform -configSaveSuccess=Successfully saved the execution config +configSaveSuccess=Successfully saved the config regenerate=Regenerate ignoreMissingGroups=Ignore missing groups sslType.external=External requests @@ -732,6 +732,8 @@ client-updater-source-groups.label=Groups frontchannelLogoutUrlHelp=URL that will cause the client to log itself out when a logout request is sent to this realm (via end_session_endpoint). If not provided, it defaults to the base url. authenticationOverridesHelp=Override realm authentication flow bindings. requiredActions=Required actions +requiredAction=Required action +requiredActionConfig=Configuration for {{name}} selectLocales=Select locales policyDecisionStagey=The decision strategy dictates how the policies associated with a given permission are evaluated and how a final decision is obtained. 'Affirmative' means that at least one policy must evaluate to a positive decision in order for the final decision to be also positive. 'Unanimous' means that all policies must evaluate to a positive decision in order for the final decision to be also positive. 'Consensus' means that the number of positive decisions must be greater than the number of negative decisions. If the number of positive and negative is the same, the final decision will be negative. usermodel.prop.tooltip=Name of the property method in the UserModel interface. For example, a value of 'email' would reference the UserModel.getEmail() method. @@ -922,7 +924,7 @@ eventTypes.OAUTH2_DEVICE_AUTH_ERROR.description=Oauth2 device authentication err unlinkUsers=Unlink users userLdapFilter=User LDAP filter emailVerification=Email Verification -configSaveError=Could not save the execution config\: {{error}} +configSaveError=Could not save the config\: {{error}} clientAuthenticatorTypeHelp=Client Authenticator used for authentication of this client against Keycloak server cachePolicyHelp=Cache Policy for this storage provider. 'DEFAULT' is whatever the default settings are for the global cache. 'EVICT_DAILY' is a time of day every day that the cache will be invalidated. 'EVICT_WEEKLY' is a day of the week and time the cache will be invalidated. 'MAX_LIFESPAN' is the time in milliseconds that will be the lifespan of a cache entry. eventTypes.CUSTOM_REQUIRED_ACTION_ERROR.description=Custom required action error diff --git a/js/apps/admin-ui/src/authentication/RequiredActions.tsx b/js/apps/admin-ui/src/authentication/RequiredActions.tsx index 81eb48d3d06c..ec198bbb71c4 100644 --- a/js/apps/admin-ui/src/authentication/RequiredActions.tsx +++ b/js/apps/admin-ui/src/authentication/RequiredActions.tsx @@ -1,17 +1,24 @@ import type RequiredActionProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation"; import type RequiredActionProviderSimpleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderSimpleRepresentation"; -import { AlertVariant, Switch } from "@patternfly/react-core"; +import { AlertVariant, Button, Switch } from "@patternfly/react-core"; +import { CogIcon } from "@patternfly/react-icons"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useAdminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; -import { toKey } from "../util"; +import { addTrailingSlash, toKey } from "../util"; import { useFetch } from "../utils/useFetch"; import { DraggableTable } from "./components/DraggableTable"; +import { RequiredActionConfigModal } from "./components/RequiredActionConfigModal"; +import { fetchWithError } from "@keycloak/keycloak-admin-client"; +import { getAuthorizationHeaders } from "../utils/getAuthorizationHeaders"; +import { useRealm } from "../context/realm-context/RealmContext"; type DataType = RequiredActionProviderRepresentation & - RequiredActionProviderSimpleRepresentation; + RequiredActionProviderSimpleRepresentation & { + configurable?: boolean; + }; type Row = { name: string; @@ -27,27 +34,45 @@ export const RequiredActions = () => { const { addAlert, addError } = useAlerts(); const [actions, setActions] = useState(); + const [selectedAction, setSelectedAction] = useState(); const [key, setKey] = useState(0); const refresh = () => setKey(key + 1); + const { realm: realmName } = useRealm(); + + const loadActions = async (): Promise< + RequiredActionProviderRepresentation[] + > => { + const requiredActionsRequest = await fetchWithError( + `${addTrailingSlash( + adminClient.baseUrl, + )}admin/realms/${realmName}/ui-ext/authentication-management/required-actions`, + { + method: "GET", + headers: getAuthorizationHeaders(await adminClient.getAccessToken()), + }, + ); + + return (await requiredActionsRequest.json()) as DataType[]; + }; useFetch( async () => { const [requiredActions, unregisteredRequiredActions] = await Promise.all([ - adminClient.authenticationManagement.getRequiredActions(), + loadActions(), adminClient.authenticationManagement.getUnregisteredRequiredActions(), ]); return [ - ...requiredActions.map((a) => ({ - name: a.name!, - enabled: a.enabled!, - defaultAction: a.defaultAction!, - data: a, + ...requiredActions.map((action) => ({ + name: action.name!, + enabled: action.enabled!, + defaultAction: action.defaultAction!, + data: action, })), - ...unregisteredRequiredActions.map((a) => ({ - name: a.name!, + ...unregisteredRequiredActions.map((action) => ({ + name: action.name!, enabled: false, defaultAction: false, - data: a, + data: action, })), ]; }, @@ -66,6 +91,8 @@ export const RequiredActions = () => { try { if (field in action) { action[field] = !action[field]; + // remove configurable property from action which only exists for the admin ui + delete action.configurable; await adminClient.authenticationManagement.updateRequiredAction( { alias: action.alias! }, action, @@ -117,59 +144,85 @@ export const RequiredActions = () => { } return ( - { - const keys = actions.map((e) => e.name); - const newIndex = items.indexOf(nameDragged); - const oldIndex = keys.indexOf(nameDragged); - const dragged = actions[oldIndex].data; - if (!dragged.alias) return; + <> + {selectedAction && ( + setSelectedAction(undefined)} + /> + )} + { + const keys = actions.map((e) => e.name); + const newIndex = items.indexOf(nameDragged); + const oldIndex = keys.indexOf(nameDragged); + const dragged = actions[oldIndex].data; + if (!dragged.alias) return; - const times = newIndex - oldIndex; - executeMove(dragged, times); - }} - columns={[ - { - name: "name", - displayKey: "requiredActions", - }, - { - name: "enabled", - displayKey: "enabled", - cellRenderer: (row) => ( - { - updateAction(row.data, "enabled"); - }} - aria-label={toKey(row.name)} - /> - ), - }, - { - name: "default", - displayKey: "setAsDefaultAction", - thTooltipText: "authDefaultActionTooltip", - cellRenderer: (row) => ( - { - updateAction(row.data, "defaultAction"); - }} - aria-label={toKey(row.name)} - /> - ), - }, - ]} - data={actions} - /> + const times = newIndex - oldIndex; + executeMove(dragged, times); + }} + columns={[ + { + name: "name", + displayKey: "action", + width: 50, + }, + { + name: "enabled", + displayKey: "enabled", + cellRenderer: (row) => ( + { + updateAction(row.data, "enabled"); + }} + aria-label={toKey(row.name)} + /> + ), + width: 20, + }, + { + name: "default", + displayKey: "setAsDefaultAction", + thTooltipText: "authDefaultActionTooltip", + cellRenderer: (row) => ( + { + updateAction(row.data, "defaultAction"); + }} + aria-label={toKey(row.name)} + /> + ), + width: 20, + }, + { + name: "config", + displayKey: "configure", + cellRenderer: (row) => + row.data.configurable ? ( + + ) : undefined, + width: 10, + }, + ]} + data={actions} + /> + ); }; diff --git a/js/apps/admin-ui/src/authentication/components/DraggableTable.tsx b/js/apps/admin-ui/src/authentication/components/DraggableTable.tsx index 238c8aa790c4..591268793460 100644 --- a/js/apps/admin-ui/src/authentication/components/DraggableTable.tsx +++ b/js/apps/admin-ui/src/authentication/components/DraggableTable.tsx @@ -9,6 +9,7 @@ import { Thead, Tr, type TableProps, + ThProps, } from "@patternfly/react-table"; import type { ThInfoType } from "@patternfly/react-table/dist/esm/components/Table/base/types"; import { get } from "lodash-es"; @@ -21,7 +22,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; -export type Field = { +export type Field = Pick & { name: string; displayKey?: string; cellRenderer?: (row: T) => ReactNode; @@ -199,7 +200,12 @@ export function DraggableTable({ {columns.map((column) => ( - + {t(column.displayKey || column.name)} ))} diff --git a/js/apps/admin-ui/src/authentication/components/RequiredActionConfigModal.tsx b/js/apps/admin-ui/src/authentication/components/RequiredActionConfigModal.tsx new file mode 100644 index 000000000000..f34480d18e53 --- /dev/null +++ b/js/apps/admin-ui/src/authentication/components/RequiredActionConfigModal.tsx @@ -0,0 +1,143 @@ +import RequiredActionConfigInfoRepresentation from "@keycloak/keycloak-admin-client/lib/defs/requiredActionConfigInfoRepresentation"; +import RequiredActionConfigRepresentation from "@keycloak/keycloak-admin-client/lib/defs/requiredActionConfigRepresentation"; +import type RequiredActionProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/requiredActionProviderRepresentation"; +import { + ActionGroup, + AlertVariant, + Button, + ButtonVariant, + Form, + Modal, + ModalVariant, +} from "@patternfly/react-core"; +import { TrashIcon } from "@patternfly/react-icons"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useAdminClient } from "../../admin-client"; +import { useAlerts } from "../../components/alert/Alerts"; +import { DynamicComponents } from "../../components/dynamic/DynamicComponents"; +import { convertFormValuesToObject, convertToFormValues } from "../../util"; +import { useFetch } from "../../utils/useFetch"; + +type RequiredActionConfigModalForm = { + // alias: string; + config: { [index: string]: string }; +}; + +type RequiredActionConfigModalProps = { + requiredAction: RequiredActionProviderRepresentation; + onClose: () => void; +}; + +export const RequiredActionConfigModal = ({ + requiredAction, + onClose, +}: RequiredActionConfigModalProps) => { + const { adminClient } = useAdminClient(); + const { t } = useTranslation(); + const { addAlert, addError } = useAlerts(); + + const [configDescription, setConfigDescription] = + useState(); + + const form = useForm(); + const { setValue, handleSubmit } = form; + + // // default config all required actions should have + // const defaultConfigProperties = []; + + const setupForm = (config?: RequiredActionConfigRepresentation) => { + convertToFormValues(config || {}, setValue); + }; + + useFetch( + async () => { + const configDescription = + await adminClient.authenticationManagement.getRequiredActionConfigDescription( + { + alias: requiredAction.alias!, + }, + ); + + const config = + await adminClient.authenticationManagement.getRequiredActionConfig({ + alias: requiredAction.alias!, + }); + + // merge default and fetched config properties + configDescription.properties = [ + //...defaultConfigProperties!, + ...configDescription.properties!, + ]; + + return { configDescription, config }; + }, + ({ configDescription, config }) => { + setConfigDescription(configDescription); + setupForm(config); + }, + [], + ); + + const save = async (saved: RequiredActionConfigModalForm) => { + const newConfig = convertFormValuesToObject(saved); + try { + await adminClient.authenticationManagement.updateRequiredActionConfig( + { alias: requiredAction.alias! }, + newConfig, + ); + setupForm(newConfig); + addAlert(t("configSaveSuccess"), AlertVariant.success); + onClose(); + } catch (error) { + addError("configSaveError", error); + } + }; + + return ( + +
+ + + + + + + + +
+
+ ); +}; diff --git a/js/libs/keycloak-admin-client/src/defs/requiredActionConfigInfoRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/requiredActionConfigInfoRepresentation.ts new file mode 100644 index 000000000000..7de6aa496d5a --- /dev/null +++ b/js/libs/keycloak-admin-client/src/defs/requiredActionConfigInfoRepresentation.ts @@ -0,0 +1,5 @@ +import { ConfigPropertyRepresentation } from "./configPropertyRepresentation.js"; + +export default interface RequiredActionConfigInfoRepresentation { + properties?: ConfigPropertyRepresentation[]; +} diff --git a/js/libs/keycloak-admin-client/src/defs/requiredActionConfigRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/requiredActionConfigRepresentation.ts new file mode 100644 index 000000000000..d4e311ebf773 --- /dev/null +++ b/js/libs/keycloak-admin-client/src/defs/requiredActionConfigRepresentation.ts @@ -0,0 +1,3 @@ +export default interface RequiredActionConfigRepresentation { + config?: { [index: string]: string }; +} diff --git a/js/libs/keycloak-admin-client/src/resources/authenticationManagement.ts b/js/libs/keycloak-admin-client/src/resources/authenticationManagement.ts index d7d6f4a37b63..931687d314e6 100644 --- a/js/libs/keycloak-admin-client/src/resources/authenticationManagement.ts +++ b/js/libs/keycloak-admin-client/src/resources/authenticationManagement.ts @@ -7,6 +7,8 @@ import type AuthenticatorConfigRepresentation from "../defs/authenticatorConfigR import type { AuthenticationProviderRepresentation } from "../defs/authenticatorConfigRepresentation.js"; import type AuthenticatorConfigInfoRepresentation from "../defs/authenticatorConfigInfoRepresentation.js"; import type RequiredActionProviderSimpleRepresentation from "../defs/requiredActionProviderSimpleRepresentation.js"; +import type RequiredActionConfigInfoRepresentation from "../defs/requiredActionConfigInfoRepresentation.js"; +import type RequiredActionConfigRepresentation from "../defs/requiredActionConfigRepresentation.js"; export class AuthenticationManagement extends Resource<{ realm?: string }> { /** @@ -231,6 +233,44 @@ export class AuthenticationManagement extends Resource<{ realm?: string }> { urlParamKeys: ["id"], }); + // Get required actions provider's configuration description + public getRequiredActionConfigDescription = this.makeRequest< + { alias: string }, + RequiredActionConfigInfoRepresentation + >({ + method: "GET", + path: "/required-actions/{alias}/config-description", + urlParamKeys: ["alias"], + }); + + // Get the configuration of the RequiredAction provider in the current Realm. + public getRequiredActionConfig = this.makeRequest< + { alias: string }, + RequiredActionConfigRepresentation + >({ + method: "GET", + path: "/required-actions/{alias}/config", + urlParamKeys: ["alias"], + }); + + // Remove the configuration from the RequiredAction provider in the current Realm. + public removeRequiredActionConfig = this.makeRequest<{ alias: string }>({ + method: "DELETE", + path: "/required-actions/{alias}/config", + urlParamKeys: ["alias"], + }); + + // Update the configuration from the RequiredAction provider in the current Realm. + public updateRequiredActionConfig = this.makeUpdateRequest< + { alias: string }, + RequiredActionConfigRepresentation, + void + >({ + method: "PUT", + path: "/required-actions/{alias}/config", + urlParamKeys: ["alias"], + }); + public getConfigDescription = this.makeRequest< { providerId: string }, AuthenticatorConfigInfoRepresentation diff --git a/js/libs/keycloak-admin-client/test/authenticationManagement.spec.ts b/js/libs/keycloak-admin-client/test/authenticationManagement.spec.ts index 470f2392af7d..10fd98299082 100644 --- a/js/libs/keycloak-admin-client/test/authenticationManagement.spec.ts +++ b/js/libs/keycloak-admin-client/test/authenticationManagement.spec.ts @@ -133,6 +133,66 @@ describe("Authentication management", () => { ); }); + it("should fetch config description for required action", async () => { + const configDescription = + await kcAdminClient.authenticationManagement.getRequiredActionConfigDescription( + { + alias: "UPDATE_PASSWORD", + }, + ); + + expect(configDescription).is.ok; + expect(configDescription.properties).is.ok; + }); + + it("should fetch required action config for update password", async () => { + const actionConfig = + await kcAdminClient.authenticationManagement.getRequiredActionConfig({ + alias: "UPDATE_PASSWORD", + }); + + expect(actionConfig).is.ok; + expect(actionConfig.config).is.ok; + expect(actionConfig.config!["max_auth_age"]).to.be.eq(300); // default max_auth_age for update password + }); + + it("should update required action config for update password", async () => { + await kcAdminClient.authenticationManagement.updateRequiredActionConfig( + { + alias: "UPDATE_PASSWORD", + }, + { + config: { + max_auth_age: "301", + }, + }, + ); + + const actionConfig = + await kcAdminClient.authenticationManagement.getRequiredActionConfig({ + alias: "UPDATE_PASSWORD", + }); + + expect(actionConfig).is.ok; + expect(actionConfig.config).is.ok; + expect(actionConfig.config!["max_auth_age"]).to.be.eq(301); // updated value max_auth_age for update password + }); + + it("should reset required action config for update password", async () => { + await kcAdminClient.authenticationManagement.removeRequiredActionConfig({ + alias: "UPDATE_PASSWORD", + }); + + const actionConfig = + await kcAdminClient.authenticationManagement.getRequiredActionConfig({ + alias: "UPDATE_PASSWORD", + }); + + expect(actionConfig).is.ok; + expect(actionConfig.config).is.ok; + expect(actionConfig.config!["max_auth_age"]).to.be.eq(300); // default max_auth_age for update password + }); + it("should get client authenticator providers", async () => { const authenticationProviders = await kcAdminClient.authenticationManagement.getClientAuthenticatorProviders(); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java index 05ece6aa8d1a..eb17b8f8a0d8 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java @@ -1416,6 +1416,36 @@ public AuthenticatorConfigModel getAuthenticatorConfigById(String id) { return cached.getAuthenticatorConfigs().get(id); } + @Override + public RequiredActionConfigModel getRequiredActionConfigById(String id) { + if (isUpdated()) return updated.getRequiredActionConfigById(id); + return cached.getRequiredActionProviderConfigs().get(id); + } + + @Override + public RequiredActionConfigModel getRequiredActionConfigByAlias(String alias) { + if (isUpdated()) return updated.getRequiredActionConfigByAlias(alias); + return cached.getRequiredActionProviderConfigsByAlias().get(alias); + } + + @Override + public void updateRequiredActionConfig(RequiredActionConfigModel model) { + getDelegateForUpdate(); + updated.updateRequiredActionConfig(model); + } + + @Override + public void removeRequiredActionProviderConfig(RequiredActionConfigModel model) { + getDelegateForUpdate(); + updated.removeRequiredActionProviderConfig(model); + } + + @Override + public Stream getRequiredActionConfigsStream() { + if (isUpdated()) return updated.getRequiredActionConfigsStream(); + return cached.getRequiredActionProviderConfigsByAlias().values().stream(); + } + @Override public Stream getRequiredActionProvidersStream() { if (isUpdated()) return updated.getRequiredActionProvidersStream(); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java index 1cc52d3e342c..ca331e77296a 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java @@ -43,6 +43,7 @@ import org.keycloak.models.OTPPolicy; import org.keycloak.models.ParConfig; import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.RequiredActionConfigModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.RequiredCredentialModel; @@ -131,6 +132,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { protected Map authenticationFlows = new HashMap<>(); protected List authenticationFlowList; protected Map authenticatorConfigs; + protected Map requiredActionProviderConfigs = new HashMap<>(); + protected Map requiredActionProviderConfigsByAlias = new HashMap<>(); protected Map requiredActionProviders = new HashMap<>(); protected List requiredActionProviderList; protected Map requiredActionProvidersByAlias = new HashMap<>(); @@ -290,9 +293,15 @@ public CachedRealm(Long revision, RealmModel model) { authenticatorConfigs = model.getAuthenticatorConfigsStream() .collect(Collectors.toMap(AuthenticatorConfigModel::getId, Function.identity())); + List requiredActionConfigsList = model.getRequiredActionConfigsStream().collect(Collectors.toList()); + for (RequiredActionConfigModel requiredActionConfig : requiredActionConfigsList) { + requiredActionProviderConfigs.put(requiredActionConfig.getId(), requiredActionConfig); + requiredActionProviderConfigsByAlias.put(requiredActionConfig.getAlias(), requiredActionConfig); + } + requiredActionProviderList = model.getRequiredActionProvidersStream().collect(Collectors.toList()); for (RequiredActionProviderModel action : requiredActionProviderList) { - this.requiredActionProviders.put(action.getId(), action); + requiredActionProviders.put(action.getId(), action); requiredActionProvidersByAlias.put(action.getAlias(), action); } @@ -756,4 +765,12 @@ public boolean isAllowUserManagedAccess() { public Map> getRealmLocalizationTexts() { return realmLocalizationTexts; } + + public Map getRequiredActionProviderConfigsByAlias() { + return requiredActionProviderConfigsByAlias; + } + + public Map getRequiredActionProviderConfigs() { + return requiredActionProviderConfigs; + } } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java index 3f08d27f90ab..73caea59e975 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java @@ -19,6 +19,8 @@ import org.keycloak.Config; import org.jboss.logging.Logger; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.broker.provider.IdentityProviderFactory; import org.keycloak.broker.social.SocialIdentityProvider; @@ -32,6 +34,7 @@ import org.keycloak.models.jpa.entities.*; import org.keycloak.models.utils.ComponentUtil; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.provider.ProviderConfigProperty; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; @@ -1848,26 +1851,103 @@ public Stream getAuthenticatorConfigsStream() { return realm.getAuthenticatorConfigs().stream().map(this::entityToModel); } + @Override + public Stream getRequiredActionConfigsStream() { + return getRequiredActionProvidersStream() // + .map(this::requiredActionToConfigModel); + } + + @Override + public RequiredActionConfigModel getRequiredActionConfigById(String id) { + return getRequiredActionConfigsStream() // + .filter(req -> req.getId().equals(id))// + .findFirst() // + .orElse(null); + } + + @Override + public RequiredActionConfigModel getRequiredActionConfigByAlias(String alias) { + return getRequiredActionConfigsStream() // + .filter(req -> req.getAlias().equals(alias))// + .findFirst() // + .orElse(null); + } + + private RequiredActionConfigModel requiredActionToConfigModel(RequiredActionProviderModel reqAction) { + RequiredActionConfigModel configModel = new RequiredActionConfigModel(); + configModel.setId(reqAction.getId()); + configModel.setConfig(new HashMap<>(reqAction.getConfig())); + configModel.setProviderId(reqAction.getProviderId()); + configModel.setAlias(reqAction.getAlias()); + return configModel; + } + + @Override + public void removeRequiredActionProviderConfig(RequiredActionConfigModel model) { + getRequiredActionProvidersStream() // + .filter(req -> req.getProviderId().equals(model.getProviderId()) && req.getAlias().equals(model.getAlias()))// + .findFirst() // + .ifPresent(reqAction -> { // + reqAction.setConfig(null); + updateRequiredActionProvider(reqAction); + }); + } + + @Override + public void updateRequiredActionConfig(RequiredActionConfigModel model) { + + getRequiredActionProvidersStream() // + .filter(req -> req.getProviderId().equals(model.getProviderId()) && req.getAlias().equals(model.getAlias()))// + .findFirst() // + .ifPresent(reqAction -> { // + + RequiredActionFactory factory = (RequiredActionFactory)session.getKeycloakSessionFactory().getProviderFactory(RequiredActionProvider.class, model.getProviderId()); + if (factory == null || !factory.isConfigurable()) { + return; + } + + // validate model config + factory.validateConfig(session, this, model); + + // update model config + Map config = new HashMap<>(); + if (reqAction.getConfig() != null) { + config.putAll(reqAction.getConfig()); + } + if (model != null && model.getConfig() != null) { + // only apply explicitly listed config properties + for (ProviderConfigProperty configProperty : factory.getConfigMetadata()) { + String value = model.getConfig().get(configProperty.getName()); + config.put(configProperty.getName(), value); + } + } + reqAction.setConfig(config); + + // propagate update to database + updateRequiredActionProvider(reqAction); + }); + } + @Override public RequiredActionProviderModel addRequiredActionProvider(RequiredActionProviderModel model) { if (getRequiredActionProviderByAlias(model.getAlias()) != null) { throw new ModelDuplicateException("A Required Action Provider with given alias already exists."); } - RequiredActionProviderEntity auth = new RequiredActionProviderEntity(); + RequiredActionProviderEntity action = new RequiredActionProviderEntity(); String id = (model.getId() == null) ? KeycloakModelUtils.generateId(): model.getId(); - auth.setId(id); - auth.setAlias(model.getAlias()); - auth.setName(model.getName()); - auth.setRealm(realm); - auth.setProviderId(model.getProviderId()); - auth.setConfig(model.getConfig()); - auth.setEnabled(model.isEnabled()); - auth.setDefaultAction(model.isDefaultAction()); - auth.setPriority(model.getPriority()); - realm.getRequiredActionProviders().add(auth); - em.persist(auth); + action.setId(id); + action.setAlias(model.getAlias()); + action.setName(model.getName()); + action.setRealm(realm); + action.setProviderId(model.getProviderId()); + action.setConfig(model.getConfig()); + action.setEnabled(model.isEnabled()); + action.setDefaultAction(model.isDefaultAction()); + action.setPriority(model.getPriority()); + realm.getRequiredActionProviders().add(action); + em.persist(action); em.flush(); - model.setId(auth.getId()); + model.setId(action.getId()); return model; } @@ -1896,9 +1976,11 @@ public RequiredActionProviderModel entityToModel(RequiredActionProviderEntity en model.setDefaultAction(entity.isDefaultAction()); model.setPriority(entity.getPriority()); model.setName(entity.getName()); - Map config = new HashMap<>(); - if (entity.getConfig() != null) config.putAll(entity.getConfig()); - model.setConfig(config); + if (entity.getConfig() != null) { + Map config = new HashMap<>(); + config.putAll(entity.getConfig()); + model.setConfig(config); + } return model; } diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/AuthenticationManagementResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/AuthenticationManagementResource.java index f90becab756f..e134a579c61f 100644 --- a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/AuthenticationManagementResource.java +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/AuthenticationManagementResource.java @@ -11,19 +11,28 @@ import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.resteasy.reactive.NoCache; import org.keycloak.admin.ui.rest.model.Authentication; import org.keycloak.admin.ui.rest.model.AuthenticationMapper; +import org.keycloak.admin.ui.rest.model.ConfigurableRequiredActionProviderRepresentation; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.ClientModel; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.utils.DefaultAuthenticationFlows; +import org.keycloak.representations.idm.RequiredActionProviderRepresentation; +import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; @@ -105,4 +114,39 @@ public final List listUsed(@PathParam("id") String id, @PathParam("type" throw new IllegalArgumentException("Invalid type"); } -} \ No newline at end of file + + /** + * Get required actions + * + * Returns a stream of required actions. + */ + @Path("required-actions") + @GET + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.AUTHENTICATION_MANAGEMENT) + @Operation( // + summary = "List all required actions for this realm.", + description = "List all required actions for this realm with Admin UI specific metadata." + ) + public Stream getRequiredActions() { + auth.realm().requireViewRequiredActions(); + return realm.getRequiredActionProvidersStream().map(this::toRepresentation); + } + + public ConfigurableRequiredActionProviderRepresentation toRepresentation(RequiredActionProviderModel model) { + ConfigurableRequiredActionProviderRepresentation rep = new ConfigurableRequiredActionProviderRepresentation(); + rep.setAlias(model.getAlias()); + rep.setProviderId(model.getProviderId()); + rep.setName(model.getName()); + rep.setDefaultAction(model.isDefaultAction()); + rep.setPriority(model.getPriority()); + rep.setEnabled(model.isEnabled()); + rep.setConfig(model.getConfig()); + + RequiredActionFactory factory = (RequiredActionFactory)session.getKeycloakSessionFactory().getProviderFactory(RequiredActionProvider.class, model.getProviderId()); + rep.setConfigurable(factory.isConfigurable()); + + return rep; + } +} diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/ConfigurableRequiredActionProviderRepresentation.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/ConfigurableRequiredActionProviderRepresentation.java new file mode 100644 index 000000000000..735ca0942f3c --- /dev/null +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/ConfigurableRequiredActionProviderRepresentation.java @@ -0,0 +1,32 @@ +/* + * 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.admin.ui.rest.model; + +import org.keycloak.representations.idm.RequiredActionProviderRepresentation; + +public class ConfigurableRequiredActionProviderRepresentation extends RequiredActionProviderRepresentation { + + private boolean configurable; + + public boolean isConfigurable() { + return configurable; + } + + public void setConfigurable(boolean configurable) { + this.configurable = configurable; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java index 7cc719700cdb..a8dc57856131 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionContext.java @@ -23,6 +23,7 @@ import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredActionConfigModel; import org.keycloak.models.UserModel; import org.keycloak.sessions.AuthenticationSessionModel; @@ -103,6 +104,12 @@ enum KcActionStatus { KeycloakSession getSession(); HttpRequest getHttpRequest(); + /** + * The configuration of the current required action. Returns {@literal null} if the current required action is not configurable. + * @return + */ + RequiredActionConfigModel getConfig(); + /** * Generates access code and updates clientsession timestamp * Access codes must be included in form action callbacks as a query parameter. diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionFactory.java b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionFactory.java index 517b5f4a4c43..4a624eefa7a0 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionFactory.java @@ -17,12 +17,16 @@ package org.keycloak.authentication; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredActionConfigModel; +import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderFactory; +import java.util.List; + /** - * You must specify a file - * META-INF/services/org.keycloak.authentication.RequiredActionFactory in the jar that this class is contained in - * This file must have the fully qualified class name of all your RequiredActionFactory classes + * Factory interface for {@link RequiredActionProvider RequiredActionProvider's}. * * @author Bill Burke * @version $Revision: 1 $ @@ -44,4 +48,23 @@ public interface RequiredActionFactory extends ProviderFactory configMetadata = getConfigMetadata(); + return configMetadata != null && !configMetadata.isEmpty(); + } + + /** + * Allows users to validate the provided configuration for this required action. Users can throw a {@link org.keycloak.models.ModelValidationException} to indicate that the configuration is invalid. + * + * @param session + * @param realm + * @param model + */ + default void validateConfig(KeycloakSession session, RealmModel realm, RequiredActionConfigModel model) { + } } diff --git a/server-spi-private/src/main/java/org/keycloak/events/admin/ResourceType.java b/server-spi-private/src/main/java/org/keycloak/events/admin/ResourceType.java index 729474f23626..5bd475cf443a 100644 --- a/server-spi-private/src/main/java/org/keycloak/events/admin/ResourceType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/admin/ResourceType.java @@ -63,6 +63,11 @@ public enum ResourceType { */ , AUTHENTICATOR_CONFIG + /** + * + */ + , REQUIRED_ACTION_CONFIG + /** * */ diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 310e8c89aa68..d55b3d9f68fa 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -33,7 +33,6 @@ import org.keycloak.authorization.store.PolicyStore; import org.keycloak.authorization.store.StoreFactory; import org.keycloak.common.Profile; -import org.keycloak.common.Profile.Feature; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.Time; import org.keycloak.component.ComponentModel; @@ -1230,4 +1229,9 @@ private static PolicyRepresentation toRepresentation(AuthorizationProvider autho } } + public static RequiredActionConfigRepresentation toRepresentation(RequiredActionConfigModel model) { + RequiredActionConfigRepresentation rep = new RequiredActionConfigRepresentation(); + rep.setConfig(model.getConfig()); + return rep; + } } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RealmModelDelegate.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RealmModelDelegate.java index b9be5bb0926a..fd6af3b1ff7c 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RealmModelDelegate.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RealmModelDelegate.java @@ -33,6 +33,7 @@ import org.keycloak.models.OTPPolicy; import org.keycloak.models.ParConfig; import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.RequiredActionConfigModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.RequiredCredentialModel; @@ -726,6 +727,31 @@ public AuthenticatorConfigModel getAuthenticatorConfigByAlias(String alias) { return delegate.getAuthenticatorConfigByAlias(alias); } + @Override + public RequiredActionConfigModel getRequiredActionConfigById(String id) { + return delegate.getRequiredActionConfigById(id); + } + + @Override + public RequiredActionConfigModel getRequiredActionConfigByAlias(String alias) { + return delegate.getRequiredActionConfigByAlias(alias); + } + + @Override + public void removeRequiredActionProviderConfig(RequiredActionConfigModel model) { + delegate.removeRequiredActionProviderConfig(model); + } + + @Override + public void updateRequiredActionConfig(RequiredActionConfigModel model) { + delegate.updateRequiredActionConfig(model); + } + + @Override + public Stream getRequiredActionConfigsStream() { + return delegate.getRequiredActionConfigsStream(); + } + public Stream getRequiredActionProvidersStream() { return delegate.getRequiredActionProvidersStream(); } diff --git a/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java b/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java index 876165274b0c..ba3dd1a40591 100644 --- a/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java +++ b/server-spi-private/src/main/java/org/keycloak/userprofile/ValidationException.java @@ -39,6 +39,12 @@ public final class ValidationException extends RuntimeException implements Consu private final Map> errors = new HashMap<>(); + public ValidationException() {} + + public ValidationException(ValidationError error) { + addError(error); + } + public List getErrors() { return errors.values().stream().reduce(new ArrayList<>(), (l, r) -> { l.addAll(r); diff --git a/server-spi-private/src/main/java/org/keycloak/utils/RequiredActionHelper.java b/server-spi-private/src/main/java/org/keycloak/utils/RequiredActionHelper.java new file mode 100644 index 000000000000..8784ded119ee --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/utils/RequiredActionHelper.java @@ -0,0 +1,69 @@ +/* + * 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.utils; + +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredActionProviderModel; +import org.keycloak.models.utils.Base32; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +/** + * Helpers for managing RequiredActions. + */ +public class RequiredActionHelper { + + private RequiredActionHelper() {} + + public static RequiredActionFactory getConfigurableRequiredActionFactory(KeycloakSession session, String providerId) { + RequiredActionFactory providerFactory = (RequiredActionFactory)session.getKeycloakSessionFactory().getProviderFactory(RequiredActionProvider.class, providerId); + + if (providerFactory == null) { + return null; + } + + List configMetadata = providerFactory.getConfigMetadata(); + if (configMetadata != null && !configMetadata.isEmpty()) { + return providerFactory; + } + + return null; + } + + public static RequiredActionFactory lookupConfigurableRequiredActionFactory(KeycloakSession session, String providerId) { + + RequiredActionFactory factory = getConfigurableRequiredActionFactory(session, providerId); + + if (factory == null) { + providerId = new String(Base32.decode(providerId)); + factory = getConfigurableRequiredActionFactory(session, providerId); + } + + return factory; + } + + public static RequiredActionProviderModel getRequiredActionByProviderId(RealmModel realm, String providerId) { + return realm.getRequiredActionProvidersStream() // + .filter(action -> action.getProviderId().equals(providerId)) // + .findFirst() // + .orElse(null); + } +} diff --git a/server-spi-private/src/test/java/org/keycloak/broker/provider/util/IdentityBrokerStateTestHelpers.java b/server-spi-private/src/test/java/org/keycloak/broker/provider/util/IdentityBrokerStateTestHelpers.java index f78b1659bfda..7e154e98aade 100644 --- a/server-spi-private/src/test/java/org/keycloak/broker/provider/util/IdentityBrokerStateTestHelpers.java +++ b/server-spi-private/src/test/java/org/keycloak/broker/provider/util/IdentityBrokerStateTestHelpers.java @@ -1774,6 +1774,31 @@ public void decreaseRemainingCount(ClientInitialAccessModel clientInitialAccess) } + @Override + public Stream getRequiredActionConfigsStream() { + return Stream.empty(); + } + + @Override + public RequiredActionConfigModel getRequiredActionConfigByAlias(String alias) { + return null; + } + + @Override + public RequiredActionConfigModel getRequiredActionConfigById(String id) { + return null; + } + + @Override + public void removeRequiredActionProviderConfig(RequiredActionConfigModel model) { + + } + + @Override + public void updateRequiredActionConfig(RequiredActionConfigModel model) { + + } + @Override public boolean isOrganizationsEnabled() { return false; diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java index 9569229f0c23..1fafd6f54085 100755 --- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java @@ -420,6 +420,12 @@ default Boolean getAttribute(String name, Boolean defaultValue) { AuthenticatorConfigModel getAuthenticatorConfigById(String id); AuthenticatorConfigModel getAuthenticatorConfigByAlias(String alias); + RequiredActionConfigModel getRequiredActionConfigById(String id); + RequiredActionConfigModel getRequiredActionConfigByAlias(String alias); + void removeRequiredActionProviderConfig(RequiredActionConfigModel model); + void updateRequiredActionConfig(RequiredActionConfigModel model); + Stream getRequiredActionConfigsStream(); + /** * Returns sorted {@link RequiredActionProviderModel RequiredActionProviderModel} as a stream. * It should be used with forEachOrdered if the ordering is required. diff --git a/server-spi/src/main/java/org/keycloak/models/RequiredActionConfigModel.java b/server-spi/src/main/java/org/keycloak/models/RequiredActionConfigModel.java new file mode 100755 index 000000000000..bea97fe34c81 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/RequiredActionConfigModel.java @@ -0,0 +1,87 @@ +/* + * 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.models; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +/** + * Holds the configuration for a required action. + */ +public class RequiredActionConfigModel implements Serializable { + + protected String id; + + protected String providerId; + + protected String alias; + + protected Map config = new HashMap<>(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProviderId() { + return providerId; + } + + public void setProviderId(String providerId) { + this.providerId = providerId; + } + + public Map getConfig() { + return config; + } + + public void setConfig(Map config) { + this.config = config; + } + + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public boolean containsConfigKey(String key) { + return config != null && config.containsKey(key); + } + + public String getConfigValue(String key) { + return getConfigValue(key, null); + } + + public String getConfigValue(String key, String defaultValue) { + if (config == null) { + return defaultValue; + } + String value = config.get(key); + if (value == null) { + return defaultValue; + } + return value; + } +} diff --git a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java index 557885335acc..df7bcc9a68d0 100755 --- a/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java +++ b/services/src/main/java/org/keycloak/authentication/RequiredActionContextResult.java @@ -26,13 +26,13 @@ import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredActionConfigModel; import org.keycloak.models.UserModel; import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.resources.LoginActionsService; import org.keycloak.sessions.AuthenticationSessionModel; import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriInfo; import java.net.URI; @@ -50,6 +50,7 @@ public class RequiredActionContextResult implements RequiredActionContext { protected HttpRequest httpRequest; protected UserModel user; protected RequiredActionFactory factory; + protected RequiredActionConfigModel config; public RequiredActionContextResult(AuthenticationSessionModel authSession, RealmModel realm, EventBuilder eventBuilder, KeycloakSession session, @@ -62,6 +63,11 @@ public RequiredActionContextResult(AuthenticationSessionModel authSession, this.httpRequest = httpRequest; this.user = user; this.factory = factory; + this.config = realm.getRequiredActionConfigByAlias(factory.getId()); + } + + public RequiredActionConfigModel getConfig() { + return config; } public RequiredActionFactory getFactory() { diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java index 627bb0848406..30063f388599 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java @@ -17,9 +17,15 @@ package org.keycloak.authentication.requiredactions; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; import org.keycloak.Config; -import org.keycloak.authentication.*; +import org.keycloak.authentication.AuthenticatorUtil; +import org.keycloak.authentication.InitiatedActionSupport; +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.common.util.Time; import org.keycloak.credential.CredentialModel; import org.keycloak.credential.CredentialProvider; @@ -29,19 +35,28 @@ import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredActionConfigModel; +import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.FormMessage; import org.keycloak.policy.MaxAuthAgePasswordPolicyProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; import org.keycloak.services.messages.Messages; import org.keycloak.services.validation.Validation; import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.userprofile.ValidationException; +import org.keycloak.utils.RequiredActionHelper; +import org.keycloak.validate.ValidationError; -import jakarta.ws.rs.core.MultivaluedMap; -import jakarta.ws.rs.core.Response; +import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -49,7 +64,30 @@ * @version $Revision: 1 $ */ public class UpdatePassword implements RequiredActionProvider, RequiredActionFactory { + private static final Logger logger = Logger.getLogger(UpdatePassword.class); + + private static final List CONFIG_PROPERTIES; + + public static final String MAX_AUTH_AGE_KEY = "max_auth_age"; + + static { + List properties = ProviderConfigurationBuilder.create() // + .property() // + .name(MAX_AUTH_AGE_KEY) // + .label("Maximum Age of Authentication") // + .helpText("Configures the duration in seconds this action can be used after the last authentication before the user is required to re-authenticate. " + // + "This parameter is used just in the context of AIA when the kc_action parameter is available in the request, which is for instance when user " + // + "himself updates his password in the account console. When the 'Maximum Authentication Age' password policy is used in the realm, it's value has " + // + "precedence over the value configured here.") // + .type(ProviderConfigProperty.STRING_TYPE) // + .defaultValue(MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE) // + .add() // + .build(); + + CONFIG_PROPERTIES = properties; + } + private final KeycloakSession session; @Override @@ -68,25 +106,25 @@ public UpdatePassword() { public UpdatePassword(KeycloakSession session) { this.session = session; } - + @Override public void evaluateTriggers(RequiredActionContext context) { - if(!AuthenticatorUtil.isPasswordValidated(context.getAuthenticationSession())) { + if (!AuthenticatorUtil.isPasswordValidated(context.getAuthenticationSession())) { return; } int daysToExpirePassword = context.getRealm().getPasswordPolicy().getDaysToExpirePassword(); - if(daysToExpirePassword != -1) { - PasswordCredentialProvider passwordProvider = (PasswordCredentialProvider)context.getSession().getProvider(CredentialProvider.class, PasswordCredentialProviderFactory.PROVIDER_ID); + if (daysToExpirePassword != -1) { + PasswordCredentialProvider passwordProvider = (PasswordCredentialProvider) context.getSession().getProvider(CredentialProvider.class, PasswordCredentialProviderFactory.PROVIDER_ID); CredentialModel password = passwordProvider.getPassword(context.getRealm(), context.getUser()); if (password != null) { - if(password.getCreatedDate() == null) { + if (password.getCreatedDate() == null) { context.getUser().addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD); logger.debug("User is required to update password"); } else { long timeElapsed = Time.toMillis(Time.currentTime()) - password.getCreatedDate(); long timeToExpire = TimeUnit.DAYS.toMillis(daysToExpirePassword); - if(timeElapsed > timeToExpire) { + if (timeElapsed > timeToExpire) { context.getUser().addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD); logger.debug("User is required to update password"); } @@ -205,12 +243,59 @@ public int getMaxAuthAge() { return MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE; } - int maxAge = session.getContext().getRealm().getPasswordPolicy().getMaxAuthAge(); - if (maxAge < 0) { - // passwordPolicy is not present fallback to default maxAuthAge - return MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE; + // try password policy + KeycloakContext keycloakContext = session.getContext(); + RealmModel realm = keycloakContext.getRealm(); + int maxAge = realm.getPasswordPolicy().getMaxAuthAge(); + if (maxAge >= 0) { + return maxAge; + } + + // try required action config + AuthenticationSessionModel authSession = keycloakContext.getAuthenticationSession(); + if (authSession != null) { + + // we need to figure out the alias for the current required action + String providerId = authSession.getClientNote(Constants.KC_ACTION); + RequiredActionProviderModel requiredAction = RequiredActionHelper.getRequiredActionByProviderId(realm, providerId); + + if (requiredAction != null) { + RequiredActionConfigModel configModel = realm.getRequiredActionConfigByAlias(requiredAction.getAlias()); + if (configModel != null && configModel.containsConfigKey(MAX_AUTH_AGE_KEY)) { + maxAge = parseMaxAuthAge(configModel); + if (maxAge >= 0) { + return maxAge; + } + } + } } - return maxAge; + // fallback to default + return MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE; + } + + + @Override + public List getConfigMetadata() { + return List.copyOf(CONFIG_PROPERTIES); + } + + @Override + public void validateConfig(KeycloakSession session, RealmModel realm, RequiredActionConfigModel model) { + + int parsedMaxAuthAge; + try { + parsedMaxAuthAge = parseMaxAuthAge(model); + } catch (Exception ex) { + throw new ValidationException(new ValidationError(getId(), MAX_AUTH_AGE_KEY, "error-invalid-value")); + } + + if (parsedMaxAuthAge < 0) { + throw new ValidationException(new ValidationError(getId(), MAX_AUTH_AGE_KEY, "error-number-out-of-range-too-small", 0)); + } + } + + private int parseMaxAuthAge(RequiredActionConfigModel model) throws NumberFormatException { + return Integer.parseInt(model.getConfigValue(MAX_AUTH_AGE_KEY)); } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java index b2385dad0604..e6e6f61988bc 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java @@ -40,8 +40,8 @@ import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticatorConfigModel; -import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RequiredActionConfigModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.utils.Base32; @@ -58,10 +58,14 @@ import org.keycloak.representations.idm.AuthenticatorConfigInfoRepresentation; import org.keycloak.representations.idm.AuthenticatorConfigRepresentation; import org.keycloak.representations.idm.ConfigPropertyRepresentation; +import org.keycloak.representations.idm.ErrorRepresentation; +import org.keycloak.representations.idm.RequiredActionConfigInfoRepresentation; +import org.keycloak.representations.idm.RequiredActionConfigRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.services.ErrorResponse; import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; +import org.keycloak.userprofile.ValidationException; import org.keycloak.utils.CredentialHelper; import jakarta.ws.rs.Consumes; @@ -88,6 +92,8 @@ import java.util.stream.Stream; import static jakarta.ws.rs.core.Response.Status.NOT_FOUND; + +import org.keycloak.utils.RequiredActionHelper; import org.keycloak.utils.ReservedCharValidator; /** @@ -1270,6 +1276,126 @@ public void lowerRequiredActionPriority(@Parameter(description = "Alias of requi adminEvent.operation(OperationType.UPDATE).resource(ResourceType.REQUIRED_ACTION).resourcePath(session.getContext().getUri()).success(); } + /** + * Get required actions provider's configuration description + */ + @Path("required-actions/{alias}/config-description") + @GET + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.AUTHENTICATION_MANAGEMENT) + @Operation( summary = "Get RequiredAction provider configuration description") + public RequiredActionConfigInfoRepresentation getRequiredActionConfigDescription(@Parameter(description = "Alias of required action") @PathParam("alias") String alias) { + auth.realm().requireViewRealm(); + + RequiredActionFactory factory = RequiredActionHelper.lookupConfigurableRequiredActionFactory(session, alias); + if (factory == null) { + throw new NotFoundException("Could not find configurable RequiredAction provider"); + } + + RequiredActionConfigInfoRepresentation rep = new RequiredActionConfigInfoRepresentation(); + rep.setProperties(new LinkedList<>()); + List configProperties = Optional.ofNullable(factory.getConfigMetadata()).orElse(Collections.emptyList()); + for (ProviderConfigProperty prop : configProperties) { + ConfigPropertyRepresentation propRep = getConfigPropertyRep(prop); + rep.getProperties().add(propRep); + } + return rep; + } + + /** + * Get the configuration of the RequiredAction provider in the current Realm. + * @param alias Provider id + */ + @Path("required-actions/{alias}/config") + @GET + @Produces(MediaType.APPLICATION_JSON) + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.AUTHENTICATION_MANAGEMENT) + @Operation( summary = "Get RequiredAction configuration") + public RequiredActionConfigRepresentation getRequiredActionConfig(@Parameter(description = "Alias of required action") @PathParam("alias") String alias) { + auth.realm().requireViewRealm(); + + RequiredActionFactory factory = RequiredActionHelper.lookupConfigurableRequiredActionFactory(session, alias); + if (factory == null) { + throw new BadRequestException("RequiredAction is not configurable"); + } + + RequiredActionConfigModel config = realm.getRequiredActionConfigByAlias(alias); + if (config == null) { + throw new NotFoundException("Could not find RequiredAction config"); + } + + return ModelToRepresentation.toRepresentation(config); + } + + /** + * Remove the configuration from the RequiredAction provider in the current Realm. + * @param alias Provider id + */ + @Path("required-actions/{alias}/config") + @DELETE + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.AUTHENTICATION_MANAGEMENT) + @Operation( summary = "Delete RequiredAction configuration") + public void removeRequiredActionConfig(@Parameter(description = "Alias of required action") @PathParam("alias") String alias) { + auth.realm().requireManageRealm(); + + RequiredActionFactory factory = RequiredActionHelper.lookupConfigurableRequiredActionFactory(session, alias); + if (factory == null) { + throw new BadRequestException("RequiredAction is not configurable"); + } + + RequiredActionConfigModel config = realm.getRequiredActionConfigByAlias(alias); + if (config == null) { + throw new NotFoundException("Could not find RequiredAction config"); + } + realm.removeRequiredActionProviderConfig(config); + + adminEvent.operation(OperationType.DELETE) // + .resource(ResourceType.REQUIRED_ACTION_CONFIG) // + .resourcePath(session.getContext().getUri()) // + .success(); + } + + /** + * Update the configuration of the RequiredAction provider in the current Realm. + * @param alias provider id + * @param rep JSON describing new state of RequiredAction configuration + */ + @Path("required-actions/{alias}/config") + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @NoCache + @Tag(name = KeycloakOpenAPI.Admin.Tags.AUTHENTICATION_MANAGEMENT) + @Operation( summary = "Update RequiredAction configuration") + public void updateRequiredActionConfig(@Parameter(description = "Alias of required action") @PathParam("alias") String alias, @Parameter(description = "JSON describing new state of required action configuration") RequiredActionConfigRepresentation rep) { + auth.realm().requireManageRealm(); + + RequiredActionFactory factory = RequiredActionHelper.lookupConfigurableRequiredActionFactory(session, alias); + if (factory == null) { + throw new BadRequestException("RequiredAction is not configurable"); + } + + RequiredActionConfigModel exists = realm.getRequiredActionConfigByAlias(alias); + if (exists == null) { + throw new NotFoundException("Could not find RequiredAction config"); + } + + exists.setConfig(RepresentationToModel.removeEmptyString(rep.getConfig())); + try { + realm.updateRequiredActionConfig(exists); + adminEvent.operation(OperationType.UPDATE) // + .resource(ResourceType.REQUIRED_ACTION_CONFIG) // + .resourcePath(session.getContext().getUri()) // + .representation(rep) // + .success(); + } catch (ValidationException ve) { + List errorReps = ve.getErrors().stream().map(err -> new ErrorRepresentation(err.getAttribute(), err.getMessage(), err.getMessageParameters())).toList(); + throw ErrorResponse.errors(errorReps, Response.Status.BAD_REQUEST); + } + } + /** * Get authenticator provider's configuration description */ diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/actions/DummyConfigurableRequiredActionFactory.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/actions/DummyConfigurableRequiredActionFactory.java new file mode 100755 index 000000000000..961e71f6f0b9 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/actions/DummyConfigurableRequiredActionFactory.java @@ -0,0 +1,120 @@ +/* + * 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 org.keycloak.Config; +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RequiredActionConfigModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +import java.util.List; +import java.util.Map; + +public class DummyConfigurableRequiredActionFactory implements RequiredActionFactory { + + public static final String PROVIDER_ID = "configurable-test-action"; + + public static final String SETTING_1 = "setting1"; + + public static final String SETTING_2 = "setting2"; + + @Override + public String getDisplayText() { + return "Configurable Test Action"; + } + + @Override + public RequiredActionProvider create(KeycloakSession session) { + return new RequiredActionProvider() { + @Override + public void evaluateTriggers(RequiredActionContext context) { + + } + + @Override + public void requiredActionChallenge(RequiredActionContext context) { + + // users can access the given Required Action configuration via RequiredActionContext#getContext() + RequiredActionConfigModel configModel = context.getConfig(); + Map config = configModel.getConfig(); + + String setting1Value = configModel.getConfigValue(SETTING_1); + + context.success(); + } + + @Override + public void processAction(RequiredActionContext context) { + } + + @Override + public void close() { + + } + }; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + private static final List CONFIG_PROPERTIES = ProviderConfigurationBuilder.create() // + .property() // + .name(SETTING_1) // + .label("Setting 1") // + .helpText("Setting 1 Help Text") // + .type(ProviderConfigProperty.STRING_TYPE) // + .defaultValue("setting1Default") // + .add() // + + .property() // + .name(SETTING_2) // + .label("Setting 2") // + .helpText("Setting 2 Help Text") // + .type(ProviderConfigProperty.BOOLEAN_TYPE) // + .defaultValue("true") // + .add() // + + .build(); + + @Override + public List getConfigMetadata() { + return CONFIG_PROPERTIES; + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory index 1992baef3871..5186b2f68905 100755 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory @@ -15,4 +15,5 @@ # limitations under the License. # -org.keycloak.testsuite.actions.DummyRequiredActionFactory \ No newline at end of file +org.keycloak.testsuite.actions.DummyRequiredActionFactory +org.keycloak.testsuite.actions.DummyConfigurableRequiredActionFactory \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java index c06ca1b4b0c3..44b45889805f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java @@ -21,8 +21,11 @@ import org.junit.Test; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; +import org.keycloak.representations.idm.RequiredActionConfigInfoRepresentation; +import org.keycloak.representations.idm.RequiredActionConfigRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation; +import org.keycloak.testsuite.actions.DummyConfigurableRequiredActionFactory; import org.keycloak.testsuite.actions.DummyRequiredActionFactory; import org.keycloak.testsuite.util.AdminEventPaths; @@ -92,7 +95,7 @@ public void testCRUDRequiredAction() { // Dummy RequiredAction is not registered in the realm and WebAuthn actions List result = authMgmtResource.getUnregisteredRequiredActions(); - Assert.assertEquals(1, result.size()); + Assert.assertEquals(2, result.size()); RequiredActionProviderSimpleRepresentation action = result.stream().filter( a -> a.getProviderId().equals(DummyRequiredActionFactory.PROVIDER_ID) ).findFirst().get(); @@ -166,6 +169,65 @@ public void testCRUDRequiredAction() { } + @Test + public void testConfigurableRequiredActionMetadata() { + + String providerId = DummyConfigurableRequiredActionFactory.PROVIDER_ID; + + // query configurable properties + RequiredActionConfigInfoRepresentation requiredActionConfigDescription = authMgmtResource.getRequiredActionConfigDescription(providerId); + Assert.assertNotNull(requiredActionConfigDescription); + Assert.assertNotNull(requiredActionConfigDescription.getProperties()); + Assert.assertTrue(requiredActionConfigDescription.getProperties().size() == 2); + } + + @Test + public void testCRUDConfigurableRequiredAction() { + int lastPriority = authMgmtResource.getRequiredActions().get(authMgmtResource.getRequiredActions().size() - 1).getPriority(); + + // Dummy RequiredAction is not registered in the realm and WebAuthn actions + List result = authMgmtResource.getUnregisteredRequiredActions(); + Assert.assertEquals(2, result.size()); + String providerId = DummyConfigurableRequiredActionFactory.PROVIDER_ID; + RequiredActionProviderSimpleRepresentation action = result.stream().filter( + a -> providerId.equals(a.getProviderId()) + ).findFirst().get(); + Assert.assertEquals(providerId, action.getProviderId()); + Assert.assertEquals("Configurable Test Action", action.getName()); + + // Register it + authMgmtResource.registerRequiredAction(action); + assertAdminEvents.assertEvent(testRealmId, OperationType.CREATE, AdminEventPaths.authMgmtBasePath() + "/register-required-action", action, ResourceType.REQUIRED_ACTION); + + RequiredActionConfigRepresentation requiredActionConfigRep = new RequiredActionConfigRepresentation(); + Map newActionConfig = Map.ofEntries(Map.entry("setting1", "value1"), Map.entry("setting2", "false")); + requiredActionConfigRep.setConfig(newActionConfig); + + authMgmtResource.updateRequiredActionConfig(providerId, requiredActionConfigRep); + assertAdminEvents.assertEvent(testRealmId, OperationType.UPDATE, AdminEventPaths.authRequiredActionConfigPath(providerId), ResourceType.REQUIRED_ACTION_CONFIG); + + RequiredActionConfigRepresentation savedRequiredActionConfigRep = authMgmtResource.getRequiredActionConfig(providerId); + Assert.assertNotNull(savedRequiredActionConfigRep); + Assert.assertNotNull(savedRequiredActionConfigRep.getConfig()); + Assert.assertTrue(savedRequiredActionConfigRep.getConfig().entrySet().containsAll(newActionConfig.entrySet())); + + // delete config + authMgmtResource.removeRequiredActionConfig(providerId); + assertAdminEvents.assertEvent(testRealmId, OperationType.DELETE, AdminEventPaths.authRequiredActionConfigPath(providerId), ResourceType.REQUIRED_ACTION_CONFIG); + + RequiredActionProviderRepresentation rep = authMgmtResource.getRequiredAction(providerId); + + // Remove success + authMgmtResource.removeRequiredAction(providerId); + assertAdminEvents.assertEvent(testRealmId, OperationType.DELETE, AdminEventPaths.authRequiredActionPath(rep.getAlias()), ResourceType.REQUIRED_ACTION); + + // Retrieval after deletion should throw a NotFound exception + try { + authMgmtResource.getRequiredActionConfig(providerId); + } catch (Exception ex) { + Assert.assertTrue(NotFoundException.class.isInstance(ex)); + } + } private RequiredActionProviderRepresentation findRequiredActionByAlias(String alias, List list) { for (RequiredActionProviderRepresentation a: list) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java index e544a9a3276d..bf20e1dceeb1 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java @@ -454,6 +454,12 @@ public static String authRequiredActionPath(String requiredActionAlias) { return uri.toString(); } + public static String authRequiredActionConfigPath(String requiredActionAlias) { + URI uri = UriBuilder.fromUri(authMgmtBasePath()).path(AuthenticationManagementResource.class, "getRequiredActionConfig") + .build(requiredActionAlias); + return uri.toString(); + } + public static String authRaiseRequiredActionPath(String requiredActionAlias) { URI uri = UriBuilder.fromUri(authMgmtBasePath()).path(AuthenticationManagementResource.class, "raiseRequiredActionPriority") .build(requiredActionAlias);