From 41b706bb6a9d18acdcbc531571c9370aead9a697 Mon Sep 17 00:00:00 2001 From: rmartinc Date: Tue, 2 Apr 2024 13:23:33 +0200 Subject: [PATCH] Initial security profile SPI to integrate default client policies Closes #27189 Signed-off-by: rmartinc --- .../idm/ClientPoliciesRepresentation.java | 9 + .../idm/SecurityProfileConfiguration.java | 80 ++++++ .../ClientPoliciesPoliciesResource.java | 5 + .../admin/messages/messages_en.properties | 1 + .../src/realm-settings/NewClientPolicy.tsx | 201 ++++++++------ .../NewClientPolicyCondition.tsx | 100 ++++--- .../src/realm-settings/PoliciesTab.tsx | 85 ++++-- .../src/defs/clientPoliciesRepresentation.ts | 1 + .../src/resources/clientPolicies.ts | 9 +- .../SecurityProfileProvider.java | 51 ++++ .../SecurityProfileProviderFactory.java | 27 ++ .../securityprofile/SecurityProfileSpi.java | 48 ++++ .../services/org.keycloak.provider.Spi | 1 + .../clientpolicy/ClientPolicyManager.java | 3 +- .../clientpolicy/ClientPoliciesUtil.java | 246 ++++++++++++------ .../DefaultClientPolicyManager.java | 25 +- .../DefaultClientPolicyManagerFactory.java | 32 +-- .../admin/ClientPoliciesResource.java | 6 +- .../DefaultSecurityProfileProvider.java | 56 ++++ ...DefaultSecurityProfileProviderFactory.java | 116 +++++++++ ...rityprofile.SecurityProfileProviderFactory | 1 + .../keycloak-strict-client-policies.json | 68 +++++ .../main/resources/lax-security-profile.json | 5 + .../main/resources/none-security-profile.json | 5 + .../resources/strict-security-profile.json | 5 + ...faultSecurityProfileProverFactoryTest.java | 81 ++++++ .../annotation/SetDefaultProvider.java | 6 + .../util/SpiProvidersSwitchingUtils.java | 59 ++++- .../policies/AbstractClientPoliciesTest.java | 1 - .../StrictSecurityProfileTest.java | 200 ++++++++++++++ .../resources/META-INF/keycloak-server.json | 7 + .../tests/base/testsuites/base-suite | 3 +- .../resources/META-INF/keycloak-server.json | 8 +- 33 files changed, 1274 insertions(+), 277 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/idm/SecurityProfileConfiguration.java create mode 100644 server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileProvider.java create mode 100644 server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileProviderFactory.java create mode 100644 server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileSpi.java create mode 100644 services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProvider.java create mode 100644 services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProviderFactory.java create mode 100644 services/src/main/resources/META-INF/services/org.keycloak.securityprofile.SecurityProfileProviderFactory create mode 100644 services/src/main/resources/keycloak-strict-client-policies.json create mode 100644 services/src/main/resources/lax-security-profile.json create mode 100644 services/src/main/resources/none-security-profile.json create mode 100644 services/src/main/resources/strict-security-profile.json create mode 100644 services/src/test/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProverFactoryTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/securityprofile/StrictSecurityProfileTest.java diff --git a/core/src/main/java/org/keycloak/representations/idm/ClientPoliciesRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/ClientPoliciesRepresentation.java index fdb5fa493d..f2a076c707 100644 --- a/core/src/main/java/org/keycloak/representations/idm/ClientPoliciesRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/ClientPoliciesRepresentation.java @@ -30,6 +30,7 @@ import org.keycloak.util.JsonSerialization; */ public class ClientPoliciesRepresentation { protected List policies = new ArrayList<>(); + private List globalPolicies; public List getPolicies() { return policies; @@ -39,6 +40,14 @@ public class ClientPoliciesRepresentation { this.policies = policies; } + public List getGlobalPolicies() { + return globalPolicies; + } + + public void setGlobalPolicies(List globalPolicies) { + this.globalPolicies = globalPolicies; + } + @Override public int hashCode() { return JsonSerialization.mapper.convertValue(this, JsonNode.class).hashCode(); diff --git a/core/src/main/java/org/keycloak/representations/idm/SecurityProfileConfiguration.java b/core/src/main/java/org/keycloak/representations/idm/SecurityProfileConfiguration.java new file mode 100644 index 0000000000..0da5f06ab7 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/idm/SecurityProfileConfiguration.java @@ -0,0 +1,80 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Default configuration for security profile. For the moment just a name and pointers + * to default global client profiles and policies. + * + * @author rmartinc + */ +public class SecurityProfileConfiguration { + + private String name; + @JsonProperty("client-profiles") + private String clientProfiles; + @JsonProperty("client-policies") + private String clientPolicies; + @JsonIgnore + private List defaultClientProfiles; + @JsonIgnore + private List defaultClientPolicies; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getClientProfiles() { + return clientProfiles; + } + + public void setClientProfiles(String clientProfiles) { + this.clientProfiles = clientProfiles; + } + + public String getClientPolicies() { + return clientPolicies; + } + + public void setClientPolicies(String clientPolicies) { + this.clientPolicies = clientPolicies; + } + + public List getDefaultClientProfiles() { + return defaultClientProfiles; + } + + public void setDefaultClientProfiles(List defaultClientProfiles) { + this.defaultClientProfiles = defaultClientProfiles; + } + + public List getDefaultClientPolicies() { + return defaultClientPolicies; + } + + public void setDefaultClientPolicies(List defaultClientPolicies) { + this.defaultClientPolicies = defaultClientPolicies; + } +} diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/ClientPoliciesPoliciesResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/ClientPoliciesPoliciesResource.java index 66b683ba79..c9ae3fa3da 100644 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/ClientPoliciesPoliciesResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/ClientPoliciesPoliciesResource.java @@ -4,6 +4,7 @@ import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.PUT; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; import org.keycloak.representations.idm.ClientPoliciesRepresentation; @@ -17,6 +18,10 @@ public interface ClientPoliciesPoliciesResource { @Produces(MediaType.APPLICATION_JSON) ClientPoliciesRepresentation getPolicies(); + @GET + @Produces(MediaType.APPLICATION_JSON) + ClientPoliciesRepresentation getPolicies(@QueryParam("include-global-policies") Boolean includeGlobalPolicies); + @PUT @Consumes(MediaType.APPLICATION_JSON) void updatePolicies(final ClientPoliciesRepresentation clientPolicies); diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index d20645bedb..9f202522c2 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -1586,6 +1586,7 @@ times.minutes=Minutes disableUserInfo=Disable user info authorizationEncryptedResponseEnc=Authorization response encryption content encryption algorithm editCondition=Edit condition +viewCondition=View condition ssoSessionMaxRememberMe=Max time before a session is expired when a user has set the remember me option. Tokens and browser sessions are invalidated when a session is expired. If not set it uses the standard SSO Session Max value. forcePostBinding=Force POST binding usersExplain=Users are the users in the current realm. diff --git a/js/apps/admin-ui/src/realm-settings/NewClientPolicy.tsx b/js/apps/admin-ui/src/realm-settings/NewClientPolicy.tsx index b11a8f5f1e..cacc42753b 100644 --- a/js/apps/admin-ui/src/realm-settings/NewClientPolicy.tsx +++ b/js/apps/admin-ui/src/realm-settings/NewClientPolicy.tsx @@ -14,6 +14,7 @@ import { Flex, FlexItem, FormGroup, + Label, PageSection, Text, TextVariants, @@ -67,7 +68,12 @@ export default function NewClientPolicy() { const { t } = useTranslation(); const { realm } = useRealm(); const { addAlert, addError } = useAlerts(); + const [isGlobalPolicy, setIsGlobalPolicy] = useState(false); const [policies, setPolicies] = useState(); + const [globalPolicies, setGlobalPolicies] = + useState(); + const [allPolicies, setAllPolicies] = + useState(); const [clientProfiles, setClientProfiles] = useState< ClientProfileRepresentation[] >([]); @@ -101,7 +107,9 @@ export default function NewClientPolicy() { useFetch( async () => { const [policies, profiles] = await Promise.all([ - adminClient.clientPolicies.listPolicies(), + adminClient.clientPolicies.listPolicies({ + includeGlobalPolicies: true, + }), adminClient.clientPolicies.listProfiles({ includeGlobalProfiles: true, }), @@ -110,16 +118,29 @@ export default function NewClientPolicy() { return { policies, profiles }; }, ({ policies, profiles }) => { - const currentPolicy = policies.policies?.find( + let currentPolicy = policies.policies?.find( (item) => item.name === policyName, ); + if (currentPolicy === undefined) { + currentPolicy = policies.globalPolicies?.find( + (item) => item.name === policyName, + ); + setIsGlobalPolicy(currentPolicy !== undefined); + } const allClientProfiles = [ ...(profiles.globalProfiles ?? []), ...(profiles.profiles ?? []), ]; + const allClientPolicies = [ + ...(policies.globalPolicies ?? []), + ...(policies.policies ?? []), + ]; + setPolicies(policies.policies ?? []); + setGlobalPolicies(policies.globalPolicies ?? []); + setAllPolicies(allClientPolicies); if (currentPolicy) { setupForm(currentPolicy); setClientProfiles(allClientProfiles); @@ -134,7 +155,7 @@ export default function NewClientPolicy() { form.reset(policy); }; - const policy = (policies || []).filter( + const policy = (allPolicies || []).filter( (policy) => policy.name === policyName, ); const policyConditions = policy[0]?.conditions || []; @@ -151,8 +172,6 @@ export default function NewClientPolicy() { const createdForm = form.getValues(); const createdPolicy = { ...createdForm, - profiles: [], - conditions: [], }; const getAllPolicies = () => { @@ -279,6 +298,7 @@ export default function NewClientPolicy() { policies: policies, }); addAlert(t("deleteClientPolicyProfileSuccess"), AlertVariant.success); + form.setValue("profiles", currentPolicy?.profiles || []); navigate(toEditClientPolicy({ realm, policyName: formValues.name! })); } catch (error) { addError(t("deleteClientPolicyProfileError"), error); @@ -346,6 +366,10 @@ export default function NewClientPolicy() { policies: newPolicies, }); setPolicies(newPolicies); + const allClientPolicies = [...(globalPolicies || []), ...newPolicies]; + setAllPolicies(allClientPolicies); + setCurrentPolicy(createdPolicy); + form.setValue("profiles", createdPolicy.profiles); navigate(toEditClientPolicy({ realm, policyName: formValues.name! })); addAlert(t("addClientProfileSuccess"), AlertVariant.success); } catch (error) { @@ -393,9 +417,20 @@ export default function NewClientPolicy() { ? policyName : "createPolicy" } + badges={[ + { + id: "global-client-policy-badge", + text: isGlobalPolicy ? ( + + ) : ( + "" + ), + }, + ]} divider dropdownItems={ - showAddConditionsAndProfilesForm || policyName + (showAddConditionsAndProfilesForm || policyName) && + !isGlobalPolicy ? [ { if (!value) { @@ -455,7 +491,7 @@ export default function NewClientPolicy() { variant="primary" type="submit" data-testid="saveCreatePolicy" - isDisabled={!form.formState.isValid} + isDisabled={!form.formState.isValid || isGlobalPolicy} > {t("save")} @@ -463,7 +499,8 @@ export default function NewClientPolicy() { id="cancelCreatePolicy" variant="link" onClick={() => - showAddConditionsAndProfilesForm || policyName + (showAddConditionsAndProfilesForm || policyName) && + !isGlobalPolicy ? reset() : navigate( toClientPolicies({ @@ -474,7 +511,9 @@ export default function NewClientPolicy() { } data-testid="cancelCreatePolicy" > - {showAddConditionsAndProfilesForm ? t("reload") : t("cancel")} + {showAddConditionsAndProfilesForm && !isGlobalPolicy + ? t("reload") + : t("cancel")} {(showAddConditionsAndProfilesForm || @@ -490,26 +529,28 @@ export default function NewClientPolicy() { /> - - - + {!isGlobalPolicy && ( + + + + )} {policyConditions.length > 0 ? ( @@ -552,24 +593,26 @@ export default function NewClientPolicy() { helpText={type.helpText} fieldLabelId={condition.condition} /> - + {!isGlobalPolicy && ( + + )} ), )} @@ -609,18 +652,20 @@ export default function NewClientPolicy() { /> - - - + {!isGlobalPolicy && ( + + + + )} {policyProfiles.length > 0 ? ( @@ -663,24 +708,26 @@ export default function NewClientPolicy() { } fieldLabelId={profile} /> - + {!isGlobalPolicy && ( + + )} ))} , diff --git a/js/apps/admin-ui/src/realm-settings/NewClientPolicyCondition.tsx b/js/apps/admin-ui/src/realm-settings/NewClientPolicyCondition.tsx index 56fd35908c..ab5c9a91d7 100644 --- a/js/apps/admin-ui/src/realm-settings/NewClientPolicyCondition.tsx +++ b/js/apps/admin-ui/src/realm-settings/NewClientPolicyCondition.tsx @@ -18,12 +18,13 @@ import { camelCase } from "lodash-es"; import { useState } from "react"; import { Controller, FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { useNavigate, useParams } from "react-router-dom"; -import { FormPanel, HelpItem } from "ui-shared"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { HelpItem } from "ui-shared"; import { adminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { DynamicComponents } from "../components/dynamic/DynamicComponents"; import { FormAccess } from "../components/form/FormAccess"; +import { ViewHeader } from "../components/view-header/ViewHeader"; import { useRealm } from "../context/realm-context/RealmContext"; import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import { useFetch } from "../utils/useFetch"; @@ -44,6 +45,7 @@ export default function NewClientPolicyCondition() { const { realm } = useRealm(); const [openConditionType, setOpenConditionType] = useState(false); + const [isGlobalPolicy, setIsGlobalPolicy] = useState(false); const [policies, setPolicies] = useState([]); const [condition, setCondition] = useState< @@ -72,15 +74,24 @@ export default function NewClientPolicyCondition() { }; useFetch( - () => adminClient.clientPolicies.listPolicies(), + () => + adminClient.clientPolicies.listPolicies({ + includeGlobalPolicies: true, + }), (policies) => { setPolicies(policies.policies ?? []); if (conditionName) { - const currentPolicy = policies.policies?.find( + let currentPolicy = policies.policies?.find( (item) => item.name === policyName, ); + if (currentPolicy === undefined) { + currentPolicy = policies.globalPolicies?.find( + (item) => item.name === policyName, + ); + setIsGlobalPolicy(currentPolicy !== undefined); + } const typeAndConfigData = currentPolicy?.conditions?.find( (item) => item.condition === conditionName, @@ -170,14 +181,22 @@ export default function NewClientPolicyCondition() { }; return ( - - + <> + + @@ -245,27 +264,48 @@ export default function NewClientPolicyCondition() { - - - - + {!isGlobalPolicy && ( + + + + + )} - - + {isGlobalPolicy && ( +
+ +
+ )} + + ); } diff --git a/js/apps/admin-ui/src/realm-settings/PoliciesTab.tsx b/js/apps/admin-ui/src/realm-settings/PoliciesTab.tsx index 2b701ea832..0734779b6b 100644 --- a/js/apps/admin-ui/src/realm-settings/PoliciesTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/PoliciesTab.tsx @@ -7,6 +7,7 @@ import { Divider, Flex, FlexItem, + Label, PageSection, Radio, Switch, @@ -36,29 +37,48 @@ import { toEditClientPolicy } from "./routes/EditClientPolicy"; import "./realm-settings-section.css"; +type ClientPolicy = ClientPolicyRepresentation & { + global?: boolean; +}; + export const PoliciesTab = () => { const { t } = useTranslation(); const { addAlert, addError } = useAlerts(); const { realm } = useRealm(); const navigate = useNavigate(); const [show, setShow] = useState(false); - const [policies, setPolicies] = useState(); - const [selectedPolicy, setSelectedPolicy] = - useState(); + const [policies, setPolicies] = useState(); + const [selectedPolicy, setSelectedPolicy] = useState(); const [key, setKey] = useState(0); const [code, setCode] = useState(); - const [tablePolicies, setTablePolicies] = - useState(); + const [tablePolicies, setTablePolicies] = useState(); const refresh = () => setKey(key + 1); const form = useForm>({ mode: "onChange" }); useFetch( - () => adminClient.clientPolicies.listPolicies(), - (policies) => { - setPolicies(policies.policies), - setTablePolicies(policies.policies || []), - setCode(prettyPrintJSON(policies.policies)); + () => + adminClient.clientPolicies.listPolicies({ + includeGlobalPolicies: true, + }), + (allPolicies) => { + const globalPolicies = allPolicies.globalPolicies?.map( + (globalPolicies) => ({ + ...globalPolicies, + global: true, + }), + ); + + const policies = allPolicies.policies?.map((policies) => ({ + ...policies, + global: false, + })); + + const allClientPolicies = globalPolicies?.concat(policies ?? []); + + setPolicies(allClientPolicies), + setTablePolicies(allClientPolicies || []), + setCode(prettyPrintJSON(allClientPolicies)); }, [key], ); @@ -68,16 +88,19 @@ export const PoliciesTab = () => { const saveStatus = async () => { const switchValues = form.getValues(); - const updatedPolicies = policies?.map( - (policy) => { + const updatedPolicies = policies + ?.filter((policy) => { + return !policy.global; + }) + .map((policy) => { const enabled = switchValues[policy.name!]; - - return { + const enabledPolicy = { ...policy, enabled, }; - }, - ); + delete enabledPolicy.global; + return enabledPolicy; + }); try { await adminClient.clientPolicies.updatePolicy({ @@ -90,15 +113,13 @@ export const PoliciesTab = () => { } }; - const ClientPolicyDetailLink = ({ name }: ClientPolicyRepresentation) => ( - {name} + const ClientPolicyDetailLink = (row: ClientPolicy) => ( + + {row.name} {row.global && } + ); - const SwitchRenderer = ({ - clientPolicy, - }: { - clientPolicy: ClientPolicyRepresentation; - }) => { + const SwitchRenderer = ({ clientPolicy }: { clientPolicy: ClientPolicy }) => { const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({ titleKey: "disablePolicyConfirmTitle", messageKey: "disablePolicyConfirm", @@ -122,6 +143,7 @@ export const PoliciesTab = () => { label={t("enabled")} labelOff={t("disabled")} isChecked={field.value} + isDisabled={clientPolicy.global} onChange={(_event, value) => { if (!value) { toggleDisableDialog(); @@ -144,7 +166,7 @@ export const PoliciesTab = () => { } try { - const obj: ClientPolicyRepresentation[] = JSON.parse(code); + const obj: ClientPolicy[] = JSON.parse(code); try { await adminClient.clientPolicies.updatePolicy({ @@ -169,9 +191,15 @@ export const PoliciesTab = () => { continueButtonLabel: t("delete"), continueButtonVariant: ButtonVariant.danger, onConfirm: async () => { - const updatedPolicies = policies?.filter( - (policy) => policy.name !== selectedPolicy?.name, - ); + const updatedPolicies = policies + ?.filter((policy) => { + return !policy.global && policy.name !== selectedPolicy?.name; + }) + .map((policy) => { + const newPolicy = { ...policy }; + delete newPolicy.global; + return newPolicy; + }); try { await adminClient.clientPolicies.updatePolicy({ @@ -250,6 +278,7 @@ export const PoliciesTab = () => { } + isRowDisabled={(value) => !!value.global} actions={[ { title: t("delete"), @@ -257,7 +286,7 @@ export const PoliciesTab = () => { toggleDeleteDialog(); setSelectedPolicy(item); }, - } as Action, + } as Action, ]} columns={[ { diff --git a/js/libs/keycloak-admin-client/src/defs/clientPoliciesRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/clientPoliciesRepresentation.ts index 06b7b597ac..fcbd17a86a 100644 --- a/js/libs/keycloak-admin-client/src/defs/clientPoliciesRepresentation.ts +++ b/js/libs/keycloak-admin-client/src/defs/clientPoliciesRepresentation.ts @@ -4,5 +4,6 @@ import type ClientPolicyRepresentation from "./clientPolicyRepresentation.js"; * https://www.keycloak.org/docs-api/15.0/rest-api/#_clientpoliciesrepresentation */ export default interface ClientPoliciesRepresentation { + globalPolicies?: ClientPolicyRepresentation[]; policies?: ClientPolicyRepresentation[]; } diff --git a/js/libs/keycloak-admin-client/src/resources/clientPolicies.ts b/js/libs/keycloak-admin-client/src/resources/clientPolicies.ts index 23bb5b4393..617decccf8 100644 --- a/js/libs/keycloak-admin-client/src/resources/clientPolicies.ts +++ b/js/libs/keycloak-admin-client/src/resources/clientPolicies.ts @@ -38,9 +38,16 @@ export class ClientPolicies extends Resource<{ realm?: string }> { /* Client Policies */ - public listPolicies = this.makeRequest<{}, ClientPoliciesRepresentation>({ + public listPolicies = this.makeRequest< + { includeGlobalPolicies?: boolean }, + ClientPoliciesRepresentation + >({ method: "GET", path: "/policies", + queryParamKeys: ["include-global-policies"], + keyTransform: { + includeGlobalPolicies: "include-global-policies", + }, }); public updatePolicy = this.makeRequest({ diff --git a/server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileProvider.java b/server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileProvider.java new file mode 100644 index 0000000000..730339bb53 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileProvider.java @@ -0,0 +1,51 @@ +/* + * 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.securityprofile; + +import java.util.List; +import org.keycloak.provider.Provider; +import org.keycloak.representations.idm.ClientPolicyRepresentation; +import org.keycloak.representations.idm.ClientProfileRepresentation; + +/** + * The security profile provider is a default security configuration that enforces a + * minimum level of security in the keycloak environment. For the moment the class + * is just used for client policies but it can be extended for password policies + * or any other security configuration in the future. + * + * @author rmartinc + */ +public interface SecurityProfileProvider extends Provider { + + /** + * Name of the security profile. + * @return The name + */ + String getName(); + + /** + * List of default client profiles that the security profile contains. + * @return The list of client profiles defined + */ + List getDefaultClientProfiles(); + + /** + * List of default client policies defined in the security profile. + * @return The list of client policies defined + */ + List getDefaultClientPolicies(); +} diff --git a/server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileProviderFactory.java new file mode 100644 index 0000000000..ab4f29d9f9 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileProviderFactory.java @@ -0,0 +1,27 @@ +/* + * 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.securityprofile; + +import org.keycloak.provider.ProviderFactory; + +/** + * + * @author rmartinc + */ +public interface SecurityProfileProviderFactory extends ProviderFactory{ + +} diff --git a/server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileSpi.java b/server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileSpi.java new file mode 100644 index 0000000000..2345ed996a --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/securityprofile/SecurityProfileSpi.java @@ -0,0 +1,48 @@ +/* + * 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.securityprofile; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * + * @author rmartinc + */ +public class SecurityProfileSpi implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "security-profile"; + } + + @Override + public Class getProviderClass() { + return SecurityProfileProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return SecurityProfileProviderFactory.class; + } +} diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 610d789fb0..fabe22b455 100755 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -99,3 +99,4 @@ org.keycloak.device.DeviceRepresentationSpi org.keycloak.health.LoadBalancerCheckSpi org.keycloak.cookie.CookieSpi org.keycloak.organization.OrganizationSpi +org.keycloak.securityprofile.SecurityProfileSpi diff --git a/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyManager.java b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyManager.java index 479305f682..a15796ad1c 100644 --- a/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyManager.java +++ b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyManager.java @@ -93,9 +93,10 @@ public interface ClientPolicyManager extends Provider { * when getting client policies via Admin REST API, returns the existing client policies set on the realm. * * @param realm - the realm whose client policies is to be returned + * @param includeGlobalPolicies - the json representation will include the default policies * @return the json representation of the client policies set on the realm */ - ClientPoliciesRepresentation getClientPolicies(RealmModel realm) throws ClientPolicyException; + ClientPoliciesRepresentation getClientPolicies(RealmModel realm, boolean includeGlobalPolicies) throws ClientPolicyException; /** * when exporting realm, or retrieve the realm for admin REST API, prepares the exported representation of the client profiles and policies. diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java b/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java index 7343e7c991..499a5eb8d6 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java @@ -18,8 +18,13 @@ package org.keycloak.services.clientpolicy; + +import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; @@ -27,10 +32,7 @@ import java.util.List; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; - -import com.fasterxml.jackson.databind.JsonNode; import org.jboss.logging.Logger; - import org.keycloak.common.Profile; import org.keycloak.component.ComponentModel; import org.keycloak.component.JsonConfigComponentModel; @@ -38,14 +40,15 @@ import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.representations.idm.ClientPoliciesRepresentation; +import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation; import org.keycloak.representations.idm.ClientPolicyConditionRepresentation; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation; import org.keycloak.representations.idm.ClientPolicyRepresentation; import org.keycloak.representations.idm.ClientProfileRepresentation; import org.keycloak.representations.idm.ClientProfilesRepresentation; -import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation; +import org.keycloak.securityprofile.SecurityProfileProvider; import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider; -import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; import org.keycloak.util.JsonSerialization; @@ -58,6 +61,43 @@ public class ClientPoliciesUtil { private static final Logger logger = Logger.getLogger(ClientPoliciesUtil.class); + public static InputStream getJsonFileFromClasspathOrConfFolder(String name) throws IOException { + final String fileName = name + ".json"; + // first try to read the json configuration file from classpath + InputStream is = ClientPoliciesUtil.class.getResourceAsStream("/" + fileName); + if (is == null) { + Path path = Paths.get(System.getProperty("jboss.server.config.dir")).resolve(fileName); + if (!Files.isReadable(path)) { + throw new IOException(String.format("File \"%s\" does not exists under the config folder", path)); + } + is = Files.newInputStream(path); + } + return is; + } + + public static List readGlobalClientProfilesRepresentation(KeycloakSession session, String name) throws ClientPolicyException { + if (name == null) { + return Collections.emptyList(); + } + try (InputStream is = getJsonFileFromClasspathOrConfFolder(name)) { + return getValidatedGlobalClientProfilesRepresentation(session, is); + } catch (IOException e) { + throw new ClientPolicyException("Error reading profiles from " + name, e.getMessage(), e); + } + } + + public static List readGlobalClientPoliciesRepresentation(KeycloakSession session, String name, + List profiles) throws ClientPolicyException { + if (name == null) { + return Collections.emptyList(); + } + try (InputStream is = getJsonFileFromClasspathOrConfFolder(name)) { + return getValidatedGlobalClientPoliciesRepresentation(session, is, profiles); + } catch (IOException e) { + throw new ClientPolicyException("Error reading profiles from " + name, e.getMessage(), e); + } + } + /** * gets existing client profiles in a realm as representation. * not return null. @@ -183,6 +223,24 @@ public class ClientPoliciesUtil { return updatingProfileList; } + /** + * get validated and modified global (built-in) client policies set on keycloak app as representation. + * it is loaded from json file enclosed in keycloak's binary. + * not return null. + */ + static List getValidatedGlobalClientPoliciesRepresentation(KeycloakSession session, InputStream is, List profiles) throws ClientPolicyException { + ClientPoliciesRepresentation proposedPoliciesRep = null; + try { + proposedPoliciesRep = JsonSerialization.readValue(is, ClientPoliciesRepresentation.class); + } catch (Exception e) { + throw new ClientPolicyException("failed to deserialize global proposed client profiles json string.", e.getMessage()); + } + if (proposedPoliciesRep == null) { + return Collections.emptyList(); + } + return validatePolicies(session, proposedPoliciesRep.getPolicies(), profiles, Collections.emptyList()); + } + /** * convert client profiles as representation to json. * can return null. @@ -240,7 +298,7 @@ public class ClientPoliciesUtil { Set globalProfileNames = globalClientProfiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet()); for (ClientProfileRepresentation clientProfile : proposedProfileRepList) { if (globalProfileNames.contains(clientProfile.getName())) { - throw new ClientPolicyException("Proposed profile name duplicated as the name of some global profile"); + throw new ClientPolicyException("Proposed profile name '" + clientProfile.getName() + "' is duplicated as a global profile"); } } @@ -298,32 +356,42 @@ public class ClientPoliciesUtil { return convertClientPoliciesJsonToRepresentation(policiesJson); } + static List getGlobalClientProfiles(KeycloakSession session) { + SecurityProfileProvider securityProfile = session.getProvider(SecurityProfileProvider.class); + return securityProfile.getDefaultClientProfiles(); + } + + static List getGlobalClientPolicies(KeycloakSession session) { + SecurityProfileProvider securityProfile = session.getProvider(SecurityProfileProvider.class); + return securityProfile.getDefaultClientPolicies(); + } + /** * Gets existing enabled client policies in a realm. * not return null. */ static List getEnabledClientPolicies(KeycloakSession session, RealmModel realm) { + // get the global policies defined in the security profile + List policiesRep = new ArrayList<>(getGlobalClientPolicies(session)); + // get existing profiles as json String policiesJson = getClientPoliciesJsonString(realm); - if (policiesJson == null) { - return Collections.emptyList(); + if (policiesJson != null) { + // deserialize existing policies (json -> representation) + try { + policiesRep.addAll(convertClientPoliciesJsonToRepresentation(policiesJson).getPolicies()); + } catch (ClientPolicyException e) { + logger.warnv("Failed to serialize client policies json string. err={0}, errDetail={1}", e.getError(), e.getErrorDetail()); + return Collections.emptyList(); + } } - - // deserialize existing policies (json -> representation) - ClientPoliciesRepresentation policiesRep = null; - try { - policiesRep = convertClientPoliciesJsonToRepresentation(policiesJson); - } catch (ClientPolicyException e) { - logger.warnv("Failed to serialize client policies json string. err={0}, errDetail={1}", e.getError(), e.getErrorDetail()); - return Collections.emptyList(); - } - if (policiesRep == null || policiesRep.getPolicies() == null) { + if (policiesRep.isEmpty()) { return Collections.emptyList(); } // constructing existing policies (representation -> model) List policyList = new ArrayList<>(); - for (ClientPolicyRepresentation policyRep: policiesRep.getPolicies()) { + for (ClientPolicyRepresentation policyRep: policiesRep) { // ignore policy without name if (policyRep.getName() == null) { logger.warnf("Ignored client policy without name in the realm %s", realm.getName()); @@ -395,6 +463,80 @@ public class ClientPoliciesUtil { } } + /** + * Validates the policies passed with the profiles. + * @param session The session + * @param proposedPoliciesRepList The policies to validate + * @param profiles The already validated profiles used by the policies + * @param globalPolicies The global policies defined already validated + * @return The validated policies + * @throws ClientPolicyException Some error in the policies + */ + static private List validatePolicies(KeycloakSession session, List proposedPoliciesRepList, + List profiles, List globalPolicies) throws ClientPolicyException { + + // empty policies is valid + if (proposedPoliciesRepList == null || proposedPoliciesRepList.isEmpty()) { + return Collections.emptyList(); + } + + // check for duplicated names + if (proposedPoliciesRepList.size() != proposedPoliciesRepList.stream().map(i->i.getName()).distinct().count()) { + throw new ClientPolicyException("proposed client policy name duplicated"); + } + + // Conflict with any global policy is not allowed + Set globalPolicyNames = globalPolicies.stream().map(ClientPolicyRepresentation::getName).collect(Collectors.toSet()); + for (ClientPolicyRepresentation clientPolicy : proposedPoliciesRepList) { + if (globalPolicyNames.contains(clientPolicy.getName())) { + throw new ClientPolicyException("Proposed policy name '" + clientPolicy.getName() + "' is duplicated as a global policy"); + } + } + + // construct validated and modified profiles from builtin profiles in JSON file enclosed in keycloak binary. + List updatingPolicyList = new LinkedList<>(); + + Set profileNames = profiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet()); + + for (ClientPolicyRepresentation proposedPolicyRep : proposedPoliciesRepList) { + if (proposedPolicyRep.getName() == null) { + throw new ClientPolicyException("client policy without name not allowed"); + } + + ClientPolicyRepresentation policyRep = new ClientPolicyRepresentation(); + policyRep.setName(proposedPolicyRep.getName()); + policyRep.setDescription(proposedPolicyRep.getDescription()); + policyRep.setEnabled(proposedPolicyRep.isEnabled() != null ? proposedPolicyRep.isEnabled() : Boolean.FALSE); + + policyRep.setConditions(new ArrayList<>()); + if (proposedPolicyRep.getConditions() != null) { + for (ClientPolicyConditionRepresentation condition : proposedPolicyRep.getConditions()) { + if (Profile.isFeatureEnabled(Profile.Feature.CLIENT_POLICIES) && !isValidCondition(session, condition.getConditionProviderId())) { + throw new ClientPolicyException("Policy " + proposedPolicyRep.getName() + " contains invalid condition " + condition.getConditionProviderId()); + } + policyRep.getConditions().add(condition); + } + } + + policyRep.setProfiles(new ArrayList<>()); + if (proposedPolicyRep.getProfiles() != null) { + if (proposedPolicyRep.getProfiles().size() != proposedPolicyRep.getProfiles().stream().distinct().count()) { + throw new ClientPolicyException("Policy " + proposedPolicyRep.getName() + " contains duplicated profiles"); + } + for (String profile : proposedPolicyRep.getProfiles()) { + if (!profileNames.contains(profile)) { + throw new ClientPolicyException("Policy " + proposedPolicyRep.getName() + " contains invalid profile " + profile); + } + policyRep.getProfiles().add(profile); + } + } + + updatingPolicyList.add(policyRep); + } + + return updatingPolicyList; + } + /** * get validated and modified client policies as representation. * it can be constructed by merging proposed client policies with existing client policies. @@ -405,73 +547,17 @@ public class ClientPoliciesUtil { * @param proposedPoliciesRep */ static ClientPoliciesRepresentation getValidatedClientPoliciesForUpdate(KeycloakSession session, RealmModel realm, - ClientPoliciesRepresentation proposedPoliciesRep, List existingGlobalProfiles) throws ClientPolicyException { + ClientPoliciesRepresentation proposedPoliciesRep, List existingGlobalProfiles, + List existingGlobalPolicies) throws ClientPolicyException { if (realm == null) { throw new ClientPolicyException("realm not specified."); } - // no policy contained (it is valid) - List proposedPolicyRepList = proposedPoliciesRep.getPolicies(); - if (proposedPolicyRepList == null || proposedPolicyRepList.isEmpty()) { - proposedPolicyRepList = new ArrayList<>(); - proposedPoliciesRep.setPolicies(new ArrayList<>()); - } - - // Policy without name not allowed - if (proposedPolicyRepList.stream().anyMatch(clientPolicy -> clientPolicy.getName() == null || clientPolicy.getName().isEmpty())) { - throw new ClientPolicyException("proposed client policy name missing."); - } - - // duplicated policy name is not allowed. - if (proposedPolicyRepList.size() != proposedPolicyRepList.stream().map(i->i.getName()).distinct().count()) { - throw new ClientPolicyException("proposed client policy name duplicated."); - } - - // construct updating policies from existing policies and proposed policies ClientPoliciesRepresentation updatingPoliciesRep = new ClientPoliciesRepresentation(); - updatingPoliciesRep.setPolicies(new ArrayList<>()); - List updatingPoliciesList = updatingPoliciesRep.getPolicies(); - for (ClientPolicyRepresentation proposedPolicyRep : proposedPoliciesRep.getPolicies()) { - // newly proposed builtin policy not allowed because builtin policy cannot added/deleted/modified. - Boolean enabled = (proposedPolicyRep.isEnabled() != null) ? proposedPolicyRep.isEnabled() : Boolean.FALSE; - - // basically, proposed policy totally overrides existing policy except for enabled field.. - ClientPolicyRepresentation policyRep = new ClientPolicyRepresentation(); - policyRep.setName(proposedPolicyRep.getName()); - policyRep.setDescription(proposedPolicyRep.getDescription()); - policyRep.setEnabled(enabled); - - policyRep.setConditions(new ArrayList<>()); - if (proposedPolicyRep.getConditions() != null) { - for (ClientPolicyConditionRepresentation conditionRep : proposedPolicyRep.getConditions()) { - if (!isValidCondition(session, conditionRep.getConditionProviderId())) { - throw new ClientPolicyException("the proposed client policy contains the condition with its invalid configuration."); - } - policyRep.getConditions().add(conditionRep); - } - } - - Set existingProfileNames = existingGlobalProfiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet()); - ClientProfilesRepresentation reps = getClientProfilesRepresentation(session, realm); - policyRep.setProfiles(new ArrayList<>()); - if (reps.getProfiles() != null) { - existingProfileNames.addAll(reps.getProfiles().stream() - .map(ClientProfileRepresentation::getName) - .collect(Collectors.toSet())); - } - if (proposedPolicyRep.getProfiles() != null) { - for (String profileName : proposedPolicyRep.getProfiles()) { - if (!existingProfileNames.contains(profileName)) { - logger.warnf("Client policy %s referred not existing profile %s"); - throw new ClientPolicyException("referring not existing client profile not allowed."); - } - } - proposedPolicyRep.getProfiles().stream().distinct().forEach(profileName->policyRep.getProfiles().add(profileName)); - } - - updatingPoliciesList.add(policyRep); - } + List allProfiles = new ArrayList<>(getClientProfilesRepresentation(session, realm).getProfiles()); + allProfiles.addAll(existingGlobalProfiles); + updatingPoliciesRep.setPolicies(validatePolicies(session, proposedPoliciesRep.getPolicies(), allProfiles, existingGlobalPolicies)); return updatingPoliciesRep; } diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/DefaultClientPolicyManager.java b/services/src/main/java/org/keycloak/services/clientpolicy/DefaultClientPolicyManager.java index 32870cc9b1..0c410043ae 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/DefaultClientPolicyManager.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/DefaultClientPolicyManager.java @@ -18,16 +18,13 @@ package org.keycloak.services.clientpolicy; import java.io.IOException; -import java.util.LinkedList; import java.util.List; -import java.util.function.Supplier; import org.jboss.logging.Logger; import org.keycloak.common.Profile; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.representations.idm.ClientPoliciesRepresentation; -import org.keycloak.representations.idm.ClientProfileRepresentation; import org.keycloak.representations.idm.ClientProfilesRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider; @@ -42,11 +39,9 @@ public class DefaultClientPolicyManager implements ClientPolicyManager { private static final Logger logger = Logger.getLogger(DefaultClientPolicyManager.class); private final KeycloakSession session; - private final Supplier> globalClientProfilesSupplier; - public DefaultClientPolicyManager(KeycloakSession session, Supplier> globalClientProfilesSupplier) { + public DefaultClientPolicyManager(KeycloakSession session) { this.session = session; - this.globalClientProfilesSupplier = globalClientProfilesSupplier; } @Override @@ -143,7 +138,8 @@ public class DefaultClientPolicyManager implements ClientPolicyManager { ClientProfilesRepresentation clientProfiles = ClientPoliciesUtil.getClientProfilesRepresentation(session, realm); for (String profileName : policy.getProfiles()) { - ClientProfile profile = ClientPoliciesUtil.getClientProfileModel(session, realm, clientProfiles, globalClientProfilesSupplier.get(), profileName); + ClientProfile profile = ClientPoliciesUtil.getClientProfileModel(session, realm, clientProfiles, + ClientPoliciesUtil.getGlobalClientProfiles(session), profileName); if (profile == null) { logger.tracev("PROFILE NOT FOUND :: policy name = {0}, profile name = {1}", policy.getName(), profileName); continue; @@ -212,7 +208,8 @@ public class DefaultClientPolicyManager implements ClientPolicyManager { if (clientProfiles == null) { throw new ClientPolicyException("Passing null clientProfiles not allowed"); } - ClientProfilesRepresentation validatedProfilesRep = ClientPoliciesUtil.getValidatedClientProfilesForUpdate(session, realm, clientProfiles, globalClientProfilesSupplier.get()); + ClientProfilesRepresentation validatedProfilesRep = ClientPoliciesUtil.getValidatedClientProfilesForUpdate(session, realm, clientProfiles, + ClientPoliciesUtil.getGlobalClientProfiles(session)); String validatedJsonString = ClientPoliciesUtil.convertClientProfilesRepresentationToJson(validatedProfilesRep); ClientPoliciesUtil.setClientProfilesJsonString(realm, validatedJsonString); logger.tracev("UPDATE PROFILES :: realm = {0}, validated and modified PUT = {1}", realm.getName(), validatedJsonString); @@ -227,7 +224,7 @@ public class DefaultClientPolicyManager implements ClientPolicyManager { try { ClientProfilesRepresentation clientProfiles = ClientPoliciesUtil.getClientProfilesRepresentation(session, realm); if (includeGlobalProfiles) { - clientProfiles.setGlobalProfiles(new LinkedList<>(globalClientProfilesSupplier.get())); + clientProfiles.setGlobalProfiles(ClientPoliciesUtil.getGlobalClientProfiles(session)); } if (logger.isTraceEnabled()) { @@ -250,7 +247,8 @@ public class DefaultClientPolicyManager implements ClientPolicyManager { if (clientPolicies == null) { throw new ClientPolicyException("Passing null clientPolicies not allowed"); } - ClientPoliciesRepresentation clientPoliciesRep = ClientPoliciesUtil.getValidatedClientPoliciesForUpdate(session, realm, clientPolicies, globalClientProfilesSupplier.get()); + ClientPoliciesRepresentation clientPoliciesRep = ClientPoliciesUtil.getValidatedClientPoliciesForUpdate(session, realm, clientPolicies, + ClientPoliciesUtil.getGlobalClientProfiles(session), ClientPoliciesUtil.getGlobalClientPolicies(session)); validatedJsonString = ClientPoliciesUtil.convertClientPoliciesRepresentationToJson(clientPoliciesRep); } catch (ClientPolicyException e) { logger.warnv("VALIDATE SERIALIZE POLICIES FAILED :: error = {0}, error detail = {1}", e.getError(), e.getErrorDetail()); @@ -261,9 +259,12 @@ public class DefaultClientPolicyManager implements ClientPolicyManager { } @Override - public ClientPoliciesRepresentation getClientPolicies(RealmModel realm) throws ClientPolicyException { + public ClientPoliciesRepresentation getClientPolicies(RealmModel realm, boolean includeGlobalPolicies) throws ClientPolicyException { try { ClientPoliciesRepresentation clientPolicies = ClientPoliciesUtil.getClientPoliciesRepresentation(session, realm); + if (includeGlobalPolicies) { + clientPolicies.setGlobalPolicies(ClientPoliciesUtil.getGlobalClientPolicies(session)); + } if (logger.isTraceEnabled()) { logger.tracev("GET POLICIES :: realm = {0}, GET = {1}", realm.getName(), JsonSerialization.writeValueAsString(clientPolicies)); } @@ -283,7 +284,7 @@ public class DefaultClientPolicyManager implements ClientPolicyManager { ClientProfilesRepresentation filteredOutProfiles = getClientProfiles(realm, false); rep.setParsedClientProfiles(filteredOutProfiles); - ClientPoliciesRepresentation filteredOutPolicies = getClientPolicies(realm); + ClientPoliciesRepresentation filteredOutPolicies = getClientPolicies(realm, false); rep.setParsedClientPolicies(filteredOutPolicies); } catch (ClientPolicyException cpe) { throw new IllegalStateException("Exception during export client profiles or client policies", cpe); diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/DefaultClientPolicyManagerFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/DefaultClientPolicyManagerFactory.java index 8ccfd01aa7..baec720357 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/DefaultClientPolicyManagerFactory.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/DefaultClientPolicyManagerFactory.java @@ -18,13 +18,10 @@ package org.keycloak.services.clientpolicy; -import java.util.List; - import org.jboss.logging.Logger; import org.keycloak.Config; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.representations.idm.ClientProfileRepresentation; /** * @author Marek Posolda @@ -33,14 +30,9 @@ public class DefaultClientPolicyManagerFactory implements ClientPolicyManagerFac private static final Logger logger = Logger.getLogger(DefaultClientPolicyManagerFactory.class); - // Global (builtin) profiles are loaded on booting keycloak at once. - // therefore, their representations are kept and remain unchanged. - // these are shared among all realms. - private volatile List globalClientProfiles; - @Override public ClientPolicyManager create(KeycloakSession session) { - return new DefaultClientPolicyManager(session, () -> getGlobalClientProfiles(session)); + return new DefaultClientPolicyManager(session); } @Override @@ -62,26 +54,4 @@ public class DefaultClientPolicyManagerFactory implements ClientPolicyManagerFac public String getId() { return "default"; } - - /** - * When this method is called, assumption is that CLIENT_POLICIES feature is enabled - */ - protected List getGlobalClientProfiles(KeycloakSession session) { - if (globalClientProfiles == null) { - synchronized (this) { - if (globalClientProfiles == null) { - logger.trace("LOAD GLOBAL CLIENT PROFILES ON KEYCLOAK"); - - // load builtin profiles from keycloak-services - try { - this.globalClientProfiles = ClientPoliciesUtil.getValidatedGlobalClientProfilesRepresentation(session, getClass().getResourceAsStream("/keycloak-default-client-profiles.json")); - } catch (ClientPolicyException cpe) { - logger.warnv("LOAD GLOBAL PROFILES ON KEYCLOAK FAILED :: error = {0}, error detail = {1}", cpe.getError(), cpe.getErrorDetail()); - throw new IllegalStateException(cpe); - } - } - } - } - return globalClientProfiles; - } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientPoliciesResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientPoliciesResource.java index 45256b0e8e..2d14666ac9 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientPoliciesResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientPoliciesResource.java @@ -18,11 +18,11 @@ package org.keycloak.services.resources.admin; import org.eclipse.microprofile.openapi.annotations.Operation; -import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -66,11 +66,11 @@ public class ClientPoliciesResource { @Produces(MediaType.APPLICATION_JSON) @Tag(name = KeycloakOpenAPI.Admin.Tags.REALMS_ADMIN) @Operation() - public ClientPoliciesRepresentation getPolicies() { + public ClientPoliciesRepresentation getPolicies(@QueryParam("include-global-policies") boolean includeGlobalPolicies) { auth.realm().requireViewRealm(); try { - return session.clientPolicy().getClientPolicies(realm); + return session.clientPolicy().getClientPolicies(realm, includeGlobalPolicies); } catch (ClientPolicyException e) { throw ErrorResponse.error(e.getError(), Response.Status.BAD_REQUEST); } diff --git a/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProvider.java b/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProvider.java new file mode 100644 index 0000000000..c62c6e53d6 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProvider.java @@ -0,0 +1,56 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.services.securityprofile; + +import java.util.List; +import org.keycloak.representations.idm.ClientPolicyRepresentation; +import org.keycloak.representations.idm.ClientProfileRepresentation; +import org.keycloak.representations.idm.SecurityProfileConfiguration; +import org.keycloak.securityprofile.SecurityProfileProvider; + +/** + * + * @author rmartinc + */ +public class DefaultSecurityProfileProvider implements SecurityProfileProvider { + + private SecurityProfileConfiguration config; + + public DefaultSecurityProfileProvider(SecurityProfileConfiguration config) { + this.config = config; + } + + @Override + public String getName() { + return config.getName(); + } + + @Override + public List getDefaultClientProfiles() { + return config.getDefaultClientProfiles(); + } + + @Override + public List getDefaultClientPolicies() { + return config.getDefaultClientPolicies(); + } + + @Override + public void close() { + config = null; + } +} diff --git a/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProviderFactory.java b/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProviderFactory.java new file mode 100644 index 0000000000..ad1e3e8c5e --- /dev/null +++ b/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProviderFactory.java @@ -0,0 +1,116 @@ +/* + * 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.services.securityprofile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.representations.idm.SecurityProfileConfiguration; +import org.keycloak.securityprofile.SecurityProfileProvider; +import org.keycloak.securityprofile.SecurityProfileProviderFactory; +import org.keycloak.services.clientpolicy.ClientPoliciesUtil; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.util.JsonSerialization; + +/** + * The default implementation for the security profile. It reads the configuration + * from the file configured. + * @author rmartinc + */ +public class DefaultSecurityProfileProviderFactory implements SecurityProfileProviderFactory { + + private static final Logger logger = Logger.getLogger(DefaultSecurityProfileProviderFactory.class); + + private String name; + private volatile SecurityProfileConfiguration configuration; + + @Override + public SecurityProfileProvider create(KeycloakSession session) { + return new DefaultSecurityProfileProvider(readConfiguration(session)); + } + + @Override + public List getConfigMetadata() { + return ProviderConfigurationBuilder.create() + .property() + .name("name") + .type("string") + .helpText("Name for the security configuration file to use. File `name`.json is searched in classapth and `conf` installation folder.") + .add() + .build(); + } + + @Override + public void init(Config.Scope config) { + this.name = config.get("name", "none-security-profile"); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // no-op + } + + @Override + public void close() { + // no-op + } + + @Override + public String getId() { + return "default"; + } + + protected SecurityProfileConfiguration readConfiguration(KeycloakSession session) { + if (configuration == null) { + synchronized (this) { + SecurityProfileConfiguration conf; + final String file = name + ".json"; + try { + // first try to read the json configuration file from classpath + InputStream tmp = getClass().getResourceAsStream("/" + file); + if (tmp == null) { + Path path = Paths.get(System.getProperty("jboss.server.config.dir")).resolve(file); + if (!Files.isReadable(path)) { + throw new IOException(String.format("File %s does not exists in the conf folder", file)); + } + tmp = Files.newInputStream(path); + } + try (InputStream is = tmp) { + conf = JsonSerialization.readValue(is, SecurityProfileConfiguration.class); + } + // read the list of client profiles and policies validated + conf.setDefaultClientProfiles(ClientPoliciesUtil.readGlobalClientProfilesRepresentation(session, conf.getClientProfiles())); + conf.setDefaultClientPolicies(ClientPoliciesUtil.readGlobalClientPoliciesRepresentation(session, conf.getClientPolicies(), + conf.getDefaultClientProfiles())); + } catch (ClientPolicyException|IOException e) { + throw new IllegalStateException("Error loading the security profile from file " + file, e); + } + this.configuration = conf; + } + } + return this.configuration; + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.securityprofile.SecurityProfileProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.securityprofile.SecurityProfileProviderFactory new file mode 100644 index 0000000000..b8ed1dba60 --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.securityprofile.SecurityProfileProviderFactory @@ -0,0 +1 @@ +org.keycloak.services.securityprofile.DefaultSecurityProfileProviderFactory diff --git a/services/src/main/resources/keycloak-strict-client-policies.json b/services/src/main/resources/keycloak-strict-client-policies.json new file mode 100644 index 0000000000..dd3ab80388 --- /dev/null +++ b/services/src/main/resources/keycloak-strict-client-policies.json @@ -0,0 +1,68 @@ +{ + "policies": [ + { + "name": "Openid-connect OAuth 2.1 confidential client", + "description": "Openid-connect confidential client policy to ensure OAuth 2.1 specification.", + "enabled": true, + "conditions": [ + { + "condition": "client-type", + "configuration": { + "protocol": "openid-connect" + } + }, + { + "condition": "client-access-type", + "configuration": { + "type": [ + "confidential" + ] + } + } + ], + "profiles": [ + "oauth-2-1-for-confidential-client" + ] + }, + { + "name": "Openid-connect OAuth 2.1 public client", + "description": "Openid-connect confidential client policy to ensure OAuth 2.1 specification.", + "enabled": true, + "conditions": [ + { + "condition": "client-type", + "configuration": { + "protocol": "openid-connect" + } + }, + { + "condition": "client-access-type", + "configuration": { + "type": [ + "public" + ] + } + } + ], + "profiles": [ + "oauth-2-1-for-public-client" + ] + }, + { + "name": "Saml secure client (signatures, post, https)", + "description": "Saml policy to ensure signatures, POST binding and https URLs.", + "enabled": true, + "conditions": [ + { + "condition": "client-type", + "configuration": { + "protocol": "saml" + } + } + ], + "profiles": [ + "saml-security-profile" + ] + } + ] +} diff --git a/services/src/main/resources/lax-security-profile.json b/services/src/main/resources/lax-security-profile.json new file mode 100644 index 0000000000..45ba6fc397 --- /dev/null +++ b/services/src/main/resources/lax-security-profile.json @@ -0,0 +1,5 @@ +{ + "name":"lax", + "client-profiles":"keycloak-default-client-profiles", + "client-policies":null +} diff --git a/services/src/main/resources/none-security-profile.json b/services/src/main/resources/none-security-profile.json new file mode 100644 index 0000000000..42b31c189e --- /dev/null +++ b/services/src/main/resources/none-security-profile.json @@ -0,0 +1,5 @@ +{ + "name":"none", + "client-profiles":"keycloak-default-client-profiles", + "client-policies":null +} diff --git a/services/src/main/resources/strict-security-profile.json b/services/src/main/resources/strict-security-profile.json new file mode 100644 index 0000000000..157acbd52e --- /dev/null +++ b/services/src/main/resources/strict-security-profile.json @@ -0,0 +1,5 @@ +{ + "name":"strict", + "client-profiles":"keycloak-default-client-profiles", + "client-policies":"keycloak-strict-client-policies" +} diff --git a/services/src/test/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProverFactoryTest.java b/services/src/test/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProverFactoryTest.java new file mode 100644 index 0000000000..7caf5e9bfe --- /dev/null +++ b/services/src/test/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProverFactoryTest.java @@ -0,0 +1,81 @@ +/* + * 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.services.securityprofile; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.keycloak.common.Profile; +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.crypto.CryptoProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.securityprofile.SecurityProfileProvider; +import org.keycloak.securityprofile.SecurityProfileProviderFactory; +import org.keycloak.services.resteasy.ResteasyKeycloakSession; +import org.keycloak.services.resteasy.ResteasyKeycloakSessionFactory; +import org.keycloak.utils.ScopeUtil; + +/** + * + * @author rmartinc + */ +@RunWith(Parameterized.class) +public class DefaultSecurityProfileProverFactoryTest { + + private static KeycloakSession session; + private final String name; + + @Parameters + public static Collection data() { + // return of json profile files packed with keycloak + return Arrays.asList(new Object[][]{ + {"none-security-profile"}, + {"lax-security-profile"}, + {"strict-security-profile"}, + }); + } + + public DefaultSecurityProfileProverFactoryTest(String name) { + this.name = name; + } + + @BeforeClass + public static void beforeClass() { + Profile.defaults(); + CryptoIntegration.init(CryptoProvider.class.getClassLoader()); + ResteasyKeycloakSessionFactory sessionFactory = new ResteasyKeycloakSessionFactory(); + sessionFactory.init(); + session = new ResteasyKeycloakSession(sessionFactory); + } + + @Test + public void testConfigurationFile() { + SecurityProfileProviderFactory fact = new DefaultSecurityProfileProviderFactory(); + fact.init(ScopeUtil.createScope(Collections.singletonMap("name", name))); + SecurityProfileProvider prov = fact.create(session); + Assert.assertNotNull(prov.getName()); + Assert.assertNotNull(prov.getDefaultClientProfiles()); + Assert.assertNotNull(prov.getDefaultClientPolicies()); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/SetDefaultProvider.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/SetDefaultProvider.java index c587d79e2a..17c061a3b1 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/SetDefaultProvider.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/annotation/SetDefaultProvider.java @@ -35,4 +35,10 @@ public @interface SetDefaultProvider { boolean beforeEnableFeature() default true; String defaultProvider() default ""; + + /** + * Configuration for the provider in the form option1, value1, option2, value2 + * @return The config options and values + */ + String[] config() default {}; } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SpiProvidersSwitchingUtils.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SpiProvidersSwitchingUtils.java index a95f58e821..2f4ae7dee6 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SpiProvidersSwitchingUtils.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SpiProvidersSwitchingUtils.java @@ -9,9 +9,12 @@ import org.keycloak.testsuite.arquillian.containers.AbstractQuarkusDeployableCon import org.keycloak.utils.StringUtil; import java.util.Collections; +import java.util.LinkedList; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; public class SpiProvidersSwitchingUtils { @@ -28,24 +31,58 @@ public class SpiProvidersSwitchingUtils { } @Override - public void setDefaultProvider(Container container, String spiName, String providerId) { + public void setDefaultProvider(Container container, String spiName, String providerId, String... config) { System.setProperty(getProviderPropertyName(spiName), providerId); + if (config != null) { + String optionName = null; + for (String c : config) { + if (optionName == null) { + optionName = c; + } else { + System.setProperty(getProviderPropertyNameConfig(spiName, providerId, optionName), c); + optionName = null; + } + } + } } @Override public void removeProviderConfig(Container container, String spiName) { - System.clearProperty(getProviderPropertyName(spiName)); + List toRemove = System.getProperties().stringPropertyNames().stream() + .filter(p -> p.startsWith(getProviderPropertyNamePrefix(spiName))) + .collect(Collectors.toList()); + toRemove.forEach(p -> System.clearProperty(p)); + } + + private String getProviderPropertyNamePrefix(String spiName) { + return "keycloak." + spiName + "."; } private String getProviderPropertyName(String spiName) { - return "keycloak." + spiName + ".provider"; + return getProviderPropertyNamePrefix(spiName) + "provider"; + } + + private String getProviderPropertyNameConfig(String spiName, String providerId, String configName) { + return getProviderPropertyNamePrefix(spiName) + providerId + "." + configName; } }, QUARKUS { @Override - public void setDefaultProvider(Container container, String spiName, String providerId) { - getQuarkusContainer(container).setAdditionalBuildArgs(Collections - .singletonList(KEYCLOAKX_ARG_SPI_PREFIX + toDashCase(spiName) + "-provider=" + providerId)); + public void setDefaultProvider(Container container, String spiName, String providerId, String... config) { + List args = new LinkedList<>(); + args.add(KEYCLOAKX_ARG_SPI_PREFIX + toDashCase(spiName) + "-provider=" + providerId); + if (config != null) { + String optionName = null; + for (String c : config) { + if (optionName == null) { + optionName = c; + } else { + args.add(KEYCLOAKX_ARG_SPI_PREFIX + toDashCase(spiName) + "-" + providerId + "-" + optionName + "=" + c); + optionName = null; + } + } + } + getQuarkusContainer(container).setAdditionalBuildArgs(args); } @Override @@ -92,10 +129,10 @@ public class SpiProvidersSwitchingUtils { return Optional.empty(); } - public abstract void setDefaultProvider(Container container, String spiName, String providerId); + public abstract void setDefaultProvider(Container container, String spiName, String providerId, String... config); - public void updateDefaultProvider(Container container, String spiName, String providerId) { - setDefaultProvider(container, spiName, providerId); + public void updateDefaultProvider(Container container, String spiName, String providerId, String... config) { + setDefaultProvider(container, spiName, providerId, config); } public void unsetDefaultProvider(Container container, String spiName) { @@ -125,9 +162,9 @@ public class SpiProvidersSwitchingUtils { if (annotation.onlyUpdateDefault()) { spiSwitcher.getCurrentDefaultProvider(container, spi, annotation).ifPresent(v -> originalSettingsBackup.put(spi, v)); - spiSwitcher.updateDefaultProvider(container, spi, annotation.providerId()); + spiSwitcher.updateDefaultProvider(container, spi, annotation.providerId(), annotation.config()); } else { - spiSwitcher.setDefaultProvider(container, spi, annotation.providerId()); + spiSwitcher.setDefaultProvider(container, spi, annotation.providerId(), annotation.config()); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java index dc9d2f452d..2cee630e92 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java @@ -304,7 +304,6 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest { createClientScopesConditionConfig(ClientScopesConditionFactory.OPTIONAL, Arrays.asList(SAMPLE_CLIENT_ROLE))) .addProfile("ordinal-test-profile") .addProfile("lack-of-builtin-field-test-profile") - .addProfile("ordinal-test-profile") .toRepresentation(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/securityprofile/StrictSecurityProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/securityprofile/StrictSecurityProfileTest.java new file mode 100644 index 0000000000..8432a43131 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/securityprofile/StrictSecurityProfileTest.java @@ -0,0 +1,200 @@ +/* + * 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.securityprofile; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.Response; +import java.util.Collections; +import java.util.stream.Collectors; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.OAuthErrorException; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.saml.SamlConfigAttributes; +import org.keycloak.representations.idm.ClientPoliciesRepresentation; +import org.keycloak.representations.idm.ClientPolicyRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ErrorRepresentation; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider; +import org.keycloak.testsuite.auth.page.login.OIDCLogin; +import org.keycloak.testsuite.util.ClientBuilder; + +@SetDefaultProvider(spi = "security-profile", providerId = "default", config = {"name", "strict-security-profile"}) +public class StrictSecurityProfileTest extends AbstractTestRealmKeycloakTest { + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + // no-op + } + + @Test + public void testGlobalClientPolicies() { + RealmResource realm = testRealm(); + // test there are policies defined in the endpoint + ClientPoliciesRepresentation policies = realm.clientPoliciesPoliciesResource().getPolicies(true); + Assert.assertNotNull(policies.getGlobalPolicies()); + MatcherAssert.assertThat(policies.getGlobalPolicies().stream().map(ClientPolicyRepresentation::getName).collect(Collectors.toList()), + Matchers.containsInAnyOrder("Openid-connect OAuth 2.1 confidential client", + "Openid-connect OAuth 2.1 public client", + "Saml secure client (signatures, post, https)")); + // try creating a global policy fails + ClientPoliciesRepresentation policiesRep = new ClientPoliciesRepresentation(); + policiesRep.setPolicies(Collections.singletonList(policies.getGlobalPolicies().iterator().next())); + BadRequestException e = Assert.assertThrows(BadRequestException.class, + () -> realm.clientPoliciesPoliciesResource().updatePolicies(policiesRep)); + ErrorRepresentation error = e.getResponse().readEntity(ErrorRepresentation.class); + MatcherAssert.assertThat(error.getErrorMessage(), Matchers.containsString("duplicated as a global policy")); + } + + @Test + public void testCreatePublicOpenIdConnectClientSecure() { + RealmResource realm = testRealm(); + ClientRepresentation clientRep = ClientBuilder.create() + .name("test-client-policy-app") + .clientId("test-client-policy-app") + .publicClient() + .protocol(OIDCLogin.OIDC) + .baseUrl("https://www.keycloak.org") + .redirectUris("https://www.keycloak.org") + .build(); + clientRep.setImplicitFlowEnabled(true); + OIDCAdvancedConfigWrapper wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep); + wrapper.setPostLogoutRedirectUris(Collections.singletonList("https://www.keycloak.org")); + wrapper.setPkceCodeChallengeMethod(OIDCLoginProtocol.PKCE_METHOD_PLAIN); + + // set the redirect uri to unsecure http + clientRep.setRedirectUris(Collections.singletonList("http://www.keycloak.org")); + Response resp = realm.clients().create(clientRep); + Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + OAuth2ErrorRepresentation error = resp.readEntity(OAuth2ErrorRepresentation.class); + Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, error.getError()); + Assert.assertEquals("Invalid Redirect Uri: invalid uri", error.getErrorDescription()); + clientRep.setRedirectUris(Collections.singletonList("https://www.keycloak.org")); + + // create OK + resp = realm.clients().create(clientRep); + Assert.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus()); + String id = ApiUtil.getCreatedId(resp); + getCleanup().addClientUuid(id); + + // check everything is auto-configure for security as the policy has auto-configure + clientRep = realm.clients().get(id).toRepresentation(); + wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep); + Assert.assertEquals(OIDCLoginProtocol.PKCE_METHOD_S256, wrapper.getPkceCodeChallengeMethod()); + Assert.assertFalse(clientRep.isImplicitFlowEnabled()); + Assert.assertFalse(clientRep.isDirectAccessGrantsEnabled()); + } + + @Test + public void testCreateConfidentialOpenIdConnectClientSecure() { + RealmResource realm = testRealm(); + ClientRepresentation clientRep = ClientBuilder.create() + .name("test-client-policy-app") + .clientId("test-client-policy-app") + .protocol(OIDCLogin.OIDC) + .baseUrl("https://www.keycloak.org") + .redirectUris("https://www.keycloak.org") + .directAccessGrants() + .build(); + clientRep.setImplicitFlowEnabled(true); + OIDCAdvancedConfigWrapper wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep); + wrapper.setPostLogoutRedirectUris(Collections.singletonList("https://www.keycloak.org")); + wrapper.setPkceCodeChallengeMethod(OIDCLoginProtocol.PKCE_METHOD_PLAIN); + wrapper.setUseMtlsHoKToken(false); + + // set the redirect uri to unsecure http + clientRep.setRedirectUris(Collections.singletonList("http://www.keycloak.org")); + Response resp = realm.clients().create(clientRep); + Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + OAuth2ErrorRepresentation error = resp.readEntity(OAuth2ErrorRepresentation.class); + Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, error.getError()); + Assert.assertEquals("Invalid Redirect Uri: invalid uri", error.getErrorDescription()); + clientRep.setRedirectUris(Collections.singletonList("https://www.keycloak.org")); + + // create OK + resp = realm.clients().create(clientRep); + Assert.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus()); + String id = ApiUtil.getCreatedId(resp); + getCleanup().addClientUuid(id); + + // check everything is auto-configure for security as the policy has auto-configure + clientRep = realm.clients().get(id).toRepresentation(); + wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep); + Assert.assertEquals(OIDCLoginProtocol.PKCE_METHOD_S256, wrapper.getPkceCodeChallengeMethod()); + Assert.assertTrue(wrapper.isUseMtlsHokToken()); + Assert.assertFalse(clientRep.isImplicitFlowEnabled()); + Assert.assertFalse(clientRep.isDirectAccessGrantsEnabled()); + } + + @Test + public void testCreateSamlClientSecure() { + RealmResource realm = testRealm(); + // setup a perfect client for SAML secure + ClientRepresentation clientRep = ClientBuilder.create() + .name("test-client-policy-app") + .clientId("test-client-policy-app") + .protocol(OIDCLogin.SAML) + .baseUrl("https://www.keycloak.org") + .redirectUris("https://www.keycloak.org") + .adminUrl("https://www.keycloak.org") + .attribute(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "true") + .attribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "true") + .attribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "true") + .build(); + + // change base url to unsecure http + clientRep.setBaseUrl("http://www.keycloak.org"); + Response resp = realm.clients().create(clientRep); + Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + OAuth2ErrorRepresentation error = resp.readEntity(OAuth2ErrorRepresentation.class); + Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError()); + MatcherAssert.assertThat(error.getErrorDescription(), Matchers.startsWith("Non secure scheme for")); + clientRep.setBaseUrl("https://www.keycloak.org"); + + // change force post to false + clientRep.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "false"); + resp = realm.clients().create(clientRep); + Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + error = resp.readEntity(OAuth2ErrorRepresentation.class); + Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError()); + Assert.assertEquals("Force POST binding is not enabled", error.getErrorDescription()); + clientRep.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "true"); + + // remove client signature + clientRep.getAttributes().put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "false"); + resp = realm.clients().create(clientRep); + Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + error = resp.readEntity(OAuth2ErrorRepresentation.class); + Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError()); + Assert.assertEquals("Signatures not ensured for the client. Ensure Client signature required and Sign documents or Sign assertions are ON", + error.getErrorDescription()); + clientRep.getAttributes().put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "true"); + + // create OK + resp = realm.clients().create(clientRep); + Assert.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus()); + getCleanup().addClientUuid(ApiUtil.getCreatedId(resp)); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json index 6da113b96b..7943357091 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json @@ -265,5 +265,12 @@ "saml-artifact-resolver": { "provider": "${keycloak.saml-artifact-resolver.provider:default}" + }, + + "security-profile": { + "provider": "${keycloak.security-profile.provider:default}", + "default": { + "name":"${keycloak.security-profile.default.name:none-security-profile}" + } } } diff --git a/testsuite/integration-arquillian/tests/base/testsuites/base-suite b/testsuite/integration-arquillian/tests/base/testsuites/base-suite index bbd0e8ff4a..f6f72523f0 100644 --- a/testsuite/integration-arquillian/tests/base/testsuites/base-suite +++ b/testsuite/integration-arquillian/tests/base/testsuites/base-suite @@ -35,6 +35,7 @@ providers,4 runonserver,6 saml,6 script,6 +securityprofile,4 session,6 sessionlimits,6 ssl,6 @@ -46,4 +47,4 @@ util,4 validation,6 vault,4 welcomepage,6 -x509,4 \ No newline at end of file +x509,4 diff --git a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json index 6c2e9c1f57..fd458cd227 100755 --- a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json +++ b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json @@ -184,6 +184,12 @@ "sslCertChainPrefix": "x-ssl-client-cert-chain", "certificateChainLength": 1 } - } + }, + "security-profile": { + "provider": "${keycloak.security-profile.provider:default}", + "default": { + "name":"${keycloak.security-profile.default.name:none-security-profile}" + } + } }