From b2276a369afdc2b9979075eeda30b44c48ac13d1 Mon Sep 17 00:00:00 2001 From: akshya96 <87045294+akshya96@users.noreply.github.com> Date: Tue, 17 Jan 2023 14:25:56 -0800 Subject: [PATCH] Prevent Brute Forcing: Create an api endpoint to list locked users OSS changes (#18675) * api to list lockedusers oss changes * add changelog --- changelog/18675.txt | 4 + .../identity/userlockouts_test.go | 6 +- vault/logical_system.go | 58 +++--- vault/logical_system_paths.go | 19 +- vault/logical_system_user_lockout.go | 195 ++++++++++++++++++ 5 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 changelog/18675.txt create mode 100644 vault/logical_system_user_lockout.go diff --git a/changelog/18675.txt b/changelog/18675.txt new file mode 100644 index 000000000..90a8ed64d --- /dev/null +++ b/changelog/18675.txt @@ -0,0 +1,4 @@ +```release-note:improvement +core: Added sys/locked-users endpoint to list locked users. Changed api endpoint from +sys/lockedusers/[mount_accessor]/unlock/[alias_identifier] to sys/locked-users/[mount_accessor]/unlock/[alias_identifier]. +``` \ No newline at end of file diff --git a/vault/external_tests/identity/userlockouts_test.go b/vault/external_tests/identity/userlockouts_test.go index 94ec6bb55..0d376466e 100644 --- a/vault/external_tests/identity/userlockouts_test.go +++ b/vault/external_tests/identity/userlockouts_test.go @@ -327,7 +327,7 @@ func TestIdentityStore_LockoutCounterResetTest(t *testing.T) { // TestIdentityStore_UnlockUserTest tests the user is // unlocked if locked using -// sys/lockedusers/[mount_accessor]/unlock/[alias-identifier] +// sys/locked-users/[mount_accessor]/unlock/[alias-identifier] func TestIdentityStore_UnlockUserTest(t *testing.T) { coreConfig := &vault.CoreConfig{ CredentialBackends: map[string]logical.Factory{ @@ -393,7 +393,7 @@ func TestIdentityStore_UnlockUserTest(t *testing.T) { } // unlock user - if _, err = standby.Logical().Write("sys/lockedusers/"+mountAccessor+"/unlock/bsmith", nil); err != nil { + if _, err = standby.Logical().Write("sys/locked-users/"+mountAccessor+"/unlock/bsmith", nil); err != nil { t.Fatal(err) } @@ -405,7 +405,7 @@ func TestIdentityStore_UnlockUserTest(t *testing.T) { } // unlock unlocked user - if _, err = active.Logical().Write("sys/lockedusers/mountAccessor/unlock/bsmith", nil); err != nil { + if _, err = active.Logical().Write("sys/locked-users/mountAccessor/unlock/bsmith", nil); err != nil { t.Fatal(err) } } diff --git a/vault/logical_system.go b/vault/logical_system.go index 886a06663..98767cae7 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -2208,6 +2208,27 @@ func (b *SystemBackend) handleTuneWriteCommon(ctx context.Context, path string, return resp, nil } +// handleLockedUsersMetricQuery reports the locked user count metrics for this namespace and all child namespaces +// if mount_accessor in request, returns the locked user metrics for that mount accessor for namespace in ctx +func (b *SystemBackend) handleLockedUsersMetricQuery(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + var mountAccessor string + if mountAccessorRaw, ok := d.GetOk("mount_accessor"); ok { + mountAccessor = mountAccessorRaw.(string) + } + + results, err := b.handleLockedUsersQuery(ctx, mountAccessor) + if err != nil { + return nil, err + } + if results == nil { + return logical.RespondWithStatusCode(nil, req, http.StatusNoContent) + } + + return &logical.Response{ + Data: results, + }, nil +} + // handleUnlockUser is used to unlock user with given mount_accessor and alias_identifier if locked func (b *SystemBackend) handleUnlockUser(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { mountAccessor := data.Get("mount_accessor").(string) @@ -2232,33 +2253,6 @@ func (b *SystemBackend) handleUnlockUser(ctx context.Context, req *logical.Reque return nil, nil } -// unlockUser deletes the entry for locked user from storage and userFailedLoginInfo map -func unlockUser(ctx context.Context, core *Core, mountAccessor string, aliasName string) error { - ns, err := namespace.FromContext(ctx) - if err != nil { - return err - } - - lockedUserStoragePath := coreLockedUsersPath + ns.ID + "/" + mountAccessor + "/" + aliasName - - // remove entry for locked user from storage - if err := core.barrier.Delete(ctx, lockedUserStoragePath); err != nil { - return err - } - - loginUserInfoKey := FailedLoginUser{ - aliasName: aliasName, - mountAccessor: mountAccessor, - } - - // remove entry for locked user from userFailedLoginInfo map - if err := updateUserFailedLoginInfo(ctx, core, loginUserInfoKey, nil, true); err != nil { - return err - } - - return nil -} - // handleLease is use to view the metadata for a given LeaseID func (b *SystemBackend) handleLeaseLookup(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { leaseID := data.Get("lease_id").(string) @@ -5395,7 +5389,7 @@ the mount.`, "Unlock the locked user with given mount_accessor and alias_identifier.", ` This path responds to the following HTTP methods. - POST sys/lockedusers/:mount_accessor/unlock/:alias_identifier + POST sys/locked-users/:mount_accessor/unlock/:alias_identifier Unlocks the user with given mount_accessor and alias_identifier if locked.`, }, @@ -5405,6 +5399,14 @@ This path responds to the following HTTP methods. "", }, + "locked_users": { + "Report the locked user count metrics", + ` +This path responds to the following HTTP methods. + GET sys/locked-users + Report the locked user count metrics, for current namespace and all child namespaces.`, + }, + "alias_identifier": { `It is the name of the alias (user). For example, if the alias belongs to userpass backend, the name should be a valid username within userpass auth method. If the alias belongs diff --git a/vault/logical_system_paths.go b/vault/logical_system_paths.go index f83dd9acc..36ddbed9e 100644 --- a/vault/logical_system_paths.go +++ b/vault/logical_system_paths.go @@ -2069,7 +2069,7 @@ func (b *SystemBackend) experimentPaths() []*framework.Path { func (b *SystemBackend) lockedUserPaths() []*framework.Path { return []*framework.Path{ { - Pattern: "lockedusers/(?P.+?)/unlock/(?P.+)", + Pattern: "locked-users/(?P.+?)/unlock/(?P.+)", Fields: map[string]*framework.FieldSchema{ "mount_accessor": { Type: framework.TypeString, @@ -2089,5 +2089,22 @@ func (b *SystemBackend) lockedUserPaths() []*framework.Path { HelpSynopsis: strings.TrimSpace(sysHelp["unlock_user"][0]), HelpDescription: strings.TrimSpace(sysHelp["unlock_user"][1]), }, + { + Pattern: "locked-users", + Fields: map[string]*framework.FieldSchema{ + "mount_accessor": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["mount_accessor"][0]), + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.handleLockedUsersMetricQuery, + Summary: "Report the locked user count metrics, for this namespace and all child namespaces.", + }, + }, + HelpSynopsis: strings.TrimSpace(sysHelp["locked_users"][0]), + HelpDescription: strings.TrimSpace(sysHelp["locked_users"][1]), + }, } } diff --git a/vault/logical_system_user_lockout.go b/vault/logical_system_user_lockout.go new file mode 100644 index 000000000..b8663f5be --- /dev/null +++ b/vault/logical_system_user_lockout.go @@ -0,0 +1,195 @@ +package vault + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/hashicorp/vault/helper/namespace" +) + +type LockedUsersResponse struct { + NamespaceID string `json:"namespace_id" mapstructure:"namespace_id"` + NamespacePath string `json:"namespace_path" mapstructure:"namespace_path"` + Counts int `json:"counts" mapstructure:"counts"` + MountAccessors []*ResponseMountAccessors `json:"mount_accessors" mapstructure:"mount_accessors"` +} + +type ResponseMountAccessors struct { + MountAccessor string `json:"mount_accessor" mapstructure:"mount_accessor"` + Counts int `json:"counts" mapstructure:"counts"` + AliasIdentifiers []string `json:"alias_identifiers" mapstructure:"alias_identifiers"` +} + +// unlockUser deletes the entry for locked user from storage and userFailedLoginInfo map +func unlockUser(ctx context.Context, core *Core, mountAccessor string, aliasName string) error { + ns, err := namespace.FromContext(ctx) + if err != nil { + return err + } + + lockedUserStoragePath := coreLockedUsersPath + ns.ID + "/" + mountAccessor + "/" + aliasName + + // remove entry for locked user from storage + if err := core.barrier.Delete(ctx, lockedUserStoragePath); err != nil { + return err + } + + loginUserInfoKey := FailedLoginUser{ + aliasName: aliasName, + mountAccessor: mountAccessor, + } + + // remove entry for locked user from userFailedLoginInfo map + if err := updateUserFailedLoginInfo(ctx, core, loginUserInfoKey, nil, true); err != nil { + return err + } + + return nil +} + +// handleLockedUsersQuery reports the locked user metrics by namespace in the decreasing order +// of locked users +func (b *SystemBackend) handleLockedUsersQuery(ctx context.Context, mountAccessor string) (map[string]interface{}, error) { + // Calculate the namespace response breakdowns of locked users for query namespace and child namespaces (if needed) + totalCount, byNamespaceResponse, err := b.getLockedUsersResponses(ctx, mountAccessor) + if err != nil { + return nil, err + } + + // Now populate the response based on breakdowns. + responseData := make(map[string]interface{}) + responseData["by_namespace"] = byNamespaceResponse + responseData["total"] = totalCount + return responseData, nil +} + +// getLockedUsersResponses returns the locked users +// for a particular mount_accessor if provided in request +// else returns it for the current namespace and all the child namespaces that has locked users +// they are sorted in the decreasing order of locked users count +func (b *SystemBackend) getLockedUsersResponses(ctx context.Context, mountAccessor string) (int, []*LockedUsersResponse, error) { + lockedUsersResponse := make([]*LockedUsersResponse, 0) + totalCounts := 0 + + queryNS, err := namespace.FromContext(ctx) + if err != nil { + return 0, nil, err + } + + if mountAccessor != "" { + // get the locked user response for mount_accessor, here for mount_accessor in request + totalCountForNSID, mountAccessorsResponse, err := b.getMountAccessorsLockedUsers(ctx, []string{mountAccessor + "/"}, + coreLockedUsersPath+queryNS.ID+"/") + if err != nil { + return 0, nil, err + } + + totalCounts += totalCountForNSID + lockedUsersResponse = append(lockedUsersResponse, &LockedUsersResponse{ + NamespaceID: queryNS.ID, + NamespacePath: queryNS.Path, + Counts: totalCountForNSID, + MountAccessors: mountAccessorsResponse, + }) + return totalCounts, lockedUsersResponse, nil + } + + // no mount_accessor is provided in request, get information for current namespace and its child namespaces + + // get all the namespaces of locked users + nsIDs, err := b.Core.barrier.List(ctx, coreLockedUsersPath) + if err != nil { + return 0, nil, err + } + + // identify if the namespaces must be included in response and get counts + for _, nsID := range nsIDs { + nsID = strings.TrimSuffix(nsID, "/") + ns, err := NamespaceByID(ctx, nsID, b.Core) + if err != nil { + return 0, nil, err + } + + if b.includeNSInLockedUsersResponse(queryNS, ns) { + var displayPath string + if ns == nil { + // deleted namespace + displayPath = fmt.Sprintf("deleted namespace %q", nsID) + } else { + displayPath = ns.Path + } + + // get mount accessors of locked users for this namespace + mountAccessors, err := b.Core.barrier.List(ctx, coreLockedUsersPath+nsID+"/") + if err != nil { + return 0, nil, err + } + + // get the locked user response for mount_accessor list + totalCountForNSID, mountAccessorsResponse, err := b.getMountAccessorsLockedUsers(ctx, mountAccessors, coreLockedUsersPath+nsID+"/") + if err != nil { + return 0, nil, err + } + + totalCounts += totalCountForNSID + lockedUsersResponse = append(lockedUsersResponse, &LockedUsersResponse{ + NamespaceID: strings.TrimSuffix(nsID, "/"), + NamespacePath: displayPath, + Counts: totalCountForNSID, + MountAccessors: mountAccessorsResponse, + }) + + } + } + + // sort namespaces in response by decreasing order of counts + sort.Slice(lockedUsersResponse, func(i, j int) bool { + return lockedUsersResponse[i].Counts > lockedUsersResponse[j].Counts + }) + + return totalCounts, lockedUsersResponse, nil +} + +// getMountAccessorsLockedUsers returns the locked users for all the mount_accessors of locked users for a namespace +// they are sorted in the decreasing order of locked users +// returns the total locked users for the namespace and locked users response for every mount_accessor for a namespace that has locked users +func (b *SystemBackend) getMountAccessorsLockedUsers(ctx context.Context, mountAccessors []string, lockedUsersPath string) (int, []*ResponseMountAccessors, error) { + byMountAccessorsResponse := make([]*ResponseMountAccessors, 0) + totalCountForMountAccessors := 0 + + for _, mountAccessor := range mountAccessors { + // get the list of aliases of locked users for a mount accessor + aliasIdentifiers, err := b.Core.barrier.List(ctx, lockedUsersPath+mountAccessor) + if err != nil { + return 0, nil, err + } + + totalCountForMountAccessors += len(aliasIdentifiers) + byMountAccessorsResponse = append(byMountAccessorsResponse, &ResponseMountAccessors{ + MountAccessor: strings.TrimSuffix(mountAccessor, "/"), + Counts: len(aliasIdentifiers), + AliasIdentifiers: aliasIdentifiers, + }) + + } + + // sort mount Accessors in response by decreasing order of counts + sort.Slice(byMountAccessorsResponse, func(i, j int) bool { + return byMountAccessorsResponse[i].Counts > byMountAccessorsResponse[j].Counts + }) + + return totalCountForMountAccessors, byMountAccessorsResponse, nil +} + +// includeNSInLockedUsersResponse checks if the namespace is the child namespace of namespace in query +// if child namespace, it can be included in response +// locked users from deleted namespaces are listed under root namespace +func (b *SystemBackend) includeNSInLockedUsersResponse(query *namespace.Namespace, record *namespace.Namespace) bool { + if record == nil { + // Deleted namespace, only include in root queries + return query.ID == namespace.RootNamespaceID + } + return record.HasParent(query) +}