From 3b287d5784798b131fcacfe5390a7de057e5299e Mon Sep 17 00:00:00 2001 From: Duane May Date: Thu, 11 Jun 2026 18:14:32 -0400 Subject: [PATCH] Add `unlock-user` command to unlock user accounts --- cmd/unlock_user.go | 103 +++++++++++++++++++++++ cmd/unlock_user_test.go | 158 +++++++++++++++++++++++++++++++++++ docs/commands.md | 1 + docs/commands/unlock-user.md | 42 ++++++++++ 4 files changed, 304 insertions(+) create mode 100644 cmd/unlock_user.go create mode 100644 cmd/unlock_user_test.go create mode 100644 docs/commands/unlock-user.md diff --git a/cmd/unlock_user.go b/cmd/unlock_user.go new file mode 100644 index 0000000..de49d07 --- /dev/null +++ b/cmd/unlock_user.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "fmt" + + "code.cloudfoundry.org/uaa-cli/cli" + "code.cloudfoundry.org/uaa-cli/config" + "code.cloudfoundry.org/uaa-cli/utils" + "errors" + "github.com/cloudfoundry-community/go-uaa" + "github.com/spf13/cobra" +) + +func UnlockUserCmd(api *uaa.API, username, origin, attributes, zoneID string) error { + user, err := api.GetUserByUsername(username, origin, attributes) + if err != nil { + return err + } + if user.Meta == nil { + return errors.New("The user did not have expected metadata version.") + } + + err = unlockUserByID(api, user.ID, zoneID) + if err != nil { + return err + } + + log.Infof("Account for user %v successfully unlocked.", utils.Emphasize(user.Username)) + return nil +} + +// unlockUserByID makes a PATCH request to /Users/{id}/status with {"locked": false} +func unlockUserByID(api *uaa.API, userID, zoneID string) error { + path := fmt.Sprintf("/Users/%s/status", userID) + data := `{"locked": false}` + + headers := []string{"Content-Type: application/json"} + if zoneID != "" { + headers = append(headers, fmt.Sprintf("X-Identity-Zone-Id: %s", zoneID)) + } + + _, _, status, err := api.Curl(path, "PATCH", data, headers) + if err != nil { + return err + } + + if status >= 400 { + return fmt.Errorf("unlock user failed with status %d", status) + } + + return nil +} + +func UnlockUserValidations(cfg config.Config, args []string) error { + if err := cli.EnsureContextInConfig(cfg); err != nil { + return err + } + + if len(args) == 0 { + return errors.New("The positional argument USERNAME must be specified.") + } + return nil +} + +var unlockUserCmd = &cobra.Command{ + Use: "unlock-user USERNAME", + Short: "Unlock a user account by username", + PreRun: func(cmd *cobra.Command, args []string) { + cli.NotifyValidationErrors(UnlockUserValidations(GetSavedConfig(), args), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + + if zoneSubdomain == "" { + zoneSubdomain = cfg.ZoneSubdomain + } + + token := cfg.GetActiveContext().Token + api, err := uaa.New( + cfg.GetActiveTarget().BaseUrl, + uaa.WithToken(&token), + uaa.WithZoneID(zoneSubdomain), + uaa.WithSkipSSLValidation(cfg.GetActiveTarget().SkipSSLValidation), + uaa.WithVerbosity(verbose), + ) + if err != nil { + cli.NotifyErrorsWithRetry(err, log, GetSavedConfig()) + return + } + + err = UnlockUserCmd(api, args[0], origin, attributes, zoneSubdomain) + cli.NotifyErrorsWithRetry(err, log, GetSavedConfig()) + }, +} + +func init() { + RootCmd.AddCommand(unlockUserCmd) + unlockUserCmd.Annotations = make(map[string]string) + unlockUserCmd.Annotations[USER_CRUD_CATEGORY] = "true" + + unlockUserCmd.Flags().StringVarP(&zoneSubdomain, "zone", "z", "", "the identity zone subdomain from which to unlock the user") + unlockUserCmd.Flags().StringVarP(&origin, "origin", "o", "", "the identity provider in which to search. Examples: uaa, ldap, etc.") +} \ No newline at end of file diff --git a/cmd/unlock_user_test.go b/cmd/unlock_user_test.go new file mode 100644 index 0000000..3ab2765 --- /dev/null +++ b/cmd/unlock_user_test.go @@ -0,0 +1,158 @@ +package cmd_test + +import ( + "net/http" + + "code.cloudfoundry.org/uaa-cli/config" + "code.cloudfoundry.org/uaa-cli/fixtures" + "github.com/cloudfoundry-community/go-uaa" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" + . "github.com/onsi/gomega/gexec" + . "github.com/onsi/gomega/ghttp" +) + +var _ = Describe("UnlockUser", func() { + BeforeEach(func() { + c := config.NewConfigWithServerURL(server.URL()) + ctx := config.NewContextWithToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ") + c.AddContext(ctx) + Expect(config.WriteConfig(c)).Should(Succeed()) + }) + + It("unlocks a user", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22woodstock%40peanuts.com%22&startIndex=1"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "woodstock@peanuts.com", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PATCH", "/Users/abcdef/status", CombineHandlers( + VerifyRequest("PATCH", "/Users/abcdef/status", ""), + VerifyJSON(`{"locked": false}`), + RespondWith(http.StatusOK, `{"locked": false}`), + )) + + session := runCommand("unlock-user", "woodstock@peanuts.com") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(Say("Account for user woodstock@peanuts.com successfully unlocked.")) + }) + + It("unlocks a user with --verbose", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22woodstock%40peanuts.com%22&startIndex=1"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "woodstock@peanuts.com", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PATCH", "/Users/abcdef/status", CombineHandlers( + VerifyRequest("PATCH", "/Users/abcdef/status", ""), + VerifyJSON(`{"locked": false}`), + RespondWith(http.StatusOK, `{"locked": false}`), + )) + + session := runCommand("unlock-user", "woodstock@peanuts.com", "--verbose") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(Say("Account for user woodstock@peanuts.com successfully unlocked.")) + }) + + It("unlocks a user with --origin", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22woodstock%40peanuts.com%22+and+origin+eq+%22ldap%22&startIndex=1"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "woodstock@peanuts.com", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PATCH", "/Users/abcdef/status", CombineHandlers( + VerifyRequest("PATCH", "/Users/abcdef/status", ""), + VerifyJSON(`{"locked": false}`), + RespondWith(http.StatusOK, `{"locked": false}`), + )) + + session := runCommand("unlock-user", "woodstock@peanuts.com", "--origin", "ldap") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(Say("Account for user woodstock@peanuts.com successfully unlocked.")) + }) + + It("unlocks a user with --zone", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22woodstock%40peanuts.com%22&startIndex=1"), + VerifyHeaderKV("X-Identity-Zone-Id", "test-zone"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "woodstock@peanuts.com", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PATCH", "/Users/abcdef/status", CombineHandlers( + VerifyRequest("PATCH", "/Users/abcdef/status", ""), + VerifyHeaderKV("X-Identity-Zone-Id", "test-zone"), + VerifyJSON(`{"locked": false}`), + RespondWith(http.StatusOK, `{"locked": false}`), + )) + + session := runCommand("unlock-user", "woodstock@peanuts.com", "--zone", "test-zone") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(Say("Account for user woodstock@peanuts.com successfully unlocked.")) + }) + + Describe("error conditions", func() { + It("displays error when user not found", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22nobody%22&startIndex=1"), + RespondWith(http.StatusNotFound, `{"error": "scim_resource_not_found", "error_description": "User nobody does not exist"}`), + )) + + session := runCommand("unlock-user", "nobody") + + Eventually(session).Should(Exit(1)) + }) + + It("displays error when unlock request fails", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22woodstock%40peanuts.com%22&startIndex=1"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "woodstock@peanuts.com", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PATCH", "/Users/abcdef/status", CombineHandlers( + VerifyRequest("PATCH", "/Users/abcdef/status", ""), + RespondWith(http.StatusBadRequest, `{"error": "invalid_user", "error_description": "User cannot be unlocked"}`), + )) + + session := runCommand("unlock-user", "woodstock@peanuts.com") + + Eventually(session).Should(Exit(1)) + }) + }) + + Describe("validations", func() { + It("requires a target", func() { + config.WriteConfig(config.NewConfig()) + + session := runCommand("unlock-user", "woodstock@peanuts.com") + + Expect(session.Err).To(Say("You must set a target in order to use this command.")) + Expect(session).Should(Exit(1)) + }) + + It("requires a context", func() { + cfg := config.NewConfigWithServerURL(server.URL()) + config.WriteConfig(cfg) + + session := runCommand("unlock-user", "woodstock@peanuts.com") + + Expect(session.Err).To(Say("You must have a token in your context to perform this command.")) + Expect(session).Should(Exit(1)) + }) + + It("requires a username", func() { + c := config.NewConfigWithServerURL(server.URL()) + ctx := config.NewContextWithToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ") + c.AddContext(ctx) + config.WriteConfig(c) + + session := runCommand("unlock-user") + + Expect(session.Err).To(Say("The positional argument USERNAME must be specified.")) + Expect(session).Should(Exit(1)) + }) + }) +}) \ No newline at end of file diff --git a/docs/commands.md b/docs/commands.md index 48a8cdc..393b132 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -48,6 +48,7 @@ Each command name below links to a page with a full description, including all a | [`delete-user`](commands/delete-user.md) | Delete a user by username | | [`activate-user`](commands/activate-user.md) | Activate a user by username | | [`deactivate-user`](commands/deactivate-user.md) | Deactivate a user by username | +| [`unlock-user`](commands/unlock-user.md) | Unlock a user account by username | ## Managing Groups diff --git a/docs/commands/unlock-user.md b/docs/commands/unlock-user.md new file mode 100644 index 0000000..bfc3409 --- /dev/null +++ b/docs/commands/unlock-user.md @@ -0,0 +1,42 @@ +# unlock-user + +[← Command Reference](../commands.md) + +Unlock a user account by username. This removes lockouts caused by failed login attempts. + +## Usage + +``` +uaa unlock-user USERNAME [flags] +``` + +## Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--origin` | `-o` | | Identity provider to search for the user (e.g., uaa, ldap) | +| `--zone` | `-z` | | Identity zone subdomain from which to unlock the user | + +## Global Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--verbose` | `-v` | Print additional info on HTTP requests | + +## Examples + +```bash +uaa unlock-user bob +uaa unlock-user bob --origin ldap +uaa unlock-user bob --zone my-zone +``` + +## See Also + +- [activate-user](activate-user.md) +- [deactivate-user](deactivate-user.md) +- [get-user](get-user.md) + +--- + +[← Command Reference](../commands.md) \ No newline at end of file