initial version of the password policies (#1601)

This commit is contained in:
Erik Jan de Wit 2021-12-01 10:24:46 +01:00 committed by GitHub
parent 0bbd4ddad1
commit de1d677cc1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 564 additions and 2 deletions

View file

@ -7,6 +7,7 @@ import DuplicateFlowModal from "../support/pages/admin_console/manage/authentica
import FlowDetails from "../support/pages/admin_console/manage/authentication/FlowDetail";
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";
describe("Authentication test", () => {
const loginPage = new LoginPage();
@ -154,4 +155,32 @@ describe("Authentication test", () => {
masthead.checkNotificationMessage("Updated required action successfully");
});
});
describe("Password policies tab", () => {
const passwordPoliciesPage = new PasswordPolicies();
beforeEach(() => {
keycloakBefore();
loginPage.logIn();
sidebarPage.goToAuthentication();
passwordPoliciesPage.goToTab();
});
it("should add password policies", () => {
passwordPoliciesPage
.shouldShowEmptyState()
.addPolicy("Not Recently Used")
.save();
masthead.checkNotificationMessage(
"Password policies successfully updated"
);
});
it("should remove password policies", () => {
passwordPoliciesPage.removePolicy("remove-passwordHistory").save();
masthead.checkNotificationMessage(
"Password policies successfully updated"
);
passwordPoliciesPage.shouldShowEmptyState();
});
});
});

View file

@ -0,0 +1,26 @@
export default class PasswordPolicies {
goToTab() {
cy.get("#pf-tab-policies-policies").click();
return this;
}
shouldShowEmptyState() {
cy.findByTestId("empty-state").should("exist");
return this;
}
addPolicy(name: string) {
cy.get(".pf-c-select").click().contains(name).click();
return this;
}
removePolicy(name: string) {
cy.findByTestId(name).click();
return this;
}
save() {
cy.findByTestId("save").click();
return this;
}
}

View file

