added otp policies screen (#1665)
This commit is contained in:
parent
0516c60839
commit
d4c50f6218
7 changed files with 438 additions and 1 deletions
|
@ -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 RequiredActions from "../support/pages/admin_console/manage/authentication/RequiredActions";
|
||||||
import AdminClient from "../support/util/AdminClient";
|
import AdminClient from "../support/util/AdminClient";
|
||||||
import PasswordPolicies from "../support/pages/admin_console/manage/authentication/PasswordPolicies";
|
import PasswordPolicies from "../support/pages/admin_console/manage/authentication/PasswordPolicies";
|
||||||
|
import OTPPolicies from "../support/pages/admin_console/manage/authentication/OTPPolicies";
|
||||||
|
|
||||||
describe("Authentication test", () => {
|
describe("Authentication test", () => {
|
||||||
const loginPage = new LoginPage();
|
const loginPage = new LoginPage();
|
||||||
|
@ -183,4 +184,21 @@ describe("Authentication test", () => {
|
||||||
passwordPoliciesPage.shouldShowEmptyState();
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,5 +11,16 @@ export default {
|
||||||
addSubFlow:
|
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.",
|
"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",
|
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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -15,6 +15,22 @@ export default {
|
||||||
updatePasswordPolicyError:
|
updatePasswordPolicyError:
|
||||||
"Could not update the password policies: '{{error}}'",
|
"Could not update the password policies: '{{error}}'",
|
||||||
addPolicy: "Add policy",
|
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",
|
flowName: "Flow name",
|
||||||
searchForFlow: "Search for flow",
|
searchForFlow: "Search for flow",
|
||||||
usedBy: "Used by",
|
usedBy: "Used by",
|
||||||
|
|
345
src/authentication/policies/OtpPolicy.tsx
Normal file
345
src/authentication/policies/OtpPolicy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Tab, Tabs, TabTitleText } from "@patternfly/react-core";
|
import { Tab, Tabs, TabTitleText } from "@patternfly/react-core";
|
||||||
|
|
||||||
import { PasswordPolicy } from "./PasswordPolicy";
|
import { PasswordPolicy } from "./PasswordPolicy";
|
||||||
|
import { OtpPolicy } from "./OtpPolicy";
|
||||||
|
|
||||||
export const Policies = () => {
|
export const Policies = () => {
|
||||||
const { t } = useTranslation("authentication");
|
const { t } = useTranslation("authentication");
|
||||||
|
@ -24,7 +25,9 @@ export const Policies = () => {
|
||||||
id="otpPolicy"
|
id="otpPolicy"
|
||||||
eventKey={2}
|
eventKey={2}
|
||||||
title={<TabTitleText>{t("otpPolicy")}</TabTitleText>}
|
title={<TabTitleText>{t("otpPolicy")}</TabTitleText>}
|
||||||
></Tab>
|
>
|
||||||
|
<OtpPolicy />
|
||||||
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
id="webauthnPolicy"
|
id="webauthnPolicy"
|
||||||
eventKey={3}
|
eventKey={3}
|
||||||
|
|
15
src/authentication/policies/otp-policy.css
Normal file
15
src/authentication/policies/otp-policy.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue