added otp policies screen (#1665)

This commit is contained in:
Erik Jan de Wit 2021-12-13 11:39:41 +01:00 committed by GitHub
parent 0516c60839
commit d4c50f6218
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 438 additions and 1 deletions

View file

@ -8,6 +8,7 @@ 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();
@ -183,4 +184,21 @@ 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");
});
});
});

View file

@ -0,0 +1,29 @@
export default class OTPPolicies {
goToTab() {
cy.get("#pf-tab-policies-policies")
.click()
.get("#pf-tab-2-otpPolicy")
.click();
return this;
}
setPolicyType(type: string) {
cy.findByTestId(type).click();
return this;
}
increaseInitialCounter() {
cy.get('#initialCounter > .pf-c-input-group > [aria-label="Plus"]').click();
return this;
}
checkSupportedActions(supportedActions: string) {
cy.findByTestId("supportedActions").should("have.value", supportedActions);
return this;
}
save() {
cy.findByTestId("save").click();
return this;
}
}

View file

@ -11,5 +11,16 @@ export default {
addSubFlow:
"Sub-Flows can be either generic or form. The form type is used to construct a sub-flow that generates a single flow for the user. Sub-flows are a special type of execution that evaluate as successful depending on how the executions they contain evaluate.",
alias: "Name of the configuration",
otpType:
"totp is Time-Based One Time Password. 'hotp' is a counter base one time password in which the server keeps a counter to hash against.",
otpHashAlgorithm:
"What hashing algorithm should be used to generate the OTP.",
otpPolicyDigits: "How many digits should the OTP have?",
lookAhead:
"How far ahead should the server look just in case the token generator and server are out of time sync or counter sync?",
otpPolicyPeriod:
"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",
},
};

View file