@ -29,6 +29,7 @@ import { DuplicateFlowModal } from "./DuplicateFlowModal";
import { toCreateFlow } from "./routes/CreateFlow";
import { toFlow } from "./routes/Flow";
import { RequiredActions } from "./RequiredActions";
import { Policies } from "./policies/Policies";
import "./authentication-section.css";
@ -290,6 +291,13 @@ export default function AuthenticationSection() {
>
<RequiredActions />
</Tab>
<Tab
id="policies"
eventKey="policies"
title={<TabTitleText>{t("policies")}</TabTitleText>}
>
<Policies />
</Tab>
</KeycloakTabs>
</PageSection>
</>

View file

@ -74,7 +74,7 @@ export const EmptyExecutionState = ({
<Flex alignSelf={{ default: "alignSelfCenter" }}>
<FlexItem>
<Button
data-testId={section}
data-testid={section}
variant="tertiary"
onClick={() => setShow(section)}
>

View file

@ -3,6 +3,18 @@ export default {
title: "Authentication",
flows: "Flows",
requiredActions: "Required actions",
policies: "Policies",
passwordPolicy: "Password policy",
otpPolicy: "OTP Policy",
webauthnPolicy: "Webauthn Policy",
webauthnPasswordlessPolicy: "Webauthn Passwordless Policy",
noPasswordPolicies: "No password policies",
noPasswordPoliciesInstructions:
"You haven't added any password policies to this realm. Add a policy to get started.",
updatePasswordPolicySuccess: "Password policies successfully updated",
updatePasswordPolicyError:
"Could not update the password policies: '{{error}}'",
addPolicy: "Add policy",
flowName: "Flow name",
searchForFlow: "Search for flow",
usedBy: "Used by",

View file

@ -0,0 +1,197 @@
import React, { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
ActionGroup,
AlertVariant,
Button,
ButtonVariant,
Divider,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
EmptyStatePrimary,
PageSection,
Select,
SelectOption,
Title,
Toolbar,
ToolbarContent,
ToolbarItem,
} from "@patternfly/react-core";
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 { useRealm } from "../../context/realm-context/RealmContext";
import { useAlerts } from "../../components/alert/Alerts";
import { parsePolicy, SubmittedValues } from "./util";
import { PolicyRow } from "./PolicyRow";
import { serializePolicy } from "./util";
type PolicySelectProps = {
onSelect: (row: PasswordPolicyTypeRepresentation) => void;
selectedPolicies: PasswordPolicyTypeRepresentation[];
};
const PolicySelect = ({ onSelect, selectedPolicies }: PolicySelectProps) => {
const { t } = useTranslation("authentication");
const { passwordPolicies } = useServerInfo();
const [open, setOpen] = useState(false);
const policies = useMemo(
() =>
passwordPolicies?.filter(
(p) => selectedPolicies.find((o) => o.id === p.id) === undefined
),
[selectedPolicies]
);
return (
<Select
width={300}
onSelect={(_, selection) => {
onSelect(selection as PasswordPolicyTypeRepresentation);
setOpen(false);
}}
onToggle={(value) => setOpen(value)}
isOpen={open}
selections={t("addPolicy")}
isDisabled={policies?.length === 0}
>
{policies?.map((policy) => (
<SelectOption key={policy.id} value={policy}>
{policy.displayName}
</SelectOption>
))}
</Select>
);
};
export const PasswordPolicy = () => {
const { t } = useTranslation("authentication");
const { passwordPolicies } = useServerInfo();
const adminClient = useAdminClient();
const { realm: realmName } = useRealm();
const { addAlert, addError } = useAlerts();
const [realm, setRealm] = useState<RealmRepresentation>();
const [rows, setRows] = useState<PasswordPolicyTypeRepresentation[]>([]);
const onSelect = (row: PasswordPolicyTypeRepresentation) =>
setRows([...rows, row]);
const form = useForm<SubmittedValues>({ shouldUnregister: false });
const { handleSubmit, setValue, getValues } = form;
const setupForm = (realm: RealmRepresentation) => {
const values = parsePolicy(realm.passwordPolicy || "", passwordPolicies!);
values.forEach((v) => {
setValue(v.id!, v.value);
});
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);
},
[]
);
const save = async (values: SubmittedValues) => {
const updatedRealm = {
...realm,
passwordPolicy: serializePolicy(rows, values),
};
try {
await adminClient.realms.update({ realm: realmName }, updatedRealm);
setRealm(updatedRealm);
addAlert(t("updatePasswordPolicySuccess"), AlertVariant.success);
} catch (error: any) {
addError("authentication:updatePasswordPolicyError", error);
}
};
if (!realm) {
return <KeycloakSpinner />;
}
return (
<PageSection variant="light" className="pf-u-p-0">
{(rows.length !== 0 || realm.passwordPolicy) && (
<>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<PolicySelect onSelect={onSelect} selectedPolicies={rows} />
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<Divider />
<PageSection variant="light">
<FormProvider {...form}>
<FormAccess
role="manage-realm"
isHorizontal
onSubmit={handleSubmit(save)}
>
{rows.map((r, index) => (
<PolicyRow
key={`${r.id}-${index}`}
policy={r}
onRemove={(id) => setRows(rows.filter((r) => r.id !== id))}
/>
))}
<ActionGroup>
<Button
data-testid="save"
variant="primary"
type="submit"
isDisabled={
serializePolicy(rows, getValues()) ===
realm.passwordPolicy
}
>
{t("common:save")}
</Button>
<Button
data-testid="reload"
variant={ButtonVariant.link}
onClick={() => setupForm(realm)}
>
{t("common:reload")}
</Button>
</ActionGroup>
</FormAccess>
</FormProvider>
</PageSection>
</>
)}
{!rows.length && !realm.passwordPolicy && (
<EmptyState data-testid="empty-state" variant="large">
<EmptyStateIcon icon={PlusCircleIcon} />
<Title headingLevel="h1" size="lg">
{t("noPasswordPolicies")}
</Title>
<EmptyStateBody>{t("noPasswordPoliciesInstructions")}</EmptyStateBody>
<EmptyStatePrimary>
<PolicySelect onSelect={onSelect} selectedPolicies={[]} />
</EmptyStatePrimary>
</EmptyState>
)}
</PageSection>
);
};

View file

@ -0,0 +1,40 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Tab, Tabs, TabTitleText } from "@patternfly/react-core";
import { PasswordPolicy } from "./PasswordPolicy";
export const Policies = () => {
const { t } = useTranslation("authentication");
const [subTab, setSubTab] = useState(1);
return (
<Tabs
activeKey={subTab}
onSelect={(_, key) => setSubTab(key as number)}
mountOnEnter
>
<Tab
id="passwordPolicy"
eventKey={1}
title={<TabTitleText>{t("passwordPolicy")}</TabTitleText>}
>
<PasswordPolicy />
</Tab>
<Tab
id="otpPolicy"
eventKey={2}
title={<TabTitleText>{t("otpPolicy")}</TabTitleText>}
></Tab>
<Tab
id="webauthnPolicy"
eventKey={3}
title={<TabTitleText>{t("webauthnPolicy")}</TabTitleText>}
></Tab>
<Tab
id="webauthnPasswordlessPolicy"
eventKey={4}
title={<TabTitleText>{t("webauthnPasswordlessPolicy")}</TabTitleText>}
></Tab>
</Tabs>
);
};

View file

@ -0,0 +1,105 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import {
Button,
FormGroup,
NumberInput,
Split,
SplitItem,
Switch,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { MinusCircleIcon } from "@patternfly/react-icons";
import type PasswordPolicyTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/passwordPolicyTypeRepresentation";
import "./policy-row.css";
type PolicyRowProps = {
policy: PasswordPolicyTypeRepresentation;
onRemove: (id?: string) => void;
};
export const PolicyRow = ({
policy: { id, configType, defaultValue, displayName },
onRemove,
}: PolicyRowProps) => {
const { t } = useTranslation("authentication");
const { control, register, errors } = useFormContext();
return (
<FormGroup
label={displayName}
fieldId={id!}
isRequired
helperTextInvalid={t("common:required")}
validated={
errors[id!] ? ValidatedOptions.error : ValidatedOptions.default
}
>
<Split>
<SplitItem isFilled>
{configType && configType !== "int" && (
<TextInput
id={id}
data-testid={id}
ref={register({ required: true })}
name={id}
defaultValue={defaultValue}
validated={
errors[id!] ? ValidatedOptions.error : ValidatedOptions.default
}
/>
)}
{configType === "int" && (
<Controller
name={id!}
defaultValue={Number.parseInt(defaultValue || "0")}
control={control}
render={({ onChange, value }) => {
const MIN_VALUE = 0;
const setValue = (newValue: number) =>
onChange(Math.max(newValue, MIN_VALUE));
return (
<NumberInput
id={id}
value={value}
min={MIN_VALUE}
onPlus={() => setValue(value + 1)}
onMinus={() => setValue(value - 1)}
onChange={(event) => {
const newValue = Number(event.currentTarget.value);
setValue(!isNaN(newValue) ? newValue : 0);
}}
className="keycloak__policies_authentication__number-field"
/>
);
}}
/>
)}
{!configType && (
<Switch
id={id!}
label={t("common:on")}
labelOff={t("common:off")}
isChecked
isDisabled
/>
)}
</SplitItem>
<SplitItem>
<Button
data-testid={`remove-${id}`}
variant="link"
className="keycloak__policies_authentication__minus-icon"
onClick={() => onRemove(id)}
>
<MinusCircleIcon />
</Button>
</SplitItem>
</Split>
</FormGroup>
);
};

View file

@ -0,0 +1,8 @@
.keycloak__policies_authentication__minus-icon svg {
color: var(--pf-c-button--m-plain--Color);
}
.keycloak__policies_authentication__number-field {
--pf-c-number-input--c-form-control--Width: 7ch;
}

View file

@ -0,0 +1,76 @@
import type PasswordPolicyTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/passwordPolicyTypeRepresentation";
import { parsePolicy, serializePolicy, SubmittedValues } from "./util";
describe("serializePolicy", () => {
it("returns an empty string if there are no policies", () => {
expect(serializePolicy([], {})).toEqual("");
});
it("encodes the policies", () => {
const policies: PasswordPolicyTypeRepresentation[] = [
{ id: "one" },
{ id: "two" },
];
const submittedValues: SubmittedValues = {
one: "value1",
two: "value2",
};
expect(serializePolicy(policies, submittedValues)).toEqual(
"one(value1) and two(value2)"
);
});
});
describe("parsePolicy", () => {
it("returns an empty array if an empty value is passed", () => {
expect(parsePolicy("", [])).toEqual([]);
});
it("parses the policy", () => {
const policies: PasswordPolicyTypeRepresentation[] = [
{ id: "one" },
{ id: "two" },
];
expect(parsePolicy("one(value1) and two", policies)).toEqual([
{ id: "one", value: "value1" },
{ id: "two" },
]);
});
it("parses the policy and trims excessive whitespace", () => {
const policies: PasswordPolicyTypeRepresentation[] = [
{ id: "one" },
{ id: "two" },
];
expect(parsePolicy("one( value1 ) and two ", policies)).toEqual([
{ id: "one", value: "value1" },
{ id: "two" },
]);
});
it("parses the policy and it handles unescaped values", () => {
const policies: PasswordPolicyTypeRepresentation[] = [{ id: "one" }];
expect(parsePolicy("one(value1", policies)).toEqual([{ id: "one" }]);
});
it("parses the policy and preserves nested parentheses", () => {
const policies: PasswordPolicyTypeRepresentation[] = [{ id: "one" }];
expect(parsePolicy("one(value1))", policies)).toEqual([
{ id: "one", value: "value1)" },
]);
});
it("parses the policy and preserves only existing entries", () => {
const policies: PasswordPolicyTypeRepresentation[] = [{ id: "two" }];
expect(parsePolicy("one(value1) and two", policies)).toEqual([
{ id: "two" },
]);
});
});

View file

@ -0,0 +1,60 @@
import type PasswordPolicyTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/passwordPolicyTypeRepresentation";
export type SubmittedValues = {
[index: string]: string;
};
const POLICY_SEPARATOR = " and ";
export const serializePolicy = (
policies: PasswordPolicyTypeRepresentation[],
submitted: SubmittedValues
) =>
policies
.map((policy) => `${policy.id}(${submitted[policy.id!]})`)
.join(POLICY_SEPARATOR);
type PolicyValue = PasswordPolicyTypeRepresentation & {
value?: string;
};
export const parsePolicy = (
value: string,
policies: PasswordPolicyTypeRepresentation[]
) =>
value
.split(POLICY_SEPARATOR)
.map(parsePolicyToken)
.reduce<PolicyValue[]>((result, { id, value }) => {
const matchingPolicy = policies.find((policy) => policy.id === id);
if (!matchingPolicy) {
return result;
}
return result.concat({ ...matchingPolicy, value });
}, []);
type PolicyTokenParsed = {
id: string;
value?: string;
};
function parsePolicyToken(token: string): PolicyTokenParsed {
const valueStart = token.indexOf("(");
if (valueStart === -1) {
return { id: token.trim() };
}
const id = token.substring(0, valueStart).trim();
const valueEnd = token.lastIndexOf(")");
if (valueEnd === -1) {
return { id };
}
const value = token.substring(valueStart + 1, valueEnd).trim();
return { id, value };
}

View file

@ -10,6 +10,7 @@ export default {
save: "Save",
revert: "Revert",
cancel: "Cancel",
reload: "Reload",
continue: "Continue",
close: "Close",
delete: "Delete",

View file

@ -39,7 +39,7 @@ export const Time = ({
rules={{ required: true }}
render={({ onChange, value }) => (
<TimeSelector
data-testId={name}
data-testid={name}
value={value}
onChange={onChange}
validated={