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:
Thomas Darimont 2024-04-03 00:42:32 +02:00 committed by Marek Posolda
parent 37f85937a7
commit ab376d9101
36 changed files with 1389 additions and 113 deletions

View file

@ -0,0 +1,37 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.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;
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -52,4 +52,5 @@ public class RequiredActionProviderSimpleRepresentation {
public void setProviderId(String providerId) {
this.providerId = providerId;
}
}

View file

@ -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)

View file

@ -556,7 +556,7 @@ syncModes.inherit=继承
masterSamlProcessingUrlHelp=如果配置,则此 URL 将用于每个绑定到 SP 的断言消费者和单点注销服务。这可以在 Fine Grain SAML 端点配置中为每个绑定和服务单独覆写。
addedGroupMembershipError=添加群组成员身份时出错
authenticatorAttachment.platform=平台
configSaveSuccess=成功保存执行器的配置
configSaveSuccess=成功保存配置
regenerate=重新生成
ignoreMissingGroups=忽略缺失的群组
sslType.external=外部请求

View file

@ -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

View file

@ -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}
/>
</>
);
};

View file

@ -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>
))}

View file

@ -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>
);
};

View file

@ -0,0 +1,5 @@
import { ConfigPropertyRepresentation } from "./configPropertyRepresentation.js";
export default interface RequiredActionConfigInfoRepresentation {
properties?: ConfigPropertyRepresentation[];
}

View file

@ -0,0 +1,3 @@
export default interface RequiredActionConfigRepresentation {
config?: { [index: string]: string };
}

View file

@ -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

View file

@ -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();

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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.

View file

@ -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) {
}
}

View file

@ -63,6 +63,11 @@ public enum ResourceType {
*/
, AUTHENTICATOR_CONFIG
/**
*
*/
, REQUIRED_ACTION_CONFIG
/**
*
*/

View file

@ -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;
}
}

View file

@ -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();
}

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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.

View 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;
}
}

View file

@ -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() {

View file

@ -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
@ -71,22 +109,22 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
@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));
}
}

View file

@ -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
*/

View file

@ -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;
}
}

View file

@ -16,3 +16,4 @@
#
org.keycloak.testsuite.actions.DummyRequiredActionFactory
org.keycloak.testsuite.actions.DummyConfigurableRequiredActionFactory

View file

@ -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) {

View file

@ -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);