Added Security Defenses tab in the realm settings (#738)

* initial version of the Security defenses tab

* disable when not dirty

* added test

* removed unsessary fetches

* fixed format

* fixed tests

* fixed title

* Update src/realm-settings/security-defences/BruteForceDetection.tsx

Co-authored-by: Jenny <32821331+jenny-s51@users.noreply.github.com>

* Update src/realm-settings/messages.json

Co-authored-by: Jenny <32821331+jenny-s51@users.noreply.github.com>

* fixed test

Co-authored-by: Jenny <32821331+jenny-s51@users.noreply.github.com>
This commit is contained in:
Erik Jan de Wit 2021-07-09 16:23:49 +02:00 committed by GitHub
parent b0d6ad9356
commit dfda67146c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 488 additions and 72 deletions

View file

@ -31,18 +31,17 @@ describe("Realm settings", () => {
});
const goToKeys = () => {
const keysUrl = "/auth/admin/realms/master/keys";
const keysUrl = `/auth/admin/realms/${realmName}/keys`;
cy.intercept(keysUrl).as("keysFetch");
cy.getId("rs-keys-tab").click();
cy.wait(10000);
cy.getId("rs-keys-list-tab").click();
cy.wait(["@keysFetch"]);
return this;
};
const addBundle = () => {
const localizationUrl =
"/auth/admin/realms/master/realm-settings/localization";
const localizationUrl = `/auth/admin/realms/${realmName}/localization/en`;
cy.intercept(localizationUrl).as("localizationFetch");
realmSettingsPage.addKeyValuePair(
@ -50,6 +49,8 @@ describe("Realm settings", () => {
"value_" + (Math.random() + 1).toString(36).substring(7)
);
cy.wait(["@localizationFetch"]);
return this;
};
@ -57,8 +58,10 @@ describe("Realm settings", () => {
sidebarPage.goToRealmSettings();
realmSettingsPage.toggleSwitch(realmSettingsPage.managedAccessSwitch);
realmSettingsPage.save(realmSettingsPage.generalSaveBtn);
masthead.checkNotificationMessage("Realm successfully updated");
realmSettingsPage.toggleSwitch(realmSettingsPage.managedAccessSwitch);
realmSettingsPage.save(realmSettingsPage.generalSaveBtn);
masthead.checkNotificationMessage("Realm successfully updated");
});
it("Go to login tab", () => {
@ -96,9 +99,12 @@ describe("Realm settings", () => {
});
it("Go to themes tab", () => {
cy.wait(5000);
sidebarPage.goToRealmSettings();
cy.intercept(`/auth/admin/realms/${realmName}/keys`).as("load");
cy.getId("rs-themes-tab").click();
cy.wait(["@load"]);
realmSettingsPage.selectLoginThemeType("keycloak");
realmSettingsPage.selectAccountThemeType("keycloak");
realmSettingsPage.selectAdminThemeType("base");
@ -111,10 +117,11 @@ describe("Realm settings", () => {
const listingPage = new ListingPage();
it("Enable user events", () => {
cy.intercept("GET", `/auth/admin/realms/${realmName}/keys`).as("load");
sidebarPage.goToRealmSettings();
cy.getId("rs-realm-events-tab").click();
cy.wait(["@load"]);
cy.wait(5000);
realmSettingsPage
.toggleSwitch(realmSettingsPage.enableEvents)
.save(realmSettingsPage.eventsUserSave);
@ -147,8 +154,6 @@ describe("Realm settings", () => {
});
it("Go to keys tab", () => {
cy.wait(5000);
sidebarPage.goToRealmSettings();
cy.getId("rs-keys-tab").click();
@ -204,4 +209,13 @@ describe("Realm settings", () => {
"Success! The localization text has been created."
);
});
it("Realm header settings", () => {
sidebarPage.goToRealmSettings();
cy.get("#pf-tab-securityDefences-securityDefences").click();
cy.getId("headers-form-tab-save").should("be.disabled");
cy.get("#xFrameOptions").clear().type("DENY");
cy.getId("headers-form-tab-save").should("be.enabled").click();
masthead.checkNotificationMessage("Realm successfully updated");
});
});

View file

@ -45,9 +45,6 @@ export default class RealmSettingsPage {
valueInput = "value-input";
selectLoginThemeType(themeType: string) {
const themesUrl = "/auth/admin/realms/master/themes";
cy.intercept(themesUrl).as("themesFetch");
cy.get(this.selectLoginTheme).click();
cy.get(this.loginThemeList).contains(themeType).click();

View file

@ -1,11 +1,11 @@
import React, { useContext } from "react";
import React, { isValidElement, ReactNode, useContext } from "react";
import { Popover } from "@patternfly/react-core";
import { HelpIcon } from "@patternfly/react-icons";
import { useTranslation } from "react-i18next";
import { HelpContext } from "./HelpHeader";
type HelpItemProps = {
helpText: string;
helpText: string | ReactNode;
forLabel: string;
forID: string;
noVerticalAlign?: boolean;
@ -26,7 +26,11 @@ export const HelpItem = ({
return (
<>
{enabled && (
<Popover bodyContent={t(helpText)}>
<Popover
bodyContent={
isValidElement(helpText) ? helpText : t(helpText as string)
}
>
<>
{!unWrap && (
<button

View file

@ -62,7 +62,7 @@ export const RealmSettingsEmailTab = ({
const authenticationEnabled = useWatch({
control,
name: "smtpServer.authentication",
defaultValue: realm?.smtpServer!.authentication,
defaultValue: {},
});
useEffect(() => {

View file

@ -36,7 +36,12 @@ export const RealmSettingsGeneralTab = ({
const { t } = useTranslation("realm-settings");
const adminClient = useAdminClient();
const { realm: realmName } = useRealm();
const { register, control, handleSubmit } = useFormContext();
const {
register,
control,
handleSubmit,
formState: { isDirty },
} = useFormContext();
const [open, setOpen] = useState(false);
const baseUrl = getBaseUrl(adminClient);
@ -193,6 +198,7 @@ export const RealmSettingsGeneralTab = ({
variant="primary"
type="submit"
data-testid="general-tab-save"
isDisabled={!isDirty}
>
{t("common:save")}
</Button>

View file

@ -34,6 +34,7 @@ import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { LocalizationTab } from "./LocalizationTab";
import { WhoAmIContext } from "../context/whoami/WhoAmI";
import type UserRepresentation from "keycloak-admin/lib/defs/userRepresentation";
import { SecurityDefences } from "./security-defences/SecurityDefences";
type RealmSettingsHeaderProps = {
onChange: (value: boolean) => void;
@ -130,7 +131,7 @@ export const RealmSettingsSection = () => {
const adminClient = useAdminClient();
const { realm: realmName } = useRealm();
const { addAlert } = useAlerts();
const form = useForm();
const form = useForm({ mode: "onChange" });
const { control, getValues, setValue, reset: resetForm } = form;
const [key, setKey] = useState(0);
const [realm, setRealm] = useState<RealmRepresentation>();
@ -143,35 +144,6 @@ export const RealmSettingsSection = () => {
const kpComponentTypes =
useServerInfo().componentTypes!["org.keycloak.keys.KeyProvider"];
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
(realm) => {
setupForm(realm);
setRealm(realm);
},
[]
);
useFetch(
() => adminClient.users.findOne({ id: whoAmI.getUserId()! }),
(user) => {
setCurrentUser(user);
},
[]
);
useEffect(() => {
const update = async () => {
const realmComponents = await adminClient.components.find({
type: "org.keycloak.keys.KeyProvider",
realm: realmName,
});
setRealmComponents(realmComponents);
};
setTimeout(update, 100);
}, [key]);
useFetch(
async () => {
const realm = await adminClient.realms.findOne({ realm: realmName });
@ -179,12 +151,14 @@ export const RealmSettingsSection = () => {
type: "org.keycloak.keys.KeyProvider",
realm: realmName,
});
const user = await adminClient.users.findOne({ id: whoAmI.getUserId()! });
return { realm, realmComponents };
return { user, realm, realmComponents };
},
(result) => {
setRealm(result.realm);
setRealmComponents(result.realmComponents);
({ user, realm, realmComponents }) => {
setRealmComponents(realmComponents);
setCurrentUser(user);
setRealm(realm);
},
[key]
);
@ -194,13 +168,11 @@ export const RealmSettingsSection = () => {
};
useEffect(() => {
if (realm) setupForm(realm);
}, [realm]);
const setupForm = (realm: RealmRepresentation) => {
resetForm(realm);
if (realm) {
Object.entries(realm).map((entry) => setValue(entry[0], entry[1]));
};
resetForm({ ...realm });
}
}, [realm, resetForm]);
const save = async (realm: RealmRepresentation) => {
try {
@ -240,7 +212,7 @@ export const RealmSettingsSection = () => {
>
<RealmSettingsGeneralTab
save={save}
reset={() => setupForm(realm!)}
reset={() => resetForm(realm!)}
/>
</Tab>
<Tab
@ -266,7 +238,7 @@ export const RealmSettingsSection = () => {
>
<RealmSettingsThemesTab
save={save}
reset={() => setupForm(realm!)}
reset={() => resetForm(realm!)}
realm={realm!}
/>
</Tab>
@ -311,22 +283,31 @@ export const RealmSettingsSection = () => {
<EventsTab />
</Tab>
{realm && (
<Tab
id="localization"
eventKey="localization"
data-testid="rs-localization-tab"
title={<TabTitleText>{t("localization")}</TabTitleText>}
>
{realm && (
<LocalizationTab
key={key}
refresh={refresh}
save={save}
reset={() => setupForm(realm)}
reset={() => resetForm(realm)}
realm={realm}
/>
</Tab>
)}
</Tab>
<Tab
id="securityDefences"
eventKey="securityDefences"
title={<TabTitleText>{t("securityDefences")}</TabTitleText>}
>
{realm && (
<SecurityDefences save={save} reset={() => resetForm(realm)} />
)}
</Tab>
</KeycloakTabs>
</FormProvider>
</PageSection>

View file

@ -31,7 +31,20 @@
"keyAlias": "Alias for the private key",
"keyPassword": "Password for the private key",
"privateRSAKey": "Private RSA Key encoded in PEM format",
"x509Certificate": "X509 Certificate encoded in PEM format"
"x509Certificate": "X509 Certificate encoded in PEM format",
"xFrameOptions": "Default value prevents pages from being included by non-origin iframes <1>Learn more</1>",
"contentSecurityPolicy": "Default value prevents pages from being included by non-origin iframes <1>Learn more</1>",
"contentSecurityPolicyReportOnly": "For testing Content Security Policies <1>Learn more</1>",
"xContentTypeOptions": "Default value prevents Internet Explorer and Google Chrome from MIME-sniffing a response away from the declared content-type <1>Learn more</1>",
"xRobotsTag": "Prevent pages from appearing in search engines <1>Learn more</1>",
"xXSSProtection": "Prevent pages from appearing in search engines <1>Learn more</1>",
"strictTransportSecurity": "The Strict-Transport-Security HTTP header tells browsers to always use HTTPS. Once a browser sees this header, it will only visit the site over HTTPS for the time specified (1 year) at max-age, including the subdomains. <1>Learn more</1>",
"failureFactor": "How many failures before wait is triggered.",
"permanentLockout": "Lock the user permanently when the user exceeds the maximum login failures.",
"waitIncrement": "When failure threshold has been met, how much time should the user be locked out?",
"maxFailureWait": "Max time a user will be locked out.",
"maxDeltaTime": "When will failure count be reset?",
"quickLoginCheckMilliSeconds": "If a failure happens concurrently too quickly, lock out the user.",
"minimumQuickLoginWait": "How long to wait after a quick login failure."
}
}

View file

@ -505,7 +505,26 @@
"confirm": "Confirm",
"noMessageBundles": "No message bundles",
"noMessageBundlesInstructions": "Add a message bundle to get started.",
"messageBundleDescription": "You can edit the supported locales. If you haven't selected supported locales yet, you can only edit the English locale."
"messageBundleDescription": "You can edit the supported locales. If you haven't selected supported locales yet, you can only edit the English locale.",
"defaultRoles": "Default roles",
"defaultGroups": "Default groups",
"securityDefences": "Security defenses",
"headers": "Headers",
"bruteForceDetection": "Brute force detection",
"xFrameOptions": "X-Frame-Options",
"contentSecurityPolicy": "Content-Security-Policy",
"contentSecurityPolicyReportOnly": "Content-Security-Policy-Report-Only",
"xContentTypeOptions": "X-Content-Type-Options",
"xRobotsTag": "X-Robots-Tag",
"xXSSProtection": "X-XSS-Protection",
"strictTransportSecurity": "HTTP Strict Transport Security (HSTS)",
"failureFactor": "Max login failures",
"permanentLockout": "Permanent lockout",
"waitIncrement": "Wait increment",
"maxFailureWait": "Max wait",
"maxDeltaTime": "Failure reset time",
"quickLoginCheckMilliSeconds": "Quick login check milliseconds",
"minimumQuickLoginWaitSeconds": "Minimum quick login wait"
},
"partial-import": {
"partialImportHeaderText": "Partial import allows you to import users, clients, and other resources from a previously exported json file.",

View file

@ -0,0 +1,175 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import {
ActionGroup,
Button,
FormGroup,
NumberInput,
Switch,
} from "@patternfly/react-core";
import type RealmRepresentation from "keycloak-admin/lib/defs/realmRepresentation";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { Time } from "./Time";
type BruteForceDetectionProps = {
save: (realm: RealmRepresentation) => void;
reset: () => void;
};
export const BruteForceDetection = ({
save,
reset,
}: BruteForceDetectionProps) => {
const { t } = useTranslation("realm-settings");
const {
handleSubmit,
control,
formState: { isDirty },
} = useFormContext();
const enable = useWatch({
control,
name: "bruteForceProtected",
});
const permanentLockout = useWatch({
control,
name: "permanentLockout",
});
return (
<FormAccess role="manage-realm" isHorizontal onSubmit={handleSubmit(save)}>
<FormGroup
label={t("common:enabled")}
fieldId="bruteForceProtected"
hasNoPaddingTop
>
<Controller
name="bruteForceProtected"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id="bruteForceProtected"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
{enable && (
<>
<FormGroup
label={t("failureFactor")}
labelIcon={
<HelpItem
helpText="realm-settings-help:failureFactor"
forLabel={t("failureFactor")}
forID="failureFactor"
/>
}
fieldId="failureFactor"
>
<Controller
name="failureFactor"
defaultValue={0}
control={control}
rules={{ required: true }}
render={({ onChange, value }) => (
<NumberInput
type="text"
id="failureFactor"
value={value}
onPlus={() => onChange(value + 1)}
onMinus={() => onChange(value - 1)}
onChange={(event) =>
onChange(Number((event.target as HTMLInputElement).value))
}
/>
)}
/>
</FormGroup>
<FormGroup
label={t("permanentLockout")}
fieldId="permanentLockout"
hasNoPaddingTop
>
<Controller
name="permanentLockout"
defaultValue={false}
control={control}
render={({ onChange, value }) => (
<Switch
id="permanentLockout"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value}
onChange={onChange}
/>
)}
/>
</FormGroup>
{permanentLockout && (
<>
<Time name="waitIncrement" />
<Time name="maxFailureWait" />
<Time name="maxDeltaTime" />
</>
)}
<FormGroup
label={t("quickLoginCheckMilliSeconds")}
labelIcon={
<HelpItem
helpText="realm-settings-help:quickLoginCheckMilliSeconds"
forLabel={t("quickLoginCheckMilliSeconds")}
forID="quickLoginCheckMilliSeconds"
/>
}
fieldId="quickLoginCheckMilliSeconds"
>
<Controller
name="quickLoginCheckMilliSeconds"
defaultValue={0}
control={control}
render={({ onChange, value }) => (
<NumberInput
type="text"
id="quickLoginCheckMilliSeconds"
value={value}
onPlus={() => onChange(value + 1)}
onMinus={() => onChange(value - 1)}
onChange={(event) =>
onChange(Number((event.target as HTMLInputElement).value))
}
/>
)}
/>
</FormGroup>
<Time name="minimumQuickLoginWaitSeconds" />
</>
)}
<ActionGroup>
<Button
variant="primary"
type="submit"
data-testid="brute-force-tab-save"
isDisabled={!isDirty}
>
{t("common:save")}
</Button>
<Button variant="link" onClick={reset}>
{t("common:revert")}
</Button>
</ActionGroup>
</FormAccess>
);
};

View file

@ -0,0 +1,75 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useFormContext } from "react-hook-form";
import { ActionGroup, Button } from "@patternfly/react-core";
import type RealmRepresentation from "keycloak-admin/lib/defs/realmRepresentation";
import { FormAccess } from "../../components/form-access/FormAccess";
import { HelpLinkTextInput } from "./HelpLinkTextInput";
import "./security-defences.css";
type HeadersFormProps = {
save: (realm: RealmRepresentation) => void;
reset: () => void;
};
export const HeadersForm = ({ save, reset }: HeadersFormProps) => {
const { t } = useTranslation();
const {
formState: { isDirty },
handleSubmit,
} = useFormContext();
return (
<FormAccess
isHorizontal
role="manage-realm"
className="keycloak__security-defences__form"
onSubmit={handleSubmit(save)}
>
<HelpLinkTextInput
fieldName="browserSecurityHeaders.xFrameOptions"
url="http://tools.ietf.org/html/rfc7034"
/>
<HelpLinkTextInput
fieldName="browserSecurityHeaders.contentSecurityPolicy"
url="http://www.w3.org/TR/CSP/"
/>
<HelpLinkTextInput
fieldName="browserSecurityHeaders.contentSecurityPolicyReportOnly"
url="http://www.w3.org/TR/CSP/"
/>
<HelpLinkTextInput
fieldName="browserSecurityHeaders.xContentTypeOptions"
url="https://www.owasp.org/index.php/List_of_useful_HTTP_headers"
/>
<HelpLinkTextInput
fieldName="browserSecurityHeaders.xRobotsTag"
url="https://developers.google.com/webmasters/control-crawl-index/docs/robots_meta_tag"
/>
<HelpLinkTextInput
fieldName="browserSecurityHeaders.xXSSProtection"
url="https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#xxxsp"
/>
<HelpLinkTextInput
fieldName="browserSecurityHeaders.strictTransportSecurity"
url="https://www.owasp.org/index.php/OWASP_Secure_Headers_Project#hsts"
/>
<ActionGroup>
<Button
variant="primary"
type="submit"
data-testid="headers-form-tab-save"
isDisabled={!isDirty}
>
{t("common:save")}
</Button>
<Button variant="link" onClick={reset}>
{t("common:revert")}
</Button>
</ActionGroup>
</FormAccess>
);
};

View file

@ -0,0 +1,41 @@
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { FormGroup, TextInput } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { FormattedLink } from "../../components/external-link/FormattedLink";
import { useFormContext } from "react-hook-form";
type HelpLinkTextInputProps = {
fieldName: string;
url: string;
};
export const HelpLinkTextInput = ({
fieldName,
url,
}: HelpLinkTextInputProps) => {
const { t } = useTranslation("realm-settings");
const { register } = useFormContext();
const name = fieldName.substr(fieldName.indexOf(".") + 1);
return (
<FormGroup
label={t(name)}
fieldId={name}
labelIcon={
<HelpItem
helpText={
<Trans i18nKey={`realm-settings-help:${name}`}>
Default value prevents pages from being included
<FormattedLink href={url} title={t("common:learnMore")} />
</Trans>
}
forLabel={t(name)}
forID={name}
/>
}
>
<TextInput type="text" id={name} name={fieldName} ref={register} />
</FormGroup>
);
};

View file

@ -0,0 +1,42 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { PageSection, Tab, Tabs, TabTitleText } from "@patternfly/react-core";
import type RealmRepresentation from "keycloak-admin/lib/defs/realmRepresentation";
import { HeadersForm } from "./HeadersForm";
import { BruteForceDetection } from "./BruteForceDetection";
type SecurityDefencesProps = {
save: (realm: RealmRepresentation) => void;
reset: () => void;
};
export const SecurityDefences = ({ save, reset }: SecurityDefencesProps) => {
const { t } = useTranslation("realm-settings");
const [activeTab, setActiveTab] = useState(10);
return (
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
>
<Tab
id="headers"
eventKey={10}
title={<TabTitleText>{t("headers")}</TabTitleText>}
>
<PageSection variant="light">
<HeadersForm save={save} reset={reset} />
</PageSection>
</Tab>
<Tab
id="bruteForce"
eventKey={20}
title={<TabTitleText>{t("bruteForceDetection")}</TabTitleText>}
>
<PageSection variant="light">
<BruteForceDetection save={save} reset={reset} />
</PageSection>
</Tab>
</Tabs>
);
};

View file

@ -0,0 +1,45 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import { FormGroup, ValidatedOptions } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { TimeSelector } from "../../components/time-selector/TimeSelector";
export const Time = ({ name }: { name: string }) => {
const { t } = useTranslation("realm-settings");
const { control, errors } = useFormContext();
return (
<FormGroup
label={t(name)}
fieldId={name}
labelIcon={
<HelpItem
helpText={`realm-settings-help:${name}`}
forLabel={t(name)}
forID={name}
/>
}
validated={
errors[name] ? ValidatedOptions.error : ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<Controller
name={name}
defaultValue=""
control={control}
rules={{ required: true }}
render={({ onChange, value }) => (
<TimeSelector
value={value}
onChange={onChange}
validated={
errors[name] ? ValidatedOptions.error : ValidatedOptions.default
}
/>
)}
/>
</FormGroup>
);
};

View file

@ -0,0 +1,4 @@
.keycloak__security-defences__form {
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: 12rem;
}