@ -15,6 +15,22 @@ export default {
updatePasswordPolicyError:
"Could not update the password policies: '{{error}}'",
addPolicy: "Add policy",
otpType: "OTP type",
policyType: {
totp: "Time based",
hotp: "Counter based",
},
otpHashAlgorithm: "OTP hash algorithm",
otpPolicyDigits: "Number of digits",
lookAhead: "Look ahead window",
otpPolicyPeriod: "OTP Token period",
otpPolicyPeriodErrorHint:
"Value needs to be between 1 second and 2 minutes",
initialCounter: "Initial counter",
initialCounterErrorHint: "Value needs to be between 1 and 120",
supportedActions: "Supported actions",
updateOtpSuccess: "OTP policy successfully updated",
updateOtpError: "Could not update OTP policy: {{error}}",
flowName: "Flow name",
searchForFlow: "Search for flow",
usedBy: "Used by",

View file

@ -0,0 +1,345 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useForm, useWatch } from "react-hook-form";
import {
PageSection,
FormGroup,
Radio,
Select,
SelectVariant,
SelectOption,
NumberInput,
ActionGroup,
Button,
TextInput,
ButtonVariant,
AlertVariant,
} from "@patternfly/react-core";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
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 { useRealm } from "../../context/realm-context/RealmContext";
import { useAlerts } from "../../components/alert/Alerts";
import "./otp-policy.css";
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 = () => {
const { t } = useTranslation("authentication");
const {
control,
errors,
reset,
handleSubmit,
formState: { isDirty },
} = useForm({ mode: "onChange" });
const adminClient = useAdminClient();
const { realm: realmName } = useRealm();
const { addAlert, addError } = useAlerts();
const [open, toggle] = useToggle();
const [realm, setRealm] = useState<RealmRepresentation>();
const otpType = useWatch<typeof POLICY_TYPES[number]>({
name: "otpPolicyType",
control,
defaultValue: POLICY_TYPES[0],
});
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
(realm) => {
setRealm(realm);
reset({ ...realm });
},
[]
);
const save = async (realm: RealmRepresentation) => {
try {
await adminClient.realms.update({ realm: realmName }, realm);
const updatedRealm = await adminClient.realms.findOne({
realm: realmName,
});
setRealm(updatedRealm);
reset({ ...updatedRealm });
addAlert(t("updateOtpSuccess"), AlertVariant.success);
} catch (error) {
addError("authentication:updateOtpError", error);
}
};
return (
<PageSection variant="light">
<FormAccess
role="manage-realm"
isHorizontal
onSubmit={handleSubmit(save)}
className="keycloak__otp_policies_authentication__form"
>
<FormGroup
label={t("otpType")}
labelIcon={
<HelpItem
helpText="authentication-help:otpType"
forLabel={t("otpType")}
forID={t(`common:helpLabel`, { label: t("otpType") })}
/>
}
fieldId="otpType"
hasNoPaddingTop
>
<Controller
name="otpPolicyType"
data-testid="otpPolicyType"
defaultValue={POLICY_TYPES[0]}
control={control}
render={({ onChange, value }) => (
<>
{POLICY_TYPES.map((type) => (
<Radio
id={type}
key={type}
data-testid={type}
isChecked={value === type}
name="otpPolicyType"
onChange={() => onChange(type)}
label={t(`policyType.${type}`)}
className="keycloak__otp_policies_authentication__policy-type"
/>
))}
</>
)}
/>
</FormGroup>
<FormGroup
label={t("otpHashAlgorithm")}
labelIcon={
<HelpItem
helpText="authentication-help:otpHashAlgorithm"
forLabel={t("otpHashAlgorithm")}
forID={t(`common:helpLabel`, { label: t("otpHashAlgorithm") })}
/>
}
fieldId="otpHashAlgorithm"
>
<Controller
name="otpPolicyAlgorithm"
defaultValue={`Hmac${OTP_HASH_ALGORITHMS[0]}`}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="otpHashAlgorithm"
onToggle={toggle}
onSelect={(_, value) => {
onChange(value.toString());
toggle();
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("otpHashAlgorithm")}
isOpen={open}
>
{OTP_HASH_ALGORITHMS.map((type) => (
<SelectOption
selected={`Hmac${type}` === value}
key={type}
value={`Hmac${type}`}
>
{type}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("otpPolicyDigits")}
labelIcon={
<HelpItem
helpText="authentication-help:otpPolicyDigits"
forLabel={t("otpPolicyDigits")}
forID={t(`common:helpLabel`, { label: t("otpPolicyDigits") })}
/>
}
fieldId="otpPolicyDigits"
hasNoPaddingTop
>
<Controller
name="otpPolicyDigits"
data-testid="otpPolicyDigits"
defaultValue={NUMBER_OF_DIGITS[0]}
control={control}
render={({ onChange, value }) => (
<>
{NUMBER_OF_DIGITS.map((type) => (
<Radio
id={`digit-${type}`}
key={type}
data-testid={`digit-${type}`}
isChecked={value === type}
name="otpPolicyDigits"
onChange={() => onChange(type)}
label={type}
className="keycloak__otp_policies_authentication__number-of-digits"
/>
))}
</>
)}
/>
</FormGroup>
<FormGroup
label={t("lookAhead")}
labelIcon={
<HelpItem
helpText="authentication-help:lookAhead"
forLabel={t("lookAhead")}
forID={t(`common:helpLabel`, { label: t("lookAhead") })}
/>
}
fieldId="lookAhead"
>
<Controller
name="otpPolicyLookAheadWindow"
defaultValue={1}
control={control}
render={({ onChange, value }) => {
const MIN_VALUE = 0;
const setValue = (newValue: number) =>
onChange(Math.max(newValue, MIN_VALUE));
return (
<NumberInput
id="lookAhead"
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);
}}
/>
);
}}
/>
</FormGroup>
{otpType === POLICY_TYPES[0] && (
<FormGroup
label={t("otpPolicyPeriod")}
fieldId="otpPolicyPeriod"
helperTextInvalid={t("otpPolicyPeriodErrorHint")}
validated={errors.otpPolicyPeriod ? "error" : "default"}
labelIcon={
<HelpItem
helpText="authentication-help:otpPolicyPeriod"
forLabel={t("otpPolicyPeriod")}
forID={t(`common:helpLabel`, { label: t("otpPolicyPeriod") })}
/>
}
>
<Controller
name="otpPolicyPeriod"
defaultValue={30}
control={control}
rules={{ min: 1, max: 120 }}
render={({ onChange, value }) => (
<TimeSelector
data-testid="otpPolicyPeriod"
aria-label={t("otpPolicyPeriod")}
value={value}
onChange={onChange}
units={["seconds", "minutes"]}
validated={errors.otpPolicyPeriod ? "error" : "default"}
/>
)}
/>
</FormGroup>
)}
{otpType === POLICY_TYPES[1] && (
<FormGroup
label={t("initialCounter")}
fieldId="initialCounter"
helperTextInvalid={t("initialCounterErrorHint")}
validated={errors.otpPolicyInitialCounter ? "error" : "default"}
labelIcon={
<HelpItem
helpText="authentication-help:initialCounter"
forLabel={t("initialCounter")}
forID={t(`common:helpLabel`, { label: t("initialCounter") })}
/>
}
>
<Controller
name="otpPolicyInitialCounter"
defaultValue={30}
control={control}
rules={{ min: 1, max: 120 }}
render={({ onChange, value }) => {
const MIN_VALUE = 1;
const setValue = (newValue: number) =>
onChange(Math.max(newValue, MIN_VALUE));
return (
<NumberInput
id="initialCounter"
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 : 30);
}}
/>
);
}}
/>
</FormGroup>
)}
<FormGroup
label={t("supportedActions")}
fieldId="supportedActions"
labelIcon={
<HelpItem
helpText="authentication-help:supportedActions"
forLabel={t("supportedActions")}
forID={t(`common:helpLabel`, { label: t("supportedActions") })}
/>
}
>
<TextInput
id="supportedActions"
data-testid="supportedActions"
isReadOnly
value={realm?.otpSupportedApplications?.join(", ")}
/>
</FormGroup>
<ActionGroup>
<Button
data-testid="save"
variant="primary"
type="submit"
isDisabled={!isDirty}
>
{t("common:save")}
</Button>
<Button
data-testid="reload"
variant={ButtonVariant.link}
onClick={() => reset({ ...realm })}
>
{t("common:reload")}
</Button>
</ActionGroup>
</FormAccess>
</PageSection>
);
};

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Tab, Tabs, TabTitleText } from "@patternfly/react-core";
import { PasswordPolicy } from "./PasswordPolicy";
import { OtpPolicy } from "./OtpPolicy";
export const Policies = () => {
const { t } = useTranslation("authentication");
@ -24,7 +25,9 @@ export const Policies = () => {
id="otpPolicy"
eventKey={2}
title={<TabTitleText>{t("otpPolicy")}</TabTitleText>}
></Tab>
>
<OtpPolicy />
</Tab>
<Tab
id="webauthnPolicy"
eventKey={3}

View file

@ -0,0 +1,15 @@
.keycloak__otp_policies_authentication__policy-type {
display: inline-grid;
padding-right: var(--pf-global--spacer--lg);
}
.keycloak__otp_policies_authentication__number-of-digits {
display: inline-grid;
padding-right: var(--pf-global--spacer--lg);
}
@media (min-width: 768px) {
.keycloak__otp_policies_authentication__form .pf-c-form__group {
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: 10rem;
}
}