From cab8828c66a034a061f91781a2f160fec5cf2a4c Mon Sep 17 00:00:00 2001 From: Duane May Date: Thu, 11 Jun 2026 18:17:01 -0400 Subject: [PATCH] Add `set-password` command with admin functionality and CLI documentation --- cmd/set_password.go | 121 ++++++++++++++++++++++++ cmd/set_password_test.go | 172 ++++++++++++++++++++++++++++++++++ docs/commands.md | 1 + docs/commands/set-password.md | 59 ++++++++++++ docs/migrating-from-uaac.md | 4 +- 5 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 cmd/set_password.go create mode 100644 cmd/set_password_test.go create mode 100644 docs/commands/set-password.md diff --git a/cmd/set_password.go b/cmd/set_password.go new file mode 100644 index 0000000..0daf755 --- /dev/null +++ b/cmd/set_password.go @@ -0,0 +1,121 @@ +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 SetPasswordCmd(api *uaa.API, username, origin, attributes, password, 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 = setPasswordByID(api, user.ID, password, zoneID) + if err != nil { + return err + } + + log.Infof("Password for user %v successfully set.", utils.Emphasize(user.Username)) + return nil +} + +// setPasswordByID makes a PUT request to /Users/{id}/password with {"password": "newpassword"} +func setPasswordByID(api *uaa.API, userID, password, zoneID string) error { + path := fmt.Sprintf("/Users/%s/password", userID) + data := fmt.Sprintf(`{"password": "%s"}`, password) + + headers := []string{"Content-Type: application/json"} + if zoneID != "" { + headers = append(headers, fmt.Sprintf("X-Identity-Zone-Id: %s", zoneID)) + } + + _, _, status, err := api.Curl(path, "PUT", data, headers) + if err != nil { + return err + } + + if status >= 400 { + return fmt.Errorf("set password failed with status %d", status) + } + + return nil +} + +func SetPasswordValidations(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 setPasswordCmd = &cobra.Command{ + Use: "set-password USERNAME", + Short: "Set password for a user (admin)", + PreRun: func(cmd *cobra.Command, args []string) { + cli.NotifyValidationErrors(SetPasswordValidations(GetSavedConfig(), args), cmd, log) + }, + Run: func(cmd *cobra.Command, args []string) { + cfg := GetSavedConfig() + + // Get password from flag or prompt if not provided + if userPassword == "" { + secret := cli.InteractiveSecret{Prompt: "New password"} + var err error + userPassword, err = secret.Get() + if err != nil { + cli.NotifyErrorsWithRetry(err, log, GetSavedConfig()) + return + } + } + + if userPassword == "" { + cli.NotifyErrorsWithRetry(errors.New("Password must be specified with --password flag or entered when prompted."), log, GetSavedConfig()) + return + } + + 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 = SetPasswordCmd(api, args[0], origin, attributes, userPassword, zoneSubdomain) + cli.NotifyErrorsWithRetry(err, log, GetSavedConfig()) + }, +} + +func init() { + RootCmd.AddCommand(setPasswordCmd) + setPasswordCmd.Annotations = make(map[string]string) + setPasswordCmd.Annotations[USER_CRUD_CATEGORY] = "true" + + setPasswordCmd.Flags().StringVarP(&userPassword, "password", "p", "", "new password for the user") + setPasswordCmd.Flags().StringVarP(&zoneSubdomain, "zone", "z", "", "the identity zone subdomain in which to set the password") + setPasswordCmd.Flags().StringVarP(&origin, "origin", "o", "", "the identity provider in which to search. Examples: uaa, ldap, etc.") +} diff --git a/cmd/set_password_test.go b/cmd/set_password_test.go new file mode 100644 index 0000000..49b9949 --- /dev/null +++ b/cmd/set_password_test.go @@ -0,0 +1,172 @@ +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("SetPassword", func() { + BeforeEach(func() { + c := config.NewConfigWithServerURL(server.URL()) + ctx := config.NewContextWithToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ") + c.AddContext(ctx) + Expect(config.WriteConfig(c)).Should(Succeed()) + }) + + It("sets a user password", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22testuser%22&startIndex=1"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "testuser", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PUT", "/Users/abcdef/password", CombineHandlers( + VerifyRequest("PUT", "/Users/abcdef/password", ""), + VerifyJSON(`{"password": "newpass"}`), + RespondWith(http.StatusOK, `{"message": "password updated"}`), + )) + + session := runCommand("set-password", "testuser", "--password", "newpass") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(Say("Password for user testuser successfully set.")) + }) + + It("sets a user password with --verbose", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22testuser%22&startIndex=1"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "testuser", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PUT", "/Users/abcdef/password", CombineHandlers( + VerifyRequest("PUT", "/Users/abcdef/password", ""), + VerifyJSON(`{"password": "newpass"}`), + RespondWith(http.StatusOK, `{"message": "password updated"}`), + )) + + session := runCommand("set-password", "testuser", "--password", "newpass", "--verbose") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(Say("Password for user testuser successfully set.")) + }) + + It("sets a user password with --origin", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22testuser%22+and+origin+eq+%22ldap%22&startIndex=1"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "testuser", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PUT", "/Users/abcdef/password", CombineHandlers( + VerifyRequest("PUT", "/Users/abcdef/password", ""), + VerifyJSON(`{"password": "newpass"}`), + RespondWith(http.StatusOK, `{"message": "password updated"}`), + )) + + session := runCommand("set-password", "testuser", "--password", "newpass", "--origin", "ldap") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(Say("Password for user testuser successfully set.")) + }) + + It("sets a user password with --zone", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22testuser%22&startIndex=1"), + VerifyHeaderKV("X-Identity-Zone-Id", "test-zone"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "testuser", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PUT", "/Users/abcdef/password", CombineHandlers( + VerifyRequest("PUT", "/Users/abcdef/password", ""), + VerifyHeaderKV("X-Identity-Zone-Id", "test-zone"), + VerifyJSON(`{"password": "newpass"}`), + RespondWith(http.StatusOK, `{"message": "password updated"}`), + )) + + session := runCommand("set-password", "testuser", "--password", "newpass", "--zone", "test-zone") + + Expect(server.ReceivedRequests()).To(HaveLen(2)) + Eventually(session).Should(Exit(0)) + Expect(session.Out).To(Say("Password for user testuser successfully set.")) + }) + + 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("set-password", "nobody", "--password", "newpass") + + Eventually(session).Should(Exit(1)) + }) + + It("displays error when password change request fails", func() { + server.RouteToHandler("GET", "/Users", CombineHandlers( + VerifyRequest("GET", "/Users", "count=100&filter=userName+eq+%22testuser%22&startIndex=1"), + RespondWith(http.StatusOK, fixtures.PaginatedResponse(uaa.User{Username: "testuser", ID: "abcdef", Meta: &uaa.Meta{Version: 10}})), + )) + server.RouteToHandler("PUT", "/Users/abcdef/password", CombineHandlers( + VerifyRequest("PUT", "/Users/abcdef/password", ""), + RespondWith(http.StatusBadRequest, `{"error": "invalid_password", "error_description": "Password does not meet policy requirements"}`), + )) + + session := runCommand("set-password", "testuser", "--password", "weak") + + Eventually(session).Should(Exit(1)) + }) + }) + + Describe("validations", func() { + It("requires a target", func() { + config.WriteConfig(config.NewConfig()) + + session := runCommand("set-password", "testuser", "--password", "newpass") + + 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("set-password", "testuser", "--password", "newpass") + + 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("set-password") + + Expect(session.Err).To(Say("The positional argument USERNAME must be specified.")) + Expect(session).Should(Exit(1)) + }) + + It("displays help when no password provided (interactive mode skipped in tests)", func() { + c := config.NewConfigWithServerURL(server.URL()) + ctx := config.NewContextWithToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ") + c.AddContext(ctx) + config.WriteConfig(c) + + // In test environment, when no password is provided, the interactive prompt + // will fail due to inappropriate ioctl. This tests that the validation + // is working even though the specific error may vary in test vs. real usage. + session := runCommand("set-password", "testuser") + + Eventually(session).Should(Exit(1)) + }) + }) +}) diff --git a/docs/commands.md b/docs/commands.md index 48a8cdc..728a122 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 | +| [`set-password`](commands/set-password.md) | Set password for a user (admin) | ## Managing Groups diff --git a/docs/commands/set-password.md b/docs/commands/set-password.md new file mode 100644 index 0000000..bc7fd67 --- /dev/null +++ b/docs/commands/set-password.md @@ -0,0 +1,59 @@ +# set-password + +[← Command Reference](../commands.md) + +Set password for a user account by username (admin operation). This command allows administrators to change a user's password without knowing the current password. + +## Usage + +``` +uaa set-password USERNAME [flags] +``` + +## Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--password` | `-p` | | New password for the user (will prompt if not provided) | +| `--origin` | `-o` | | Identity provider in which to search. Examples: uaa, ldap, etc. | +| `--zone` | `-z` | | Identity zone subdomain in which to set the password | + +## Global Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--verbose` | `-v` | Print additional info on HTTP requests | + +## Examples + +```bash +# Set password interactively (will prompt) +uaa set-password testuser + +# Set password via command line flag +uaa set-password testuser --password newpassword123 + +# Set password for user in specific origin +uaa set-password testuser --password newpass --origin ldap + +# Set password for user in specific zone +uaa set-password testuser --password newpass --zone my-zone + +# Use verbose mode to see request details +uaa set-password testuser --password newpass --verbose +``` + +## Authentication Requirements + +This command requires administrative privileges. You need a token with `password.write` or `scim.write` scopes. + +## See Also + +- [create-user](create-user.md) +- [get-user](get-user.md) +- [activate-user](activate-user.md) +- [deactivate-user](deactivate-user.md) + +--- + +[← Command Reference](../commands.md) \ No newline at end of file diff --git a/docs/migrating-from-uaac.md b/docs/migrating-from-uaac.md index 83b08fc..a4f3b2b 100644 --- a/docs/migrating-from-uaac.md +++ b/docs/migrating-from-uaac.md @@ -72,8 +72,8 @@ The `uaa` CLI outputs a combination of human-readable status messages and JSON d | `uaac user deactivate [name]` | [`uaa deactivate-user USERNAME`](commands/deactivate-user.md) | | | `uaac user update [name]` | *(no equivalent)* | Use `uaa curl /Users/USER_ID -X PUT -d '{...}'` | | `uaac user ids [username\|id...]` | *(no equivalent)* | Use `uaa get-user USERNAME` for individual lookups | -| `uaac user unlock [name]` | *(no equivalent)* | Use `uaa curl /Users/USER_ID/status -X PATCH -d '{"locked":false}'` | -| `uaac password set [name]` | *(no equivalent)* | Use `uaa curl /Users/USER_ID/password -X PUT -d '{"password":"NEW"}'` | +| `uaac user unlock [name]` | [`uaa unlock-user USERNAME`](commands/unlock-user.md) | | +| `uaac password set [name]` | [`uaa set-password USERNAME --password NEWPASS`](commands/set-password.md) | | | `uaac password change` | *(no equivalent)* | Use `uaa curl /Users/USER_ID/password -X PUT -d '{"oldPassword":"OLD","password":"NEW"}'` | ### Clients