Added brute force status to user search (#3236)
This commit is contained in:
parent
05a660b681
commit
a669a7f334
6 changed files with 357 additions and 35 deletions
|
@ -1,19 +1,23 @@
|
|||
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
|
||||
import type KeycloakAdminClient from "@keycloak/keycloak-admin-client";
|
||||
import { fetchAdminUI } from "../../context/auth/admin-ui-endpoint";
|
||||
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||
|
||||
type BaseQuery = {
|
||||
adminClient: KeycloakAdminClient;
|
||||
};
|
||||
|
||||
type IDQuery = BaseQuery & {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type PaginatingQuery = BaseQuery & {
|
||||
type PaginatingQuery = IDQuery & {
|
||||
first: number;
|
||||
max: number;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
type EffectiveClientRolesQuery = BaseQuery;
|
||||
type EffectiveClientRolesQuery = IDQuery;
|
||||
|
||||
type Query = Partial<Omit<PaginatingQuery, "adminClient">> & {
|
||||
adminClient: KeycloakAdminClient;
|
||||
|
@ -36,13 +40,12 @@ const fetchEndpoint = async ({
|
|||
max,
|
||||
search,
|
||||
endpoint,
|
||||
}: Query): Promise<any> => {
|
||||
return fetchAdminUI(adminClient, `/admin-ui-${endpoint}/${type}/${id}`, {
|
||||
}: Query): Promise<any> =>
|
||||
fetchAdminUI(adminClient, `/admin-ui-${endpoint}/${type}/${id}`, {
|
||||
first: (first || 0).toString(),
|
||||
max: (max || 10).toString(),
|
||||
search: search || "",
|
||||
});
|
||||
};
|
||||
|
||||
export const getAvailableClientRoles = (
|
||||
query: PaginatingQuery
|
||||
|
@ -54,5 +57,33 @@ export const getEffectiveClientRoles = (
|
|||
): Promise<ClientRole[]> =>
|
||||
fetchEndpoint({ ...query, endpoint: "effective-roles" });
|
||||
|
||||
type UserQuery = BaseQuery & {
|
||||
lastName?: string;
|
||||
firstName?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
emailVerified?: boolean;
|
||||
idpAlias?: string;
|
||||
idpUserId?: string;
|
||||
enabled?: boolean;
|
||||
briefRepresentation?: boolean;
|
||||
exact?: boolean;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export type BruteUser = UserRepresentation & {
|
||||
bruteForceStatus?: Record<string, object>;
|
||||
};
|
||||
|
||||
export const findUsers = ({
|
||||
adminClient,
|
||||
...query
|
||||
}: UserQuery): Promise<BruteUser[]> =>
|
||||
fetchAdminUI(
|
||||
adminClient,
|
||||
"admin-ui-brute-force-user",
|
||||
query as Record<string, string>
|
||||
);
|
||||
|
||||
export const fetchUsedBy = (query: PaginatingQuery): Promise<string[]> =>
|
||||
fetchEndpoint({ ...query, endpoint: "authentication-management" });
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom-v5-compat";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertVariant,
|
||||
Button,
|
||||
|
@ -26,13 +30,10 @@ import {
|
|||
WarningTriangleIcon,
|
||||
} from "@patternfly/react-icons";
|
||||
import type { IRowData } from "@patternfly/react-table";
|
||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||
|
||||
import type ComponentRepresentation from "@keycloak/keycloak-admin-client/lib/defs/componentRepresentation";
|
||||
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom-v5-compat";
|
||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||
import { useAlerts } from "../components/alert/Alerts";
|
||||
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
|
||||
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
|
||||
|
@ -52,13 +53,10 @@ import {
|
|||
RoutableTabs,
|
||||
} from "../components/routable-tabs/RoutableTabs";
|
||||
import { useAccess } from "../context/access/Access";
|
||||
import { BruteUser, findUsers } from "../components/role-mapping/resource";
|
||||
|
||||
import "./user-section.css";
|
||||
|
||||
type BruteUser = UserRepresentation & {
|
||||
brute?: Record<string, object>;
|
||||
};
|
||||
|
||||
export default function UsersSection() {
|
||||
const { t } = useTranslation("users");
|
||||
const { adminClient } = useAdminClient();
|
||||
|
@ -128,24 +126,11 @@ export default function UsersSection() {
|
|||
}
|
||||
|
||||
try {
|
||||
const users = await adminClient.users.find({
|
||||
return await findUsers({
|
||||
adminClient,
|
||||
briefRepresentation: true,
|
||||
...params,
|
||||
});
|
||||
if (realm?.bruteForceProtected) {
|
||||
const brutes = await Promise.all(
|
||||
users.map((user: BruteUser) =>
|
||||
adminClient.attackDetection.findOne({
|
||||
id: user.id!,
|
||||
})
|
||||
)
|
||||
);
|
||||
for (let index = 0; index < users.length; index++) {
|
||||
const user: BruteUser = users[index];
|
||||
user.brute = brutes[index];
|
||||
}
|
||||
}
|
||||
return users;
|
||||
} catch (error) {
|
||||
if (userStorage?.length) {
|
||||
addError("users:noUsersFoundErrorStorage", error);
|
||||
|
@ -198,12 +183,12 @@ export default function UsersSection() {
|
|||
{t("disabled")}
|
||||
</Label>
|
||||
)}
|
||||
{user.brute?.disabled && (
|
||||
{user.bruteForceStatus?.disabled && (
|
||||
<Label key={user.id} color="orange" icon={<WarningTriangleIcon />}>
|
||||
{t("temporaryDisabled")}
|
||||
</Label>
|
||||
)}
|
||||
{user.enabled && !user.brute?.disabled && "—"}
|
||||
{user.enabled && !user.bruteForceStatus?.disabled && "—"}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -226,7 +211,7 @@ export default function UsersSection() {
|
|||
|
||||
const goToCreate = () => navigate(toAddUser({ realm: realmName }));
|
||||
|
||||
if (!userStorage) {
|
||||
if (!userStorage || !realm) {
|
||||
return <KeycloakSpinner />;
|
||||
}
|
||||
|
||||
|
@ -240,7 +225,7 @@ export default function UsersSection() {
|
|||
{t("addUser")}
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
{!realm?.bruteForceProtected ? (
|
||||
{!realm.bruteForceProtected ? (
|
||||
<ToolbarItem>
|
||||
<Button
|
||||
variant={ButtonVariant.link}
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
package org.keycloak.admin.ui.rest;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.services.resources.admin.AdminEventBuilder;
|
||||
import org.keycloak.services.resources.admin.ext.AdminRealmResourceProvider;
|
||||
import org.keycloak.services.resources.admin.ext.AdminRealmResourceProviderFactory;
|
||||
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
|
||||
|
||||
public final class BruteForceUsersProvider implements AdminRealmResourceProviderFactory, AdminRealmResourceProvider {
|
||||
public AdminRealmResourceProvider create(KeycloakSession session) {
|
||||
return this;
|
||||
}
|
||||
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
public void close() {
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return "admin-ui-brute-force-user";
|
||||
}
|
||||
|
||||
public Object getResource(KeycloakSession session, RealmModel realm, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) {
|
||||
return new BruteForceUsersResource(realm, auth);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,222 @@
|
|||
package org.keycloak.admin.ui.rest;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.core.Context;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.admin.ui.rest.model.BruteUser;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserLoginFailureModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.utils.ModelToRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.services.managers.BruteForceProtector;
|
||||
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
|
||||
import org.keycloak.services.resources.admin.permissions.UserPermissionEvaluator;
|
||||
import org.keycloak.utils.SearchQueryUtils;
|
||||
|
||||
@Path("/")
|
||||
public class BruteForceUsersResource {
|
||||
private static final Logger logger = Logger.getLogger(BruteForceUsersResource.class);
|
||||
private static final String SEARCH_ID_PARAMETER = "id:";
|
||||
@Context
|
||||
private KeycloakSession session;
|
||||
private final RealmModel realm;
|
||||
private final AdminPermissionEvaluator auth;
|
||||
|
||||
public BruteForceUsersResource(RealmModel realm, AdminPermissionEvaluator auth) {
|
||||
this.realm = realm;
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes({"application/json"})
|
||||
@Produces({"application/json"})
|
||||
@Operation(
|
||||
summary = "Find all users and add if they are locked by brute force protection",
|
||||
description = "Same endpoint as the users search but added brute force protection status."
|
||||
)
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
description = "",
|
||||
content = {@Content(
|
||||
schema = @Schema(
|
||||
implementation = BruteUser.class,
|
||||
type = SchemaType.ARRAY
|
||||
)
|
||||
)}
|
||||
)
|
||||
public final Stream<BruteUser> searchUser(@QueryParam("search") String search,
|
||||
@QueryParam("lastName") String last,
|
||||
@QueryParam("firstName") String first,
|
||||
@QueryParam("email") String email,
|
||||
@QueryParam("username") String username,
|
||||
@QueryParam("emailVerified") Boolean emailVerified,
|
||||
@QueryParam("idpAlias") String idpAlias,
|
||||
@QueryParam("idpUserId") String idpUserId,
|
||||
@QueryParam("first") @DefaultValue("-1") Integer firstResult,
|
||||
@QueryParam("max") @DefaultValue("" + Constants.DEFAULT_MAX_RESULTS) Integer maxResults,
|
||||
@QueryParam("enabled") Boolean enabled,
|
||||
@QueryParam("briefRepresentation") Boolean briefRepresentation,
|
||||
@QueryParam("exact") Boolean exact,
|
||||
@QueryParam("q") String searchQuery) {
|
||||
final UserPermissionEvaluator userPermissionEvaluator = auth.users();
|
||||
userPermissionEvaluator.requireQuery();
|
||||
|
||||
Map<String, String> searchAttributes = searchQuery == null
|
||||
? Collections.emptyMap()
|
||||
: SearchQueryUtils.getFields(searchQuery);
|
||||
|
||||
Stream<UserModel> userModels = Stream.empty();
|
||||
if (search != null) {
|
||||
if (search.startsWith(SEARCH_ID_PARAMETER)) {
|
||||
UserModel userModel =
|
||||
session.users().getUserById(realm, search.substring(SEARCH_ID_PARAMETER.length()).trim());
|
||||
if (userModel != null) {
|
||||
userModels = Stream.of(userModel);
|
||||
}
|
||||
} else {
|
||||
Map<String, String> attributes = new HashMap<>();
|
||||
attributes.put(UserModel.SEARCH, search.trim());
|
||||
if (enabled != null) {
|
||||
attributes.put(UserModel.ENABLED, enabled.toString());
|
||||
}
|
||||
return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult,
|
||||
maxResults, false);
|
||||
}
|
||||
} else if (last != null || first != null || email != null || username != null || emailVerified != null
|
||||
|| idpAlias != null || idpUserId != null || enabled != null || exact != null || !searchAttributes.isEmpty()) {
|
||||
Map<String, String> attributes = new HashMap<>();
|
||||
if (last != null) {
|
||||
attributes.put(UserModel.LAST_NAME, last);
|
||||
}
|
||||
if (first != null) {
|
||||
attributes.put(UserModel.FIRST_NAME, first);
|
||||
}
|
||||
if (email != null) {
|
||||
attributes.put(UserModel.EMAIL, email);
|
||||
}
|
||||
if (username != null) {
|
||||
attributes.put(UserModel.USERNAME, username);
|
||||
}
|
||||
if (emailVerified != null) {
|
||||
attributes.put(UserModel.EMAIL_VERIFIED, emailVerified.toString());
|
||||
}
|
||||
if (idpAlias != null) {
|
||||
attributes.put(UserModel.IDP_ALIAS, idpAlias);
|
||||
}
|
||||
if (idpUserId != null) {
|
||||
attributes.put(UserModel.IDP_USER_ID, idpUserId);
|
||||
}
|
||||
if (enabled != null) {
|
||||
attributes.put(UserModel.ENABLED, enabled.toString());
|
||||
}
|
||||
if (exact != null) {
|
||||
attributes.put(UserModel.EXACT, exact.toString());
|
||||
}
|
||||
|
||||
attributes.putAll(searchAttributes);
|
||||
|
||||
return searchForUser(attributes, realm, userPermissionEvaluator, briefRepresentation, firstResult,
|
||||
maxResults, true);
|
||||
} else {
|
||||
return searchForUser(new HashMap<>(), realm, userPermissionEvaluator, briefRepresentation,
|
||||
firstResult, maxResults, false);
|
||||
}
|
||||
|
||||
return toRepresentation(realm, userPermissionEvaluator, briefRepresentation, userModels);
|
||||
|
||||
}
|
||||
|
||||
private Stream<BruteUser> searchForUser(Map<String, String> attributes, RealmModel realm, UserPermissionEvaluator usersEvaluator, Boolean briefRepresentation, Integer firstResult, Integer maxResults, Boolean includeServiceAccounts) {
|
||||
session.setAttribute(UserModel.INCLUDE_SERVICE_ACCOUNT, includeServiceAccounts);
|
||||
|
||||
if (!auth.users().canView()) {
|
||||
Set<String> groupModels = auth.groups().getGroupsWithViewPermission();
|
||||
|
||||
if (!groupModels.isEmpty()) {
|
||||
session.setAttribute(UserModel.GROUPS, groupModels);
|
||||
}
|
||||
}
|
||||
|
||||
Stream<UserModel> userModels = session.users().searchForUserStream(realm, attributes, firstResult, maxResults);
|
||||
return toRepresentation(realm, usersEvaluator, briefRepresentation, userModels);
|
||||
}
|
||||
|
||||
private Stream<BruteUser> toRepresentation(RealmModel realm, UserPermissionEvaluator usersEvaluator,
|
||||
Boolean briefRepresentation, Stream<UserModel> userModels) {
|
||||
boolean briefRepresentationB = briefRepresentation != null && briefRepresentation;
|
||||
boolean canViewGlobal = usersEvaluator.canView();
|
||||
|
||||
usersEvaluator.grantIfNoPermission(session.getAttribute(UserModel.GROUPS) != null);
|
||||
return userModels.filter(user -> canViewGlobal || usersEvaluator.canView(user)).map(user -> {
|
||||
UserRepresentation userRep = briefRepresentationB ?
|
||||
ModelToRepresentation.toBriefRepresentation(user) :
|
||||
ModelToRepresentation.toRepresentation(session, realm, user);
|
||||
userRep.setAccess(usersEvaluator.getAccess(user));
|
||||
return userRep;
|
||||
}).map(this::getBruteForceStatus);
|
||||
}
|
||||
|
||||
private BruteUser getBruteForceStatus(UserRepresentation user) {
|
||||
BruteUser bruteUser = new BruteUser(user);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("disabled", false);
|
||||
data.put("numFailures", 0);
|
||||
data.put("lastFailure", 0);
|
||||
data.put("lastIPFailure", "n/a");
|
||||
if (!realm.isBruteForceProtected())
|
||||
bruteUser.setBruteForceStatus(data);
|
||||
|
||||
UserLoginFailureModel model = session.loginFailures().getUserLoginFailure(realm, user.getId());
|
||||
if (model == null) {
|
||||
bruteUser.setBruteForceStatus(data);
|
||||
return bruteUser;
|
||||
}
|
||||
|
||||
boolean disabled;
|
||||
disabled = isTemporarilyDisabled(session, realm, user);
|
||||
if (disabled) {
|
||||
data.put("disabled", true);
|
||||
}
|
||||
|
||||
data.put("numFailures", model.getNumFailures());
|
||||
data.put("lastFailure", model.getLastFailure());
|
||||
data.put("lastIPFailure", model.getLastIPFailure());
|
||||
bruteUser.setBruteForceStatus(data);
|
||||
|
||||
return bruteUser;
|
||||
}
|
||||
|
||||
public boolean isTemporarilyDisabled(KeycloakSession session, RealmModel realm, UserRepresentation user) {
|
||||
UserLoginFailureModel failure = session.loginFailures().getUserLoginFailure(realm, user.getId());
|
||||
if (failure != null) {
|
||||
int currTime = (int)(Time.currentTimeMillis() / 1000L);
|
||||
int failedLoginNotBefore = failure.getFailedLoginNotBefore();
|
||||
if (currTime < failedLoginNotBefore) {
|
||||
logger.debugv("Current: {0} notBefore: {1}", currTime, failedLoginNotBefore);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package org.keycloak.admin.ui.rest.model;
|
||||
|
||||
import java.util.Map;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
|
||||
public class BruteUser extends UserRepresentation {
|
||||
|
||||
Map<String, Object> bruteForceStatus;
|
||||
|
||||
public BruteUser(UserRepresentation user) {
|
||||
this.id = user.getId();
|
||||
this.origin = user.getOrigin();
|
||||
this.createdTimestamp = user.getCreatedTimestamp();
|
||||
this.username = user.getUsername();
|
||||
this.enabled = user.isEnabled();
|
||||
this.totp = user.isTotp();
|
||||
this.emailVerified = user.isEmailVerified();
|
||||
this.firstName = user.getFirstName();
|
||||
this.lastName = user.getLastName();
|
||||
this.email = user.getEmail();
|
||||
this.federationLink = user.getFederationLink();
|
||||
this.serviceAccountClientId = user.getServiceAccountClientId();
|
||||
|
||||
this.attributes = user.getAttributes();
|
||||
this.credentials = user.getCredentials();
|
||||
this.disableableCredentialTypes = user.getDisableableCredentialTypes();
|
||||
this.requiredActions = user.getRequiredActions();
|
||||
this.federatedIdentities = user.getFederatedIdentities();
|
||||
this.realmRoles = user.getRealmRoles();
|
||||
this.clientRoles = user.getClientRoles();
|
||||
this.clientConsents = user.getClientConsents();
|
||||
this.notBefore = user.getNotBefore();
|
||||
|
||||
this.applicationRoles = user.getApplicationRoles();
|
||||
this.socialLinks = user.getSocialLinks();
|
||||
|
||||
this.groups = user.getGroups();
|
||||
this.setAccess(user.getAccess());
|
||||
}
|
||||
|
||||
public Map<String, Object> getBruteForceStatus() {
|
||||
return bruteForceStatus;
|
||||
}
|
||||
|
||||
public void setBruteForceStatus(Map<String, Object> bruteForceStatus) {
|
||||
this.bruteForceStatus = bruteForceStatus;
|
||||
}
|
||||
}
|
|
@ -19,3 +19,4 @@ org.keycloak.admin.ui.rest.AvailableRoleMappingProvider
|
|||
org.keycloak.admin.ui.rest.EffectiveRoleMappingProvider
|
||||
org.keycloak.admin.ui.rest.GroupsResourceProvider
|
||||
org.keycloak.admin.ui.rest.AuthenticationManagementProvider
|
||||
org.keycloak.admin.ui.rest.BruteForceUsersProvider
|
Loading…
Reference in a new issue