diff --git a/cypress/integration/authentication_policies.spec.ts b/cypress/integration/authentication_policies.spec.ts new file mode 100644 index 0000000000..df7f3378ed --- /dev/null +++ b/cypress/integration/authentication_policies.spec.ts @@ -0,0 +1,68 @@ +import { keycloakBefore } from "../support/util/keycloak_before"; +import Masthead from "../support/pages/admin_console/Masthead"; +import LoginPage from "../support/pages/LoginPage"; +import SidebarPage from "../support/pages/admin_console/SidebarPage"; +import OTPPolicies from "../support/pages/admin_console/manage/authentication/OTPPolicies"; +import WebAuthnPolicies from "../support/pages/admin_console/manage/authentication/WebAuthnPolicies"; + +describe("Policies", () => { + const masthead = new Masthead(); + const loginPage = new LoginPage(); + const sidebarPage = new SidebarPage(); + + describe("OTP policies tab", () => { + const otpPoliciesPage = new OTPPolicies(); + beforeEach(() => { + keycloakBefore(); + loginPage.logIn(); + sidebarPage.goToAuthentication(); + otpPoliciesPage.goToTab(); + }); + + it("should change to hotp", () => { + otpPoliciesPage.checkSupportedActions("FreeOTP, Google Authenticator"); + otpPoliciesPage.setPolicyType("hotp").increaseInitialCounter().save(); + masthead.checkNotificationMessage("OTP policy successfully updated"); + otpPoliciesPage.checkSupportedActions("FreeOTP"); + }); + }); + + describe("Webauthn policies tabs", () => { + const webauthnPage = new WebAuthnPolicies(); + beforeEach(() => { + keycloakBefore(); + loginPage.logIn(); + sidebarPage.goToAuthentication(); + }); + + it("should fill webauthn settings", () => { + webauthnPage.goToTab(); + webauthnPage.fillSelects({ + webAuthnPolicyAttestationConveyancePreference: "Indirect", + webAuthnPolicyRequireResidentKey: "Yes", + webAuthnPolicyUserVerificationRequirement: "Preferred", + }); + webauthnPage.webAuthnPolicyCreateTimeout(30).save(); + masthead.checkNotificationMessage( + "Updated webauthn policies successfully" + ); + }); + + it("should fill webauthn passwordless settings", () => { + webauthnPage.goToPasswordlessTab(); + webauthnPage + .fillSelects( + { + webAuthnPolicyAttestationConveyancePreference: "Indirect", + webAuthnPolicyRequireResidentKey: "Yes", + webAuthnPolicyUserVerificationRequirement: "Preferred", + }, + true + ) + .save(); + masthead.checkNotificationMessage( + "Updated webauthn policies successfully" + ); + }); + }); +}); diff --git a/cypress/integration/authentication_test.spec.ts b/cypress/integration/authentication_test.spec.ts index 7dd2cf4ddd..c3a049d7ff 100644 --- a/cypress/integration/authentication_test.spec.ts +++ b/cypress/integration/authentication_test.spec.ts @@ -8,7 +8,6 @@ import FlowDetails from "../support/pages/admin_console/manage/authentication/Fl import RequiredActions from "../support/pages/admin_console/manage/authentication/RequiredActions"; import AdminClient from "../support/util/AdminClient"; import PasswordPolicies from "../support/pages/admin_console/manage/authentication/PasswordPolicies"; -import OTPPolicies from "../support/pages/admin_console/manage/authentication/OTPPolicies"; describe("Authentication test", () => { const loginPage = new LoginPage(); @@ -184,21 +183,4 @@ describe("Authentication test", () => { passwordPoliciesPage.shouldShowEmptyState(); }); }); - - describe("OTP policies tab", () => { - const otpPoliciesPage = new OTPPolicies(); - beforeEach(() => { - keycloakBefore(); - loginPage.logIn(); - sidebarPage.goToAuthentication(); - otpPoliciesPage.goToTab(); - }); - - it("should change to hotp", () => { - otpPoliciesPage.checkSupportedActions("FreeOTP, Google Authenticator"); - otpPoliciesPage.setPolicyType("hotp").increaseInitialCounter().save(); - masthead.checkNotificationMessage("OTP policy successfully updated"); - otpPoliciesPage.checkSupportedActions("FreeOTP"); - }); - }); }); diff --git a/cypress/support/pages/admin_console/manage/authentication/WebAuthnPolicies.ts b/cypress/support/pages/admin_console/manage/authentication/WebAuthnPolicies.ts new file mode 100644 index 0000000000..42ff6e78a0 --- /dev/null +++ b/cypress/support/pages/admin_console/manage/authentication/WebAuthnPolicies.ts @@ -0,0 +1,41 @@ +export default class WebAuthnPolicies { + webAuthnPolicyCreateTimeout(value: number) { + cy.findByTestId("webAuthnPolicyCreateTimeout").type(String(value)); + return this; + } + goToTab() { + cy.get("#pf-tab-policies-policies") + .click() + .get("#pf-tab-3-webauthnPolicy") + .click(); + return this; + } + + goToPasswordlessTab() { + cy.get("#pf-tab-policies-policies") + .click() + .get("#pf-tab-4-webauthnPasswordlessPolicy") + .click(); + return this; + } + + fillSelects(data: Record, isPasswordLess: boolean = false) { + for (const prop of Object.keys(data)) { + cy.get( + `#${ + isPasswordLess ? prop.replace("Policy", "PolicyPasswordless") : prop + }` + ) + .click() + .parent() + .contains(data[prop]) + .click(); + } + return this; + } + + save() { + cy.findByTestId("save").click(); + return this; + } +} diff --git a/src/authentication/help.ts b/src/authentication/help.ts index 896a901d0b..974b1572c9 100644 --- a/src/authentication/help.ts +++ b/src/authentication/help.ts @@ -24,5 +24,28 @@ export default { "How many seconds should an OTP token be valid? Defaults to 30 seconds.", supportedActions: "Applications that are known to work with the current OTP policy", + webauthnIntro: "What is this form used for?", + webAuthnPolicyFormHelp: + "Policy for WebAuthn authentication. This one will be used by 'WebAuthn Register' required action and 'WebAuthn Authenticator' authenticator. Typical usage is, when WebAuthn will be used for the two-factor authentication.", + webAuthnPolicyPasswordlessFormHelp: + "Policy for passwordless WebAuthn authentication. This one will be used by 'Webauthn Register Passwordless' required action and 'WebAuthn Passwordless Authenticator' authenticator. Typical usage is, when WebAuthn will be used as first-factor authentication. Having both 'WebAuthn Policy' and 'WebAuthn Passwordless Policy' allows to use WebAuthn as both first factor and second factor authenticator in the same realm.", + webAuthnPolicySignatureAlgorithms: + "What signature algorithms should be used for Authentication Assertion.", + webAuthnPolicyRpId: + "This is ID as WebAuthn Relying Party. It must be origin's effective domain.", + webAuthnPolicyAttestationConveyancePreference: + "Communicates to an authenticator the preference of how to generate an attestation statement.", + webAuthnPolicyAuthenticatorAttachment: + "Communicates to an authenticator an acceptable attachment pattern.", + webAuthnPolicyRequireResidentKey: + "It tells an authenticator create a public key credential as Resident Key or not.", + webAuthnPolicyUserVerificationRequirement: + "Communicates to an authenticator to confirm actually verifying a user.", + webAuthnPolicyCreateTimeout: + "Timeout value for creating user's public key credential in seconds. if set to 0, this timeout option is not adapted.", + webAuthnPolicyAvoidSameAuthenticatorRegister: + "Avoid registering the authenticator that has already been registered.", + webAuthnPolicyAcceptableAaguids: + "The list of AAGUID of which an authenticator can be registered.", }, }; diff --git a/src/authentication/messages.ts b/src/authentication/messages.ts index c25ac50772..7872538205 100644 --- a/src/authentication/messages.ts +++ b/src/authentication/messages.ts @@ -33,6 +33,44 @@ export default { supportedActions: "Supported actions", updateOtpSuccess: "OTP policy successfully updated", updateOtpError: "Could not update OTP policy: {{error}}", + webAuthnPolicySignatureAlgorithms: "Signature algorithms", + webAuthnPolicyRpId: "Relying party ID", + webAuthnPolicyAttestationConveyancePreference: + "Attestation conveyance preference", + attestationPreference: { + "not specified": "Not specified", + none: "None", + indirect: "Indirect", + direct: "Direct", + }, + webAuthnPolicyAuthenticatorAttachment: "Authenticator Attachment", + authenticatorAttachment: { + "not specified": "Not specified", + platform: "Platform", + "cross-platform": "Cross platform", + }, + webAuthnPolicyRequireResidentKey: "Require resident key", + residentKey: { + "not specified": "Not specified", + Yes: "Yes", + No: "No", + }, + webAuthnPolicyUserVerificationRequirement: "User verification requirement", + userVerify: { + "not specified": "Not specified", + required: "Required", + preferred: "Preferred", + discouraged: "Discouraged", + }, + webAuthnPolicyCreateTimeout: "Timeout", + webAuthnPolicyCreateTimeoutHint: + "Timeout needs to be between 0 seconds and 8 hours", + webAuthnPolicyAvoidSameAuthenticatorRegister: + "Avoid same authenticator registration", + webAuthnPolicyAcceptableAaguids: "Acceptable AAGUIDs", + addAaguids: "Add AAGUID", + webAuthnUpdateSuccess: "Updated webauthn policies successfully", + webAuthnUpdateError: "Could not update webauthn policies due to {{error}}", flowName: "Flow name", searchForFlow: "Search for flow", usedBy: "Used by", diff --git a/src/authentication/policies/OtpPolicy.tsx b/src/authentication/policies/OtpPolicy.tsx index a75c60eda4..d8ea0934c8 100644 --- a/src/authentication/policies/OtpPolicy.tsx +++ b/src/authentication/policies/OtpPolicy.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Controller, useForm, useWatch } from "react-hook-form"; import { @@ -21,7 +21,7 @@ import { FormAccess } from "../../components/form-access/FormAccess"; import { HelpItem } from "../../components/help-enabler/HelpItem"; import useToggle from "../../utils/useToggle"; import { TimeSelector } from "../../components/time-selector/TimeSelector"; -import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import { useAdminClient } from "../../context/auth/AdminClient"; import { useRealm } from "../../context/realm-context/RealmContext"; import { useAlerts } from "../../components/alert/Alerts"; @@ -31,10 +31,16 @@ const POLICY_TYPES = ["totp", "hotp"] as const; const OTP_HASH_ALGORITHMS = ["SHA1", "SHA256", "SHA512"] as const; const NUMBER_OF_DIGITS = [6, 8] as const; -export const OtpPolicy = () => { +type OtpPolicyProps = { + realm: RealmRepresentation; + realmUpdated: (realm: RealmRepresentation) => void; +}; + +export const OtpPolicy = ({ realm, realmUpdated }: OtpPolicyProps) => { const { t } = useTranslation("authentication"); const { control, + register, errors, reset, handleSubmit, @@ -45,7 +51,6 @@ export const OtpPolicy = () => { const { addAlert, addError } = useAlerts(); const [open, toggle] = useToggle(); - const [realm, setRealm] = useState(); const otpType = useWatch({ name: "otpPolicyType", @@ -53,14 +58,13 @@ export const OtpPolicy = () => { defaultValue: POLICY_TYPES[0], }); - useFetch( - () => adminClient.realms.findOne({ realm: realmName }), - (realm) => { - setRealm(realm); - reset({ ...realm }); - }, - [] - ); + const setupForm = (realm: RealmRepresentation) => + reset({ + ...realm, + otpSupportedApplications: realm.otpSupportedApplications?.join(", "), + }); + + useEffect(() => setupForm(realm), []); const save = async (realm: RealmRepresentation) => { try { @@ -68,8 +72,8 @@ export const OtpPolicy = () => { const updatedRealm = await adminClient.realms.findOne({ realm: realmName, }); - setRealm(updatedRealm); - reset({ ...updatedRealm }); + realmUpdated(updatedRealm!); + setupForm(updatedRealm!); addAlert(t("updateOtpSuccess"), AlertVariant.success); } catch (error) { addError("authentication:updateOtpError", error); @@ -310,9 +314,12 @@ export const OtpPolicy = () => { > value.split(", "), + })} data-testid="supportedActions" isReadOnly - value={realm?.otpSupportedApplications?.join(", ")} /> diff --git a/src/authentication/policies/PasswordPolicy.tsx b/src/authentication/policies/PasswordPolicy.tsx index 96cc7c521a..a0923c9eee 100644 --- a/src/authentication/policies/PasswordPolicy.tsx +++ b/src/authentication/policies/PasswordPolicy.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { @@ -23,10 +23,9 @@ import { PlusCircleIcon } from "@patternfly/react-icons"; import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type PasswordPolicyTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/passwordPolicyTypeRepresentation"; -import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner"; import { useServerInfo } from "../../context/server-info/ServerInfoProvider"; import { FormAccess } from "../../components/form-access/FormAccess"; -import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import { useAdminClient } from "../../context/auth/AdminClient"; import { useRealm } from "../../context/realm-context/RealmContext"; import { useAlerts } from "../../components/alert/Alerts"; import { parsePolicy, SubmittedValues } from "./util"; @@ -72,15 +71,22 @@ const PolicySelect = ({ onSelect, selectedPolicies }: PolicySelectProps) => { ); }; -export const PasswordPolicy = () => { +type PasswordPolicyProps = { + realm: RealmRepresentation; + realmUpdated: (realm: RealmRepresentation) => void; +}; + +export const PasswordPolicy = ({ + realm, + realmUpdated, +}: PasswordPolicyProps) => { const { t } = useTranslation("authentication"); const { passwordPolicies } = useServerInfo(); const adminClient = useAdminClient(); - const { realm: realmName } = useRealm(); const { addAlert, addError } = useAlerts(); + const { realm: realmName } = useRealm(); - const [realm, setRealm] = useState(); const [rows, setRows] = useState([]); const onSelect = (row: PasswordPolicyTypeRepresentation) => setRows([...rows, row]); @@ -96,20 +102,7 @@ export const PasswordPolicy = () => { setRows(values); }; - useFetch( - async () => { - const realm = await adminClient.realms.findOne({ realm: realmName }); - if (!realm) { - throw new Error(t("common:notFound")); - } - return realm; - }, - (realm) => { - setRealm(realm); - setupForm(realm); - }, - [] - ); + useEffect(() => setupForm(realm), []); const save = async (values: SubmittedValues) => { const updatedRealm = { @@ -118,17 +111,14 @@ export const PasswordPolicy = () => { }; try { await adminClient.realms.update({ realm: realmName }, updatedRealm); - setRealm(updatedRealm); + realmUpdated(updatedRealm); + setupForm(updatedRealm); addAlert(t("updatePasswordPolicySuccess"), AlertVariant.success); } catch (error: any) { addError("authentication:updatePasswordPolicyError", error); } }; - if (!realm) { - return ; - } - return ( {(rows.length !== 0 || realm.passwordPolicy) && ( diff --git a/src/authentication/policies/Policies.tsx b/src/authentication/policies/Policies.tsx index 1593f1495c..575cfa5bcc 100644 --- a/src/authentication/policies/Policies.tsx +++ b/src/authentication/policies/Policies.tsx @@ -2,12 +2,39 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Tab, Tabs, TabTitleText } from "@patternfly/react-core"; +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import { useRealm } from "../../context/realm-context/RealmContext"; import { PasswordPolicy } from "./PasswordPolicy"; import { OtpPolicy } from "./OtpPolicy"; +import { WebauthnPolicy } from "./WebauthnPolicy"; +import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner"; export const Policies = () => { const { t } = useTranslation("authentication"); const [subTab, setSubTab] = useState(1); + const adminClient = useAdminClient(); + const { realm: realmName } = useRealm(); + const [realm, setRealm] = useState(); + + useFetch( + async () => { + const realm = await adminClient.realms.findOne({ realm: realmName }); + if (!realm) { + throw new Error(t("common:notFound")); + } + return realm; + }, + (realm) => { + setRealm(realm); + }, + [] + ); + + if (!realm) { + return ; + } + return ( { eventKey={1} title={{t("passwordPolicy")}} > - + {t("otpPolicy")}} > - + {t("webauthnPolicy")}} - > + > + + {t("webauthnPasswordlessPolicy")}} - > + > + + ); }; diff --git a/src/authentication/policies/WebauthnPolicy.tsx b/src/authentication/policies/WebauthnPolicy.tsx new file mode 100644 index 0000000000..e27af513c8 --- /dev/null +++ b/src/authentication/policies/WebauthnPolicy.tsx @@ -0,0 +1,381 @@ +import React, { useEffect, useState } from "react"; +import { + Controller, + FormProvider, + useForm, + useFormContext, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { + ActionGroup, + AlertVariant, + Button, + ButtonVariant, + FormGroup, + PageSection, + Popover, + Select, + SelectOption, + SelectVariant, + Switch, + Text, + TextContent, + TextInput, +} from "@patternfly/react-core"; +import { QuestionCircleIcon } from "@patternfly/react-icons"; + +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { convertFormValuesToObject, convertToFormValues } from "../../util"; +import { useAdminClient } from "../../context/auth/AdminClient"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { FormAccess } from "../../components/form-access/FormAccess"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { useHelp } from "../../components/help-enabler/HelpHeader"; +import { useAlerts } from "../../components/alert/Alerts"; +import { TimeSelector } from "../../components/time-selector/TimeSelector"; +import { MultiLineInput } from "../../components/multi-line-input/MultiLineInput"; + +import "./webauthn-policy.css"; + +const SIGNATURE_ALGORITHMS = [ + "ES256", + "ES384", + "ES512", + "RS256", + "RS384", + "RS512", + "RS1", +] as const; +const ATTESTATION_PREFERENCE = [ + "not specified", + "none", + "indirect", + "direct", +] as const; + +const AUTHENTICATOR_ATTACHMENT = [ + "not specified", + "platform", + "cross-platform", +] as const; + +const RESIDENT_KEY_OPTIONS = ["not specified", "Yes", "No"] as const; + +const USER_VERIFY = [ + "not specified", + "required", + "preferred", + "discouraged", +] as const; + +type WeauthnSelectProps = { + name: string; + label: string; + options: readonly string[]; + labelPrefix?: string; + isMultiSelect?: boolean; +}; + +const WebauthnSelect = ({ + name, + label, + options, + labelPrefix, + isMultiSelect = false, +}: WeauthnSelectProps) => { + const { t } = useTranslation("authentication"); + const { control } = useFormContext(); + + const [open, toggle] = useState(false); + return ( + + } + fieldId={name} + > + ( + + )} + /> + + ); +}; + +const MULTILINE_INPUTS = [ + "webAuthnPolicyAcceptableAaguids", + "webAuthnPolicyPasswordlessAcceptableAaguids", +]; + +type WebauthnPolicyProps = { + realm: RealmRepresentation; + realmUpdated: (realm: RealmRepresentation) => void; + isPasswordLess?: boolean; +}; + +export const WebauthnPolicy = ({ + realm, + realmUpdated, + isPasswordLess = false, +}: WebauthnPolicyProps) => { + const { t } = useTranslation("authentication"); + const adminClient = useAdminClient(); + const { addAlert, addError } = useAlerts(); + const { realm: realmName } = useRealm(); + const { enabled } = useHelp(); + const form = useForm({ mode: "onChange", shouldUnregister: false }); + const { + control, + register, + setValue, + errors, + handleSubmit, + formState: { isDirty }, + } = form; + + const namePrefix = isPasswordLess + ? "webAuthnPolicyPasswordless" + : "webAuthnPolicy"; + + const setupForm = (realm: RealmRepresentation) => + convertToFormValues(realm, setValue, MULTILINE_INPUTS); + + useEffect(() => setupForm(realm), []); + + const save = async (realm: RealmRepresentation) => { + const submittedRealm = convertFormValuesToObject(realm, MULTILINE_INPUTS); + try { + await adminClient.realms.update({ realm: realmName }, submittedRealm); + realmUpdated(submittedRealm); + setupForm(submittedRealm); + addAlert(t("webAuthnUpdateSuccess"), AlertVariant.success); + } catch (error) { + addError("authentication:webAuthnUpdateError", error); + } + }; + + return ( + + {enabled && ( + + + + {t("authentication-help:webauthnIntro")} + + + + )} + + + + } + > + + + + + + } + fieldId="webAuthnPolicyRpId" + > + + + + + + + + } + > + ( + + )} + /> + + + } + > + ( + + )} + /> + + + } + > + + + + + + + + + + + ); +}; diff --git a/src/authentication/policies/webauthn-policy.css b/src/authentication/policies/webauthn-policy.css new file mode 100644 index 0000000000..363e287107 --- /dev/null +++ b/src/authentication/policies/webauthn-policy.css @@ -0,0 +1,11 @@ +.keycloak__webauthn_policies__intro { + padding: var(--pf-global--spacer--md) 0 var(--pf-global--spacer--lg); + color: var(--pf-global--primary-color--100); + width: fit-content; +} + +@media (min-width: 768px) { + .keycloak__webauthn_policies_authentication__form .pf-c-form__group { + --pf-c-form--m-horizontal__group-label--md--GridColumnWidth: 10rem; + } +}