Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent Brute Forcing: Create an api endpoint to list locked users OSS changes #18675

Merged
merged 3 commits into from
Jan 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/18675.txt
Original file line number Diff line number Diff line change
@@ -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].
```
6 changes: 3 additions & 3 deletions vault/external_tests/identity/userlockouts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}
}
58 changes: 30 additions & 28 deletions vault/logical_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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.`,
},
Expand All @@ -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
Expand Down
19 changes: 18 additions & 1 deletion vault/logical_system_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -2069,7 +2069,7 @@ func (b *SystemBackend) experimentPaths() []*framework.Path {
func (b *SystemBackend) lockedUserPaths() []*framework.Path {
return []*framework.Path{
{
Pattern: "lockedusers/(?P<mount_accessor>.+?)/unlock/(?P<alias_identifier>.+)",
Pattern: "locked-users/(?P<mount_accessor>.+?)/unlock/(?P<alias_identifier>.+)",
Fields: map[string]*framework.FieldSchema{
"mount_accessor": {
Type: framework.TypeString,
Expand All @@ -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]),
},
}
}
195 changes: 195 additions & 0 deletions vault/logical_system_user_lockout.go
Original file line number Diff line number Diff line change
@@ -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)
}