diff --git a/changelog/18279.txt b/changelog/18279.txt new file mode 100644 index 000000000..9823a1f19 --- /dev/null +++ b/changelog/18279.txt @@ -0,0 +1,4 @@ +```release-note:improvement +core: Added sys/lockedusers/[mount_accessor]/unlock/[alias_identifier] endpoint to unlock an user +with given mount_accessor and alias_identifier if locked +``` \ 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 d686ab92a..94ec6bb55 100644 --- a/vault/external_tests/identity/userlockouts_test.go +++ b/vault/external_tests/identity/userlockouts_test.go @@ -324,3 +324,88 @@ func TestIdentityStore_LockoutCounterResetTest(t *testing.T) { t.Fatalf("expected to see invalid username or password error as user is not locked out, got %v", err) } } + +// TestIdentityStore_UnlockUserTest tests the user is +// unlocked if locked using +// sys/lockedusers/[mount_accessor]/unlock/[alias-identifier] +func TestIdentityStore_UnlockUserTest(t *testing.T) { + coreConfig := &vault.CoreConfig{ + CredentialBackends: map[string]logical.Factory{ + "userpass": userpass.Factory, + }, + } + cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + }) + cluster.Start() + defer cluster.Cleanup() + active := cluster.Cores[0].Client + standby := cluster.Cores[1].Client + + // enable userpass auth method on path userpass + if err := active.Sys().EnableAuthWithOptions("userpass", &api.EnableAuthOptions{ + Type: "userpass", + }); err != nil { + t.Fatal(err) + } + + // get mount accessor for userpass mount + secret, err := standby.Logical().Read("sys/auth/userpass") + if err != nil || secret == nil { + t.Fatal(err) + } + mountAccessor := secret.Data["accessor"].(string) + + // tune auth mount + userlockoutConfig := &api.UserLockoutConfigInput{ + LockoutThreshold: "2", + LockoutDuration: "5m", + } + if err = active.Sys().TuneMount("auth/userpass", api.MountConfigInput{ + UserLockoutConfig: userlockoutConfig, + }); err != nil { + t.Fatal(err) + } + + // create a user for userpass + if _, err = standby.Logical().Write("auth/userpass/users/bsmith", map[string]interface{}{ + "password": "training", + }); err != nil { + t.Fatal(err) + } + + // login failure count 1 + standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{ + "password": "wrongPassword", + }) + // login failure count 2 + standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{ + "password": "wrongPassword", + }) + // login : permission denied as user locked out + if _, err = standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{ + "password": "training", + }); err == nil { + t.Fatal("expected login to fail as user locked out") + } + if !strings.Contains(err.Error(), logical.ErrPermissionDenied.Error()) { + t.Fatalf("expected to see permission denied error as user locked out, got %v", err) + } + + // unlock user + if _, err = standby.Logical().Write("sys/lockedusers/"+mountAccessor+"/unlock/bsmith", nil); err != nil { + t.Fatal(err) + } + + // login: should be successful as user unlocked + if _, err = standby.Logical().Write("auth/userpass/login/bsmith", map[string]interface{}{ + "password": "training", + }); err != nil { + t.Fatal("expected login to succeed as user is unlocked") + } + + // unlock unlocked user + if _, err = active.Logical().Write("sys/lockedusers/mountAccessor/unlock/bsmith", nil); err != nil { + t.Fatal(err) + } +} diff --git a/vault/logical_system.go b/vault/logical_system.go index 7b9cf4b58..b7abbcfc6 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -176,6 +176,7 @@ func NewSystemBackend(core *Core, logger log.Logger) *SystemBackend { b.Backend.Paths = append(b.Backend.Paths, b.auditPaths()...) b.Backend.Paths = append(b.Backend.Paths, b.mountPaths()...) b.Backend.Paths = append(b.Backend.Paths, b.authPaths()...) + b.Backend.Paths = append(b.Backend.Paths, b.lockedUserPaths()...) b.Backend.Paths = append(b.Backend.Paths, b.leasePaths()...) b.Backend.Paths = append(b.Backend.Paths, b.policyPaths()...) b.Backend.Paths = append(b.Backend.Paths, b.wrappingPaths()...) @@ -2204,6 +2205,57 @@ func (b *SystemBackend) handleTuneWriteCommon(ctx context.Context, path string, return resp, 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) + if mountAccessor == "" { + return logical.ErrorResponse( + "missing mount_accessor"), + logical.ErrInvalidRequest + } + + aliasName := data.Get("alias_identifier").(string) + if aliasName == "" { + return logical.ErrorResponse( + "missing alias_identifier"), + logical.ErrInvalidRequest + } + + if err := unlockUser(ctx, b.Core, mountAccessor, aliasName); err != nil { + b.Backend.Logger().Error("unlock user failed", "mount accessor", mountAccessor, "alias identifier", aliasName, "error", err) + return handleError(err) + } + + 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) @@ -5307,6 +5359,27 @@ the auth path.`, the mount.`, }, + "unlock_user": { + "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 + Unlocks the user with given mount_accessor and alias_identifier + if locked.`, + }, + + "mount_accessor": { + "MountAccessor is the identifier of the mount entry to which the user belongs", + "", + }, + + "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 + to an approle auth method, the name should be a valid RoleID`, + "", + }, + "renew": { "Renew a lease on a secret", ` diff --git a/vault/logical_system_paths.go b/vault/logical_system_paths.go index 37817f491..53a3c20d3 100644 --- a/vault/logical_system_paths.go +++ b/vault/logical_system_paths.go @@ -2057,3 +2057,29 @@ func (b *SystemBackend) mountPaths() []*framework.Path { }, } } + +func (b *SystemBackend) lockedUserPaths() []*framework.Path { + return []*framework.Path{ + { + Pattern: "lockedusers/(?P.+?)/unlock/(?P.+)", + Fields: map[string]*framework.FieldSchema{ + "mount_accessor": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["mount_accessor"][0]), + }, + "alias_identifier": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["alias_identifier"][0]), + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.handleUnlockUser, + Summary: "Unlocks the user with given mount_accessor and alias_identifier", + }, + }, + HelpSynopsis: strings.TrimSpace(sysHelp["unlock_user"][0]), + HelpDescription: strings.TrimSpace(sysHelp["unlock_user"][1]), + }, + } +}