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 <thomas.darimont@googlemail.com> Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
parent
37f85937a7
commit
ab376d9101
36 changed files with 1389 additions and 113 deletions
|
@ -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<ConfigPropertyRepresentation> properties;
|
||||
|
||||
public List<ConfigPropertyRepresentation> getProperties() {
|
||||
return properties;
|
||||
}
|
||||
|
||||
public void setProperties(List<ConfigPropertyRepresentation> properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String> config = new HashMap<>();
|
||||
|
||||
public Map<String, String> getConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
public void setConfig(Map<String, String> config) {
|
||||
this.config = config;
|
||||
}
|
||||
}
|
|
@ -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<String, String> config = new HashMap<>();
|
||||
|
||||
private Map<String, String> config;
|
||||
|
||||
public String getAlias() {
|
||||
return alias;
|
||||
|
|
|
@ -52,4 +52,5 @@ public class RequiredActionProviderSimpleRepresentation {
|
|||
public void setProviderId(String providerId) {
|
||||
this.providerId = providerId;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -23,6 +23,8 @@ import org.keycloak.representations.idm.AuthenticationFlowRepresentation;
|
|||
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)
|
||||
|
|
|
@ -556,7 +556,7 @@ syncModes.inherit=继承
|
|||
masterSamlProcessingUrlHelp=如果配置,则此 URL 将用于每个绑定到 SP 的断言消费者和单点注销服务。这可以在 Fine Grain SAML 端点配置中为每个绑定和服务单独覆写。
|
||||
addedGroupMembershipError=添加群组成员身份时出错
|
||||
authenticatorAttachment.platform=平台
|
||||
configSaveSuccess=成功保存执行器的配置
|
||||
configSaveSuccess=成功保存配置
|
||||
regenerate=重新生成
|
||||
ignoreMissingGroups=忽略缺失的群组
|
||||
sslType.external=外部请求
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Row[]>();
|
||||
const [selectedAction, setSelectedAction] = useState<DataType>();
|
||||
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 (
|
||||
<DraggableTable
|
||||
keyField="name"
|
||||
onDragFinish={async (nameDragged, items) => {
|
||||
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 && (
|
||||
<RequiredActionConfigModal
|
||||
requiredAction={selectedAction}
|
||||
onClose={() => setSelectedAction(undefined)}
|
||||
/>
|
||||
)}
|
||||
<DraggableTable
|
||||
keyField="name"
|
||||
onDragFinish={async (nameDragged, items) => {
|
||||
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) => (
|
||||
<Switch
|
||||
id={`enable-${toKey(row.name)}`}
|
||||
label={t("on")}
|
||||
labelOff={t("off")}
|
||||
isChecked={row.enabled}
|
||||
onChange={() => {
|
||||
updateAction(row.data, "enabled");
|
||||
}}
|
||||
aria-label={toKey(row.name)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "default",
|
||||
displayKey: "setAsDefaultAction",
|
||||
thTooltipText: "authDefaultActionTooltip",
|
||||
cellRenderer: (row) => (
|
||||
<Switch
|
||||
id={`default-${toKey(row.name)}`}
|
||||
label={t("on")}
|
||||
isDisabled={!row.enabled}
|
||||
labelOff={!row.enabled ? t("disabledOff") : t("off")}
|
||||
isChecked={row.defaultAction}
|
||||
onChange={() => {
|
||||
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) => (
|
||||
<Switch
|
||||
id={`enable-${toKey(row.name)}`}
|
||||
label={t("on")}
|
||||
labelOff={t("off")}
|
||||
isChecked={row.enabled}
|
||||
onChange={() => {
|
||||
updateAction(row.data, "enabled");
|
||||
}}
|
||||
aria-label={toKey(row.name)}
|
||||
/>
|
||||
),
|
||||
width: 20,
|
||||
},
|
||||
{
|
||||
name: "default",
|
||||
displayKey: "setAsDefaultAction",
|
||||
thTooltipText: "authDefaultActionTooltip",
|
||||
cellRenderer: (row) => (
|
||||
<Switch
|
||||
id={`default-${toKey(row.name)}`}
|
||||
label={t("on")}
|
||||
isDisabled={!row.enabled}
|
||||
labelOff={!row.enabled ? t("disabledOff") : t("off")}
|
||||
isChecked={row.defaultAction}
|
||||
onChange={() => {
|
||||
updateAction(row.data, "defaultAction");
|
||||
}}
|
||||
aria-label={toKey(row.name)}
|
||||
/>
|
||||
),
|
||||
width: 20,
|
||||
},
|
||||
{
|
||||
name: "config",
|
||||
displayKey: "configure",
|
||||
cellRenderer: (row) =>
|
||||
row.data.configurable ? (
|
||||
<Button
|
||||
variant="plain"
|
||||
aria-label={t("settings")}
|
||||
onClick={() => setSelectedAction(row.data)}
|
||||
>
|
||||
<CogIcon />
|
||||
</Button>
|
||||
) : undefined,
|
||||
width: 10,
|
||||
},
|
||||
]}
|
||||
data={actions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<T> = {
|
||||
export type Field<T> = Pick<ThProps, "width"> & {
|
||||
name: string;
|
||||
displayKey?: string;
|
||||
cellRenderer?: (row: T) => ReactNode;
|
||||
|
@ -199,7 +200,12 @@ export function DraggableTable<T>({
|
|||
<Tr>
|
||||
<Th aria-hidden="true" />
|
||||
{columns.map((column) => (
|
||||
<Th key={column.name} info={thInfo(column)}>
|
||||
<Th
|
||||
key={column.name}
|
||||
info={thInfo(column)}
|
||||
width={column.width}
|
||||
modifier="fitContent"
|
||||
>
|
||||
{t(column.displayKey || column.name)}
|
||||
</Th>
|
||||
))}
|
||||
|
|
|
@ -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<RequiredActionConfigInfoRepresentation>();
|
||||
|
||||
const form = useForm<RequiredActionConfigModalForm>();
|
||||
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 (
|
||||
<Modal
|
||||
variant={ModalVariant.small}
|
||||
isOpen
|
||||
title={t("requiredActionConfig", { name: requiredAction.name })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Form id="required-action-config-form" onSubmit={handleSubmit(save)}>
|
||||
<FormProvider {...form}>
|
||||
<DynamicComponents
|
||||
stringify
|
||||
properties={configDescription?.properties || []}
|
||||
/>
|
||||
</FormProvider>
|
||||
<ActionGroup>
|
||||
<Button data-testid="save" variant="primary" type="submit">
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="cancel"
|
||||
variant={ButtonVariant.link}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="pf-v5-u-ml-3xl"
|
||||
data-testid="clear"
|
||||
variant={ButtonVariant.link}
|
||||
onClick={async () => {
|
||||
await adminClient.authenticationManagement.removeRequiredActionConfig(
|
||||
{
|
||||
alias: requiredAction.alias!,
|
||||
},
|
||||
);
|
||||
form.reset({});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("clear")} <TrashIcon />
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
import { ConfigPropertyRepresentation } from "./configPropertyRepresentation.js";
|
||||
|
||||
export default interface RequiredActionConfigInfoRepresentation {
|
||||
properties?: ConfigPropertyRepresentation[];
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default interface RequiredActionConfigRepresentation {
|
||||
config?: { [index: string]: string };
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -1416,6 +1416,36 @@ public class RealmAdapter implements CachedRealmModel {
|
|||
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<RequiredActionConfigModel> getRequiredActionConfigsStream() {
|
||||
if (isUpdated()) return updated.getRequiredActionConfigsStream();
|
||||
return cached.getRequiredActionProviderConfigsByAlias().values().stream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RequiredActionProviderModel> getRequiredActionProvidersStream() {
|
||||
if (isUpdated()) return updated.getRequiredActionProvidersStream();
|
||||
|
|
|
@ -43,6 +43,7 @@ import org.keycloak.models.OAuth2DeviceConfig;
|
|||
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<String, AuthenticationFlowModel> authenticationFlows = new HashMap<>();
|
||||
protected List<AuthenticationFlowModel> authenticationFlowList;
|
||||
protected Map<String, AuthenticatorConfigModel> authenticatorConfigs;
|
||||
protected Map<String, RequiredActionConfigModel> requiredActionProviderConfigs = new HashMap<>();
|
||||
protected Map<String, RequiredActionConfigModel> requiredActionProviderConfigsByAlias = new HashMap<>();
|
||||
protected Map<String, RequiredActionProviderModel> requiredActionProviders = new HashMap<>();
|
||||
protected List<RequiredActionProviderModel> requiredActionProviderList;
|
||||
protected Map<String, RequiredActionProviderModel> requiredActionProvidersByAlias = new HashMap<>();
|
||||
|
@ -290,9 +293,15 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
|||
|
||||
authenticatorConfigs = model.getAuthenticatorConfigsStream()
|
||||
.collect(Collectors.toMap(AuthenticatorConfigModel::getId, Function.identity()));
|
||||
List<RequiredActionConfigModel> 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 class CachedRealm extends AbstractExtendableRevisioned {
|
|||
public Map<String, Map<String, String>> getRealmLocalizationTexts() {
|
||||
return realmLocalizationTexts;
|
||||
}
|
||||
|
||||
public Map<String, RequiredActionConfigModel> getRequiredActionProviderConfigsByAlias() {
|
||||
return requiredActionProviderConfigsByAlias;
|
||||
}
|
||||
|
||||
public Map<String, RequiredActionConfigModel> getRequiredActionProviderConfigs() {
|
||||
return requiredActionProviderConfigs;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ package org.keycloak.models.jpa;
|
|||
|
||||
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.GroupModel.GroupUpdatedEvent;
|
|||
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 class RealmAdapter implements StorageProviderRealmModel, JpaModel<RealmEn
|
|||
return realm.getAuthenticatorConfigs().stream().map(this::entityToModel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RequiredActionConfigModel> 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<String, String> 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 class RealmAdapter implements StorageProviderRealmModel, JpaModel<RealmEn
|
|||
model.setDefaultAction(entity.isDefaultAction());
|
||||
model.setPriority(entity.getPriority());
|
||||
model.setName(entity.getName());
|
||||
Map<String, String> config = new HashMap<>();
|
||||
if (entity.getConfig() != null) config.putAll(entity.getConfig());
|
||||
model.setConfig(config);
|
||||
if (entity.getConfig() != null) {
|
||||
Map<String, String> config = new HashMap<>();
|
||||
config.putAll(entity.getConfig());
|
||||
model.setConfig(config);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
|
|
|
@ -11,19 +11,28 @@ import jakarta.ws.rs.Path;
|
|||
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 class AuthenticationManagementResource extends RoleMappingResource {
|
|||
|
||||
throw new IllegalArgumentException("Invalid type");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<RequiredActionProviderRepresentation> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ import org.keycloak.events.EventBuilder;
|
|||
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 @@ public interface RequiredActionContext {
|
|||
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.
|
||||
|
|
|
@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
|
||||
* @version $Revision: 1 $
|
||||
|
@ -44,4 +48,23 @@ public interface RequiredActionFactory extends ProviderFactory<RequiredActionPro
|
|||
default boolean isOneTimeAction() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this required action can be configured via the admin ui.
|
||||
* @return
|
||||
*/
|
||||
default boolean isConfigurable() {
|
||||
List<ProviderConfigProperty> 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) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,6 +63,11 @@ public enum ResourceType {
|
|||
*/
|
||||
, AUTHENTICATOR_CONFIG
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
, REQUIRED_ACTION_CONFIG
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
|
|
|
@ -33,7 +33,6 @@ import org.keycloak.authorization.policy.provider.PolicyProviderFactory;
|
|||
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 @@ public class ModelToRepresentation {
|
|||
}
|
||||
}
|
||||
|
||||
public static RequiredActionConfigRepresentation toRepresentation(RequiredActionConfigModel model) {
|
||||
RequiredActionConfigRepresentation rep = new RequiredActionConfigRepresentation();
|
||||
rep.setConfig(model.getConfig());
|
||||
return rep;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.keycloak.models.OAuth2DeviceConfig;
|
|||
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 class RealmModelDelegate implements RealmModel {
|
|||
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<RequiredActionConfigModel> getRequiredActionConfigsStream() {
|
||||
return delegate.getRequiredActionConfigsStream();
|
||||
}
|
||||
|
||||
public Stream<RequiredActionProviderModel> getRequiredActionProvidersStream() {
|
||||
return delegate.getRequiredActionProvidersStream();
|
||||
}
|
||||
|
|
|
@ -39,6 +39,12 @@ public final class ValidationException extends RuntimeException implements Consu
|
|||
|
||||
private final Map<String, List<Error>> errors = new HashMap<>();
|
||||
|
||||
public ValidationException() {}
|
||||
|
||||
public ValidationException(ValidationError error) {
|
||||
addError(error);
|
||||
}
|
||||
|
||||
public List<Error> getErrors() {
|
||||
return errors.values().stream().reduce(new ArrayList<>(), (l, r) -> {
|
||||
l.addAll(r);
|
||||
|
|
|
@ -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<ProviderConfigProperty> 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);
|
||||
}
|
||||
}
|
|
@ -1774,6 +1774,31 @@ public class IdentityBrokerStateTestHelpers {
|
|||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<RequiredActionConfigModel> 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;
|
||||
|
|
|
@ -420,6 +420,12 @@ public interface RealmModel extends RoleContainerModel {
|
|||
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<RequiredActionConfigModel> getRequiredActionConfigsStream();
|
||||
|
||||
/**
|
||||
* Returns sorted {@link RequiredActionProviderModel RequiredActionProviderModel} as a stream.
|
||||
* It should be used with forEachOrdered if the ordering is required.
|
||||
|
|
87
server-spi/src/main/java/org/keycloak/models/RequiredActionConfigModel.java
Executable file
87
server-spi/src/main/java/org/keycloak/models/RequiredActionConfigModel.java
Executable file
|
@ -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<String, String> 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<String, String> getConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
public void setConfig(Map<String, String> 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;
|
||||
}
|
||||
}
|
|
@ -26,13 +26,13 @@ import org.keycloak.models.ClientModel;
|
|||
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 class RequiredActionContextResult implements RequiredActionContext {
|
|||
this.httpRequest = httpRequest;
|
||||
this.user = user;
|
||||
this.factory = factory;
|
||||
this.config = realm.getRequiredActionConfigByAlias(factory.getId());
|
||||
}
|
||||
|
||||
public RequiredActionConfigModel getConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
public RequiredActionFactory getFactory() {
|
||||
|
|
|
@ -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.Details;
|
|||
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 @@ import java.util.concurrent.TimeUnit;
|
|||
* @version $Revision: 1 $
|
||||
*/
|
||||
public class UpdatePassword implements RequiredActionProvider, RequiredActionFactory {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(UpdatePassword.class);
|
||||
|
||||
private static final List<ProviderConfigProperty> CONFIG_PROPERTIES;
|
||||
|
||||
public static final String MAX_AUTH_AGE_KEY = "max_auth_age";
|
||||
|
||||
static {
|
||||
List<ProviderConfigProperty> 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 class UpdatePassword implements RequiredActionProvider, RequiredActionFac
|
|||
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 class UpdatePassword implements RequiredActionProvider, RequiredActionFac
|
|||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to default
|
||||
return MaxAuthAgePasswordPolicyProviderFactory.DEFAULT_MAX_AUTH_AGE;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> 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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,8 +40,8 @@ import org.keycloak.events.admin.ResourceType;
|
|||
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.AuthenticationFlowRepresentation;
|
|||
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.Collectors;
|
|||
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 class AuthenticationManagementResource {
|
|||
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<ProviderConfigProperty> 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<ErrorRepresentation> 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
|
||||
*/
|
||||
|
|
|
@ -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<String, String> 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<ProviderConfigProperty> 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<ProviderConfigProperty> getConfigMetadata() {
|
||||
return CONFIG_PROPERTIES;
|
||||
}
|
||||
}
|
|
@ -15,4 +15,5 @@
|
|||
# limitations under the License.
|
||||
#
|
||||
|
||||
org.keycloak.testsuite.actions.DummyRequiredActionFactory
|
||||
org.keycloak.testsuite.actions.DummyRequiredActionFactory
|
||||
org.keycloak.testsuite.actions.DummyConfigurableRequiredActionFactory
|
|
@ -21,8 +21,11 @@ import org.junit.Assert;
|
|||
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 class RequiredActionsTest extends AbstractAuthenticationTest {
|
|||
|
||||
// Dummy RequiredAction is not registered in the realm and WebAuthn actions
|
||||
List<RequiredActionProviderSimpleRepresentation> 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 class RequiredActionsTest extends AbstractAuthenticationTest {
|
|||
|
||||
}
|
||||
|
||||
@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<RequiredActionProviderSimpleRepresentation> 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<String, String> 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<RequiredActionProviderRepresentation> list) {
|
||||
for (RequiredActionProviderRepresentation a: list) {
|
||||
|
|
|
@ -454,6 +454,12 @@ public class AdminEventPaths {
|
|||
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);
|
||||
|
|
Loading…
Reference in a new issue