added unlock user when brute force detect on (#1093)

* added unlock user when brute force detect on

+ some light refactor

* fixed spelling

* fixed merge error
This commit is contained in:
Erik Jan de Wit 2021-09-02 15:44:31 +02:00 committed by GitHub
parent 0853e20ba1
commit e58dfc7508
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 170 additions and 148 deletions

View file

@ -13,31 +13,39 @@ import {
TextInput, TextInput,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Controller, UseFormMethods } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { useHistory, useParams } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { FormAccess } from "../components/form-access/FormAccess"; import { FormAccess } from "../components/form-access/FormAccess";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { HelpItem } from "../components/help-enabler/HelpItem"; import { HelpItem } from "../components/help-enabler/HelpItem";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
import { useFetch, useAdminClient } from "../context/auth/AdminClient"; import { useAdminClient } from "../context/auth/AdminClient";
import moment from "moment";
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { emailRegexPattern } from "../util"; import { emailRegexPattern } from "../util";
import { GroupPickerDialog } from "../components/group/GroupPickerDialog"; import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
import moment from "moment";
export type BruteForced = {
isBruteForceProtected?: boolean;
isLocked?: boolean;
};
export type UserFormProps = { export type UserFormProps = {
form: UseFormMethods<UserRepresentation>; user?: UserRepresentation;
bruteForce?: BruteForced;
save: (user: UserRepresentation) => void; save: (user: UserRepresentation) => void;
editMode: boolean;
timestamp?: number;
onGroupsUpdate: (groups: GroupRepresentation[]) => void; onGroupsUpdate: (groups: GroupRepresentation[]) => void;
}; };
export const UserForm = ({ export const UserForm = ({
form: { handleSubmit, register, errors, watch, control, setValue, reset }, user,
bruteForce: { isBruteForceProtected, isLocked } = {
isBruteForceProtected: false,
isLocked: false,
},
save, save,
editMode,
onGroupsUpdate, onGroupsUpdate,
}: UserFormProps) => { }: UserFormProps) => {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
@ -49,36 +57,24 @@ export const UserForm = ({
] = useState(false); ] = useState(false);
const history = useHistory(); const history = useHistory();
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const { id } = useParams<{ id: string }>(); const { addAlert, addError } = useAlerts();
const { handleSubmit, register, errors, watch, control, reset } =
useFormContext();
const watchUsernameInput = watch("username"); const watchUsernameInput = watch("username");
const [user, setUser] = useState<UserRepresentation>();
const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>( const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>(
[] []
); );
const { addAlert, addError } = useAlerts();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [locked, setLocked] = useState(isLocked);
useFetch( const unLockUser = async () => {
async () => { try {
if (editMode) return await adminClient.users.findOne({ id: id }); await adminClient.attackDetection.del({ id: user!.id! });
}, addAlert(t("unlockSuccess"), AlertVariant.success);
(user) => { } catch (error) {
if (user) { addError("users:unlockError", error);
setupForm(user);
setUser(user);
} }
},
[selectedGroups]
);
const setupForm = (user: UserRepresentation) => {
reset();
Object.entries(user).map((entry) => {
setValue(entry[0], entry[1]);
});
}; };
const requiredUserActionsOptions = [ const requiredUserActionsOptions = [
@ -116,7 +112,7 @@ export const UserForm = ({
newGroups.forEach(async (group) => { newGroups.forEach(async (group) => {
try { try {
await adminClient.users.addToGroup({ await adminClient.users.addToGroup({
id, id: user!.id!,
groupId: group.id!, groupId: group.id!,
}); });
addAlert(t("users:addedGroupMembership"), AlertVariant.success); addAlert(t("users:addedGroupMembership"), AlertVariant.success);
@ -145,17 +141,17 @@ export const UserForm = ({
ok: "users:join", ok: "users:join",
}} }}
onConfirm={(groups) => { onConfirm={(groups) => {
editMode ? addGroups(groups) : addChips(groups); user?.id ? addGroups(groups) : addChips(groups);
setOpen(false); setOpen(false);
}} }}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
filterGroups={selectedGroups} filterGroups={selectedGroups}
/> />
)} )}
{editMode && user ? ( {user?.id ? (
<> <>
<FormGroup label={t("common:id")} fieldId="kc-id" isRequired> <FormGroup label={t("common:id")} fieldId="kc-id" isRequired>
<TextInput id={user.id} value={user.id} type="text" isReadOnly /> <TextInput id={user?.id} value={user?.id} type="text" isReadOnly />
</FormGroup> </FormGroup>
<FormGroup label={t("createdAt")} fieldId="kc-created-at" isRequired> <FormGroup label={t("createdAt")} fieldId="kc-created-at" isRequired>
<TextInput <TextInput
@ -182,7 +178,7 @@ export const UserForm = ({
type="text" type="text"
id="kc-username" id="kc-username"
name="username" name="username"
isReadOnly={editMode} isReadOnly={!!user?.id}
/> />
</FormGroup> </FormGroup>
)} )}
@ -260,6 +256,32 @@ export const UserForm = ({
aria-label={t("lastName")} aria-label={t("lastName")}
/> />
</FormGroup> </FormGroup>
{isBruteForceProtected && (
<FormGroup
label={t("temporaryLocked")}
fieldId="temporaryLocked"
labelIcon={
<HelpItem
helpText="users-help:temporaryLocked"
forLabel={t("temporaryLocked")}
forID={t(`common:helpLabel`, { label: t("temporaryLocked") })}
/>
}
>
<Switch
data-testid="user-locked-switch"
id={"temporaryLocked"}
onChange={(value) => {
unLockUser();
setLocked(value);
}}
isChecked={locked}
isDisabled={!locked}
label={t("common:on")}
labelOff={t("common:off")}
/>
</FormGroup>
)}
<FormGroup <FormGroup
label={t("common:enabled")} label={t("common:enabled")}
fieldId="kc-enabled" fieldId="kc-enabled"
@ -333,7 +355,7 @@ export const UserForm = ({
)} )}
/> />
</FormGroup> </FormGroup>
{!editMode && ( {!user?.id && (
<FormGroup <FormGroup
label={t("common:groups")} label={t("common:groups")}
fieldId="kc-groups" fieldId="kc-groups"
@ -380,21 +402,21 @@ export const UserForm = ({
<ActionGroup> <ActionGroup>
<Button <Button
data-testid={!editMode ? "create-user" : "save-user"} data-testid={!user?.id ? "create-user" : "save-user"}
isDisabled={!editMode && !watchUsernameInput} isDisabled={!user?.id && !watchUsernameInput}
variant="primary" variant="primary"
type="submit" type="submit"
> >
{editMode ? t("common:save") : t("common:create")} {user?.id ? t("common:save") : t("common:create")}
</Button> </Button>
<Button <Button
data-testid="cancel-create-user" data-testid="cancel-create-user"
onClick={() => onClick={() =>
editMode ? setupForm(user!) : history.push(`/${realm}/users`) user?.id ? reset(user) : history.push(`/${realm}/users`)
} }
variant="link" variant="link"
> >
{editMode ? t("common:revert") : t("common:cancel")} {user?.id ? t("common:revert") : t("common:cancel")}
</Button> </Button>
</ActionGroup> </ActionGroup>
</FormAccess> </FormAccess>

View file

@ -12,7 +12,6 @@ import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/us
import _ from "lodash"; import _ from "lodash";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { GroupPath } from "../components/group/GroupPath"; import { GroupPath } from "../components/group/GroupPath";
@ -20,31 +19,21 @@ import { GroupPickerDialog } from "../components/group/GroupPickerDialog";
import { useHelp } from "../components/help-enabler/HelpHeader"; import { useHelp } from "../components/help-enabler/HelpHeader";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useAdminClient, useFetch } from "../context/auth/AdminClient"; import { useAdminClient } from "../context/auth/AdminClient";
import { emptyFormatter } from "../util"; import { emptyFormatter } from "../util";
export type UserFormProps = { type UserGroupsProps = {
username?: string; user: UserRepresentation;
loader?: (
first?: number,
max?: number,
search?: string
) => Promise<UserRepresentation[]>;
addGroup?: (newGroup: GroupRepresentation) => void;
}; };
export const UserGroups = () => { export const UserGroups = ({ user }: UserGroupsProps) => {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime()); const refresh = () => setKey(new Date().getTime());
const [selectedGroup, setSelectedGroup] = useState<GroupRepresentation>(); const [selectedGroup, setSelectedGroup] = useState<GroupRepresentation>();
const [list, setList] = useState(false);
const [listGroups, setListGroups] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [username, setUsername] = useState("");
const [isDirectMembership, setDirectMembership] = useState(true); const [isDirectMembership, setDirectMembership] = useState(true);
const [directMembershipList, setDirectMembershipList] = useState< const [directMembershipList, setDirectMembershipList] = useState<
@ -55,7 +44,6 @@ export const UserGroups = () => {
const { enabled } = useHelp(); const { enabled } = useHelp();
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const { id } = useParams<{ id: string }>();
const alphabetize = (groupsList: GroupRepresentation[]) => { const alphabetize = (groupsList: GroupRepresentation[]) => {
return _.sortBy(groupsList, (group) => group.path?.toUpperCase()); return _.sortBy(groupsList, (group) => group.path?.toUpperCase());
}; };
@ -66,22 +54,15 @@ export const UserGroups = () => {
max: max!, max: max!,
}; };
const user = await adminClient.users.findOne({ id });
setUsername(user.username!);
const searchParam = search || ""; const searchParam = search || "";
if (searchParam) { if (searchParam) {
params.search = searchParam; params.search = searchParam;
setSearch(searchParam); setSearch(searchParam);
} }
if (!searchParam && !listGroups && !list) {
return [];
}
const joinedUserGroups = await adminClient.users.listGroups({ const joinedUserGroups = await adminClient.users.listGroups({
...params, ...params,
id, id: user.id!,
}); });
const allCreatedGroups = await adminClient.groups.find(); const allCreatedGroups = await adminClient.groups.find();
@ -177,14 +158,6 @@ export const UserGroups = () => {
return alphabetize(directMembership); return alphabetize(directMembership);
}; };
useFetch(
() => adminClient.users.listGroups({ id }),
(response) => {
setListGroups(!!(response && response.length > 0));
},
[]
);
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, [isDirectMembership]); }, [isDirectMembership]);
@ -201,14 +174,14 @@ export const UserGroups = () => {
}), }),
messageKey: t("leaveGroupConfirmDialog", { messageKey: t("leaveGroupConfirmDialog", {
groupname: selectedGroup?.name, groupname: selectedGroup?.name,
username: username, username: user.username,
}), }),
continueButtonLabel: "leave", continueButtonLabel: "leave",
continueButtonVariant: ButtonVariant.danger, continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => { onConfirm: async () => {
try { try {
await adminClient.users.delFromGroup({ await adminClient.users.delFromGroup({
id, id: user.id!,
groupId: selectedGroup!.id!, groupId: selectedGroup!.id!,
}); });
refresh(); refresh();
@ -248,10 +221,9 @@ export const UserGroups = () => {
newGroups.forEach(async (group) => { newGroups.forEach(async (group) => {
try { try {
await adminClient.users.addToGroup({ await adminClient.users.addToGroup({
id: id, id: user.id!,
groupId: group.id!, groupId: group.id!,
}); });
setList(true);
refresh(); refresh();
addAlert(t("addedGroupMembership"), AlertVariant.success); addAlert(t("addedGroupMembership"), AlertVariant.success);
} catch (error) { } catch (error) {
@ -267,10 +239,10 @@ export const UserGroups = () => {
<DeleteConfirm /> <DeleteConfirm />
{open && ( {open && (
<GroupPickerDialog <GroupPickerDialog
id={id} id={user.id}
type="selectMany" type="selectMany"
text={{ text={{
title: t("joinGroupsFor", { username }), title: t("joinGroupsFor", { username: user.username }),
ok: "users:join", ok: "users:join",
}} }}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import { import {
AlertVariant, AlertVariant,
PageSection, PageSection,
@ -6,20 +6,21 @@ import {
TabTitleText, TabTitleText,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { ViewHeader } from "../components/view-header/ViewHeader";
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { UserForm } from "./UserForm"; import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { BruteForced, UserForm } from "./UserForm";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { useAdminClient } from "../context/auth/AdminClient"; import { useAdminClient, useFetch } from "../context/auth/AdminClient";
import { useHistory, useParams } from "react-router-dom"; import { useHistory, useParams } from "react-router-dom";
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs"; import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
import { UserGroups } from "./UserGroups"; import { UserGroups } from "./UserGroups";
import { UserConsents } from "./UserConsents"; import { UserConsents } from "./UserConsents";
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks"; import { UserIdentityProviderLinks } from "./UserIdentityProviderLinks";
import { toUser } from "./routes/User";
export const UsersTabs = () => { export const UsersTabs = () => {
const { t } = useTranslation("roles"); const { t } = useTranslation("roles");
@ -30,18 +31,36 @@ export const UsersTabs = () => {
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const userForm = useForm<UserRepresentation>({ mode: "onChange" }); const userForm = useForm<UserRepresentation>({ mode: "onChange" });
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [user, setUser] = useState(""); const [user, setUser] = useState<UserRepresentation>();
const [bruteForced, setBruteForced] = useState<BruteForced>();
const [addedGroups, setAddedGroups] = useState<GroupRepresentation[]>([]); const [addedGroups, setAddedGroups] = useState<GroupRepresentation[]>([]);
useEffect(() => { useFetch(
const update = async () => { async () => {
if (id) { if (id) {
const fetchedUser = await adminClient.users.findOne({ id }); const user = await adminClient.users.findOne({ id });
setUser(fetchedUser.username!); const isBruteForceProtected = (
await adminClient.realms.findOne({ realm })
).bruteForceProtected;
const isLocked: boolean =
isBruteForceProtected &&
(await adminClient.attackDetection.findOne({ id: user.id! }))
?.disabled;
return { user, bruteForced: { isBruteForceProtected, isLocked } };
} }
return { user: undefined };
},
({ user, bruteForced }) => {
setUser(user);
setBruteForced(bruteForced);
user && setupForm(user);
},
[]
);
const setupForm = (user: UserRepresentation) => {
userForm.reset(user);
}; };
setTimeout(update, 100);
}, []);
const updateGroups = (groups: GroupRepresentation[]) => { const updateGroups = (groups: GroupRepresentation[]) => {
setAddedGroups(groups); setAddedGroups(groups);
@ -63,7 +82,7 @@ export const UsersTabs = () => {
}); });
addAlert(t("users:userCreated"), AlertVariant.success); addAlert(t("users:userCreated"), AlertVariant.success);
history.push(`/${realm}/users/${createdUser.id}/settings`); history.push(toUser({ id: createdUser.id, realm, tab: "settings" }));
} }
} catch (error) { } catch (error) {
addError("users:userCreateError", error); addError("users:userCreateError", error);
@ -72,9 +91,13 @@ export const UsersTabs = () => {
return ( return (
<> <>
<ViewHeader titleKey={user! || t("users:createUser")} divider={!id} /> <ViewHeader
titleKey={user?.username || t("users:createUser")}
divider={!id}
/>
<PageSection variant="light" className="pf-u-p-0"> <PageSection variant="light" className="pf-u-p-0">
{id && ( <FormProvider {...userForm}>
{id && user && (
<KeycloakTabs isBox> <KeycloakTabs isBox>
<Tab <Tab
eventKey="settings" eventKey="settings"
@ -82,12 +105,14 @@ export const UsersTabs = () => {
title={<TabTitleText>{t("details")}</TabTitleText>} title={<TabTitleText>{t("details")}</TabTitleText>}
> >
<PageSection variant="light"> <PageSection variant="light">
{bruteForced && (
<UserForm <UserForm
onGroupsUpdate={updateGroups} onGroupsUpdate={updateGroups}
form={userForm}
save={save} save={save}
editMode={true} user={user}
bruteForce={bruteForced}
/> />
)}
</PageSection> </PageSection>
</Tab> </Tab>
<Tab <Tab
@ -95,7 +120,7 @@ export const UsersTabs = () => {
data-testid="user-groups-tab" data-testid="user-groups-tab"
title={<TabTitleText>{t("groups")}</TabTitleText>} title={<TabTitleText>{t("groups")}</TabTitleText>}
> >
<UserGroups /> <UserGroups user={user} />
</Tab> </Tab>
<Tab <Tab
eventKey="consents" eventKey="consents"
@ -108,7 +133,9 @@ export const UsersTabs = () => {
eventKey="identity-provider-links" eventKey="identity-provider-links"
data-testid="identity-provider-links-tab" data-testid="identity-provider-links-tab"
title={ title={
<TabTitleText>{t("users:identityProviderLinks")}</TabTitleText> <TabTitleText>
{t("users:identityProviderLinks")}
</TabTitleText>
} }
> >
<UserIdentityProviderLinks /> <UserIdentityProviderLinks />
@ -117,14 +144,10 @@ export const UsersTabs = () => {
)} )}
{!id && ( {!id && (
<PageSection variant="light"> <PageSection variant="light">
<UserForm <UserForm onGroupsUpdate={updateGroups} save={save} />
onGroupsUpdate={updateGroups}
form={userForm}
save={save}
editMode={false}
/>
</PageSection> </PageSection>
)} )}
</FormProvider>
</PageSection> </PageSection>
</> </>
); );

View file

@ -1,5 +1,7 @@
export default { export default {
"users-help": { "users-help": {
temporaryLocked:
"The user may be locked due to multiple failed attempts to log in.",
disabled: "A disabled user cannot log in.", disabled: "A disabled user cannot log in.",
emailVerified: "Has the user's email been verified?", emailVerified: "Has the user's email been verified?",
requiredUserActions: requiredUserActions:

View file

@ -38,6 +38,9 @@ export default {
firstName: "First name", firstName: "First name",
status: "Status", status: "Status",
disabled: "Disabled", disabled: "Disabled",
temporaryLocked: "Temporarily locked",
unlockSuccess: "User successfully unlocked",
unlockError: "Could not unlock user due to {{error}}",
emailInvalid: "You must enter a valid email.", emailInvalid: "You must enter a valid email.",
temporaryDisabled: "Temporarily disabled", temporaryDisabled: "Temporarily disabled",
notVerified: "Not verified", notVerified: "Not verified",