From 77652add1a5b2558a09c7e251b8d05fcf703df7d Mon Sep 17 00:00:00 2001 From: Nikita K Date: Thu, 25 Jan 2024 09:36:18 +0200 Subject: [PATCH] feat: add changing user password route for htpasswd Signed-off-by: onidoru --- errors/errors.go | 10 ++ pkg/api/authn.go | 33 +------ pkg/api/constants/consts.go | 1 + pkg/api/controller.go | 15 ++- pkg/api/controller_test.go | 54 +++++++++++ pkg/api/htpasswd.go | 184 ++++++++++++++++++++++++++++++++++++ pkg/api/htpasswd_test.go | 183 +++++++++++++++++++++++++++++++++++ pkg/api/routes.go | 81 ++++++++++++++++ pkg/api/routes_test.go | 73 ++++++++++++++ swagger/docs.go | 64 +++++++++++++ swagger/swagger.json | 64 +++++++++++++ swagger/swagger.yaml | 42 ++++++++ 12 files changed, 775 insertions(+), 29 deletions(-) create mode 100644 pkg/api/htpasswd.go create mode 100644 pkg/api/htpasswd_test.go diff --git a/errors/errors.go b/errors/errors.go index 44191b1d2f..f6126d4f18 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -168,4 +168,14 @@ var ( ErrAPINotSupported = errors.New("registry at the given address doesn't implement the correct API") ErrURLNotFound = errors.New("url not found") ErrInvalidSearchQuery = errors.New("invalid search query") + + // ErrUserIsNotFound returned if the user is not found. + ErrUserIsNotFound = errors.New("user is not found") + // ErrPasswordsDoNotMatch returned if given password does not match existing user's password. + ErrPasswordsDoNotMatch = errors.New("passwords do not match") + // ErrOldPasswordIsWrong returned if provided old password for user verification + // during the password change is wrong. + ErrOldPasswordIsWrong = errors.New("old password is wrong") + // ErrPasswordIsEmpty returned if user's new password is empty + ErrPasswordIsEmpty = errors.New("password can not be empty") ) diff --git a/pkg/api/authn.go b/pkg/api/authn.go index 6f396079e6..a296b2f5f3 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -1,7 +1,6 @@ package api import ( - "bufio" "context" "crypto/sha256" "crypto/x509" @@ -26,7 +25,6 @@ import ( "github.com/zitadel/oidc/pkg/client/rp" httphelper "github.com/zitadel/oidc/pkg/http" "github.com/zitadel/oidc/pkg/oidc" - "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" githubOAuth "golang.org/x/oauth2/github" @@ -46,9 +44,9 @@ const ( ) type AuthnMiddleware struct { - credMap map[string]string - ldapClient *LDAPClient - log log.Logger + htpasswdClient *HtpasswdClient + ldapClient *LDAPClient + log log.Logger } func AuthHandler(ctlr *Controller) mux.MiddlewareFunc { @@ -109,10 +107,10 @@ func (amw *AuthnMiddleware) basicAuthn(ctlr *Controller, userAc *reqCtx.UserAcce return false, nil } - passphraseHash, ok := amw.credMap[identity] + passphraseHash, ok := amw.htpasswdClient.Get(identity) if ok { // first, HTTPPassword authN (which is local) - if err := bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase)); err == nil { + if err := amw.htpasswdClient.CheckPassword(identity, passphraseHash); err == nil { // Process request var groups []string @@ -254,8 +252,6 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun return noPasswdAuth(ctlr) } - amw.credMap = make(map[string]string) - delay := ctlr.Config.HTTP.Auth.FailDelay // ldap and htpasswd based authN @@ -304,25 +300,6 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun } } - if ctlr.Config.IsHtpasswdAuthEnabled() { - credsFile, err := os.Open(ctlr.Config.HTTP.Auth.HTPasswd.Path) - if err != nil { - amw.log.Panic().Err(err).Str("credsFile", ctlr.Config.HTTP.Auth.HTPasswd.Path). - Msg("failed to open creds-file") - } - defer credsFile.Close() - - scanner := bufio.NewScanner(credsFile) - - for scanner.Scan() { - line := scanner.Text() - if strings.Contains(line, ":") { - tokens := strings.Split(scanner.Text(), ":") - amw.credMap[tokens[0]] = tokens[1] - } - } - } - // openid based authN if ctlr.Config.IsOpenIDAuthEnabled() { ctlr.RelyingParties = make(map[string]rp.RelyingParty) diff --git a/pkg/api/constants/consts.go b/pkg/api/constants/consts.go index 7f9e9a2cef..3cea79e4cf 100644 --- a/pkg/api/constants/consts.go +++ b/pkg/api/constants/consts.go @@ -19,6 +19,7 @@ const ( LoginPath = AppNamespacePath + "/auth/login" LogoutPath = AppNamespacePath + "/auth/logout" APIKeyPath = AppNamespacePath + "/auth/apikey" + ChangePasswordPath = AppNamespacePath + "/auth/change_password" SessionClientHeaderName = "X-ZOT-API-CLIENT" SessionClientHeaderValue = "zot-ui" APIKeysPrefix = "zak_" diff --git a/pkg/api/controller.go b/pkg/api/controller.go index 582411b6ea..3c02e3c830 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -49,6 +49,7 @@ type Controller struct { RelyingParties map[string]rp.RelyingParty CookieStore *CookieStore taskScheduler *scheduler.Scheduler + htpasswdClient *HtpasswdClient // runtime params chosenPort int // kernel-chosen port } @@ -98,7 +99,9 @@ func (c *Controller) Run() error { return err } - c.StartBackgroundTasks() + if err := c.initHtpasswdClient(); err != nil { + return err + } // setup HTTP API router engine := mux.NewRouter() @@ -279,6 +282,16 @@ func (c *Controller) initCookieStore() error { return nil } +func (c *Controller) initHtpasswdClient() error { + if c.Config.IsHtpasswdAuthEnabled() { + c.htpasswdClient = NewHtpasswdClient(c.Config.HTTP.Auth.HTPasswd.Path) + + return c.htpasswdClient.Init() + } + + return nil +} + func (c *Controller) InitMetaDB() error { // init metaDB if search is enabled or we need to store user profiles, api keys or signatures if c.Config.IsSearchEnabled() || c.Config.IsBasicAuthnEnabled() || c.Config.IsImageTrustEnabled() || diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index 9db8a2254f..6fae651e86 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -4446,6 +4446,60 @@ func TestAuthorization(t *testing.T) { }) } +func TestChangePassword(t *testing.T) { + Convey("Make a new controller", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + username, seedUser := test.GenerateRandomString() + password, seedPass := test.GenerateRandomString() + htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, password)) + defer os.Remove(htpasswdPath) + + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + test.AuthorizationAllRepos: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{}, + Actions: []string{}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } + + Convey("with basic auth", func() { + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + err := WriteImageToFileSystem(CreateDefaultImage(), "zot-test", "0.0.1", + ociutils.GetDefaultStoreController(ctlr.Config.Storage.RootDirectory, ctlr.Log)) + So(err, ShouldBeNil) + + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + defer cm.StopServer() + + client := resty.New() + client.SetBasicAuth(username, password) + + RunAuthorizationTests(t, client, baseURL, username, conf) + }) + }) +} + func TestGetUsername(t *testing.T) { Convey("Make a new controller", t, func() { port := test.GetFreePort() diff --git a/pkg/api/htpasswd.go b/pkg/api/htpasswd.go new file mode 100644 index 0000000000..81868e4601 --- /dev/null +++ b/pkg/api/htpasswd.go @@ -0,0 +1,184 @@ +package api + +import ( + "bufio" + "fmt" + "os" + "strings" + "sync" + + "golang.org/x/crypto/bcrypt" + + zerr "zotregistry.io/zot/errors" + "zotregistry.io/zot/pkg/storage/constants" +) + +const ( + htpasswdValidTokensNumber = 2 +) + +type HtpasswdClient struct { + credMap credMap + filepath string +} + +type credMap struct { + m map[string]string + rw *sync.RWMutex +} + +func NewHtpasswdClient(filepath string) *HtpasswdClient { + return &HtpasswdClient{ + filepath: filepath, + credMap: credMap{ + m: make(map[string]string), + rw: &sync.RWMutex{}, + }, + } +} + +// Init initializes the HtpasswdClient. +// It performs the file read using the filename specified in NewHtpasswdClient +// and caches all user passwords. +func (hc *HtpasswdClient) Init() error { + credsFile, err := os.Open(hc.filepath) + if err != nil { + return fmt.Errorf("error occurred while opening creds-file: %w", err) + } + defer credsFile.Close() + + hc.credMap.rw.Lock() + defer hc.credMap.rw.Unlock() + + scanner := bufio.NewScanner(credsFile) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, ":") { + tokens := strings.Split(line, ":") + if len(tokens) == htpasswdValidTokensNumber { + hc.credMap.m[tokens[0]] = tokens[1] + } + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error occurred while reading creds-file: %w", err) + } + + return nil +} + +// Get returns the password associated with the login and a bool +// indicating whether the login was found. +// It does not check whether the user's password is correct. +func (hc *HtpasswdClient) Get(login string) (string, bool) { + return hc.credMap.Get(login) +} + +// Set sets the new password. It does not perform any checks, +// the only error is possible is encryption error. +func (hc *HtpasswdClient) Set(login, password string) error { + passphrase, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("error occurred while cheking passwords: %w", err) + } + + return hc.credMap.Set(login, string(passphrase)) +} + +// CheckPassword checks whether the user has a specified password. +// It returns an error if the user is not found or passwords do not match, +// and returns the nil on passwords match. +func (hc *HtpasswdClient) CheckPassword(login, password string) error { + passwordHash, ok := hc.Get(login) + if !ok { + return zerr.ErrUserIsNotFound + } + + err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) + if err != nil { + return zerr.ErrPasswordsDoNotMatch + } + + return nil +} + +// ChangePassword changes the user password. +// It accepts user login, his supposed old password for verification and new password. +func (hc *HtpasswdClient) ChangePassword(login, supposedOldPassword, newPassword string) error { + if len(newPassword) == 0 { + return zerr.ErrPasswordIsEmpty + } + + hc.credMap.rw.RLock() + oldPassphrase, ok := hc.credMap.m[login] + hc.credMap.rw.RUnlock() + + if !ok { + return zerr.ErrUserIsNotFound + } + + // given old password must match actual old password + if err := bcrypt.CompareHashAndPassword([]byte(oldPassphrase), []byte(supposedOldPassword)); err != nil { + return zerr.ErrOldPasswordIsWrong + } + + // if passwords match, no need to update file and map, return nil as if operation is successful + if err := bcrypt.CompareHashAndPassword([]byte(oldPassphrase), []byte(newPassword)); err == nil { + return nil + } + + // encrypt new password + newPassphrase, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("error occurred while encrypting new password: %w", err) + } + + file, err := os.ReadFile(hc.filepath) + if err != nil { + return fmt.Errorf("error occurred while reading creds-file: %w", err) + } + + // read passwords line by line to find the corresponding login + lines := strings.Split(string(file), "\n") + for i, line := range lines { + if tokens := strings.Split(line, ":"); len(tokens) == htpasswdValidTokensNumber { + if tokens[0] == login { + lines[i] = tokens[0] + ":" + string(newPassphrase) + + break + } + } + } + + // write new content to file + output := strings.Join(lines, "\n") + + err = os.WriteFile(hc.filepath, []byte(output), constants.DefaultDirPerms) + if err != nil { + return fmt.Errorf("error occurred while writing to creds-file: %w", err) + } + + // set to credMap only if all file operations are successful to prevent collisions + hc.credMap.rw.Lock() + hc.credMap.m[login] = string(newPassphrase) + hc.credMap.rw.Unlock() + + return nil +} + +func (c credMap) Set(login, passphrase string) error { + c.rw.Lock() + c.m[login] = passphrase + c.rw.Unlock() + + return nil +} + +func (c credMap) Get(login string) (string, bool) { + c.rw.RLock() + defer c.rw.RUnlock() + passphrase, ok := c.m[login] + + return passphrase, ok +} diff --git a/pkg/api/htpasswd_test.go b/pkg/api/htpasswd_test.go new file mode 100644 index 0000000000..b261df2caa --- /dev/null +++ b/pkg/api/htpasswd_test.go @@ -0,0 +1,183 @@ +package api_test + +import ( + "os" + "strings" + "testing" + + . "github.com/smartystreets/goconvey/convey" + "golang.org/x/crypto/bcrypt" + + zerr "zotregistry.io/zot/errors" + "zotregistry.io/zot/pkg/api" + test "zotregistry.io/zot/pkg/test/common" +) + +func TestHtpasswdClient_ChangePassword(t *testing.T) { + Convey("test htpasswd client change oldPassword", t, func() { + username, _ := test.GenerateRandomString() + oldPassword, _ := test.GenerateRandomString() + newPassword, _ := test.GenerateRandomString() + + htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, oldPassword)) + defer os.Remove(htpasswdPath) + + client := api.NewHtpasswdClient(htpasswdPath) + So(client.Init(), ShouldBeNil) + + Convey("change for non-existing login", func() { + err := client.ChangePassword("non-existing", "old_password", "new_password") + So(err, ShouldEqual, zerr.ErrUserIsNotFound) + }) + + Convey("change with wrong old oldPassword", func() { + err := client.ChangePassword(username, "wrong_password", "new_password") + So(err, ShouldEqual, zerr.ErrOldPasswordIsWrong) + + passphrase, ok := client.Get(username) + So(ok, ShouldBeTrue) + So(bcrypt.CompareHashAndPassword([]byte(passphrase), []byte(oldPassword)), ShouldBeNil) + }) + + Convey("change with empty new oldPassword", func() { + err := client.ChangePassword(username, oldPassword, "") + So(err, ShouldEqual, zerr.ErrPasswordIsEmpty) + + passphrase, ok := client.Get(username) + So(ok, ShouldBeTrue) + So(bcrypt.CompareHashAndPassword([]byte(passphrase), []byte(oldPassword)), ShouldBeNil) + }) + + Convey("change to the same password", func() { + err := client.ChangePassword(username, oldPassword, oldPassword) + So(err, ShouldBeNil) + + passphrase, ok := client.Get(username) + So(ok, ShouldBeTrue) + So(bcrypt.CompareHashAndPassword([]byte(passphrase), []byte(oldPassword)), ShouldBeNil) + }) + + Convey("change to the new password", func() { + err := client.ChangePassword(username, oldPassword, newPassword) + So(err, ShouldBeNil) + + passphrase, ok := client.Get(username) + So(ok, ShouldBeTrue) + So(bcrypt.CompareHashAndPassword([]byte(passphrase), []byte(newPassword)), ShouldBeNil) + + // check htpasswd file to ensure the new password is written + fileContent, err := os.ReadFile(htpasswdPath) + So(err, ShouldBeNil) + lines := strings.Split(string(fileContent), "\n") + found := false + for _, line := range lines { + if strings.HasPrefix(line, username+":") { + found = true + So(line, ShouldEqual, username+":"+passphrase) + + break + } + } + So(found, ShouldBeTrue) + }) + }) +} + +func TestHtpasswdClient_CheckPassword(t *testing.T) { + Convey("test htpasswd client check password", t, func() { + username, _ := test.GenerateRandomString() + password, _ := test.GenerateRandomString() + + htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, password)) + defer os.Remove(htpasswdPath) + + client := api.NewHtpasswdClient(htpasswdPath) + So(client.Init(), ShouldBeNil) + + Convey("check for non-existing login", func() { + err := client.CheckPassword("non-existing", "password") + So(err, ShouldEqual, zerr.ErrUserIsNotFound) + }) + + Convey("check with wrong password", func() { + err := client.CheckPassword(username, "wrong_password") + So(err, ShouldEqual, zerr.ErrPasswordsDoNotMatch) + }) + + Convey("check with correct password", func() { + err := client.CheckPassword(username, password) + So(err, ShouldBeNil) + }) + }) +} + +func TestHtpasswdClient_Init(t *testing.T) { + username, _ := test.GenerateRandomString() + password, _ := test.GenerateRandomString() + + htpasswdPath := test.MakeHtpasswdFileFromString(test.GetCredString(username, password)) + defer os.Remove(htpasswdPath) + + Convey("test htpasswd client init", t, func() { + Convey("file does not exist", func() { + client := api.NewHtpasswdClient("non-existing/path") + err := client.Init() + So(err, ShouldBeError, + "error occurred while opening creds-file: open non-existing/path: no such file or directory") + }) + + Convey("file exists, bad format", func() { + htpasswdPath := test.MakeHtpasswdFileFromString("random text") + defer os.Remove(htpasswdPath) + + client := api.NewHtpasswdClient(htpasswdPath) + err := client.Init() + So(err, ShouldBeNil) + }) + + Convey("file exists, contains username:password", func() { + client := api.NewHtpasswdClient(htpasswdPath) + err := client.Init() + So(err, ShouldBeNil) + + gotPasshprase, ok := client.Get(username) + So(ok, ShouldBeTrue) + So(bcrypt.CompareHashAndPassword([]byte(gotPasshprase), []byte(password)), ShouldBeNil) + }) + }) +} + +// +// func Test_credMap_Get(t *testing.T) { +// credsMap := credMap{ +// m: map[string]string{"testuser": "testpassword"}, +// rw: &sync.RWMutex{}, +// } +// +// Convey("test credMap Get", t, func() { +// Convey("should get existing password", func() { +// passhprase, ok := credsMap.Get("testuser") +// So(ok, ShouldBeTrue) +// So(passhprase, ShouldEqual, "testpassword") +// }) +// +// Convey("should not get unexisting password", func() { +// passhprase, ok := credsMap.Get("non-existing") +// So(ok, ShouldBeFalse) +// So(passhprase, ShouldBeBlank) +// }) +// }) +// } +// +// func Test_credMap_Set(t *testing.T) { +// credsMap := credMap{ +// m: make(map[string]string), +// rw: &sync.RWMutex{}, +// } +// +// Convey("should set password", t, func() { +// err := credsMap.Set("testuser", "testpassword") +// So(err, ShouldBeNil) +// So(credsMap.m["testuser"], ShouldNotBeEmpty) +// }) +// } diff --git a/pkg/api/routes.go b/pkg/api/routes.go index b6253ebd8a..dce06afe3b 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -111,6 +111,13 @@ func (rh *RouteHandler) SetupRoutes() { Methods(http.MethodPost, http.MethodOptions) } + if rh.c.Config.IsHtpasswdAuthEnabled() { + // Changing password should be enabled only for users that have set it up through htpasswd + rh.c.Router.HandleFunc(constants.ChangePasswordPath, + getUIHeadersHandler(rh.c.Config, http.MethodPost, http.MethodOptions)(applyCORSHeaders(rh.ChangePassword))). + Methods(http.MethodPost, http.MethodOptions) + } + prefixedRouter := rh.c.Router.PathPrefix(constants.RoutePrefix).Subrouter() prefixedRouter.Use(authHandler) @@ -2204,6 +2211,80 @@ func (rh *RouteHandler) RevokeAPIKey(resp http.ResponseWriter, req *http.Request resp.WriteHeader(http.StatusOK) } +// ChangePassword godoc +// @Summary Change user's password +// @Description Change user's password. Invalidates all user's other active sessions. +// @Router /zot/auth/change_password [post] +// @Accept json +// @Produce json +// @Param old_password body string true "Old password" +// @Param new_password body string true "New password" +// @Success 200 {string} string "password changed" +// @Failure 500 {string} string "internal server error" +// @Failure 401 {string} string "unauthorized" +// @Failure 400 {string} string "bad request" +// @Failure 403 {string} string "old password is incorrect". +func (rh *RouteHandler) ChangePassword(resp http.ResponseWriter, req *http.Request) { + // leave OPTIONS method + if req.Method == http.MethodOptions { + return + } + + body, err := io.ReadAll(req.Body) + if err != nil { + rh.c.Log.Error().Msg("failed to read req body") + resp.WriteHeader(http.StatusInternalServerError) + _, _ = resp.Write([]byte("internal server error")) + + return + } + + var reqBody ChangePasswordRequest + if err := json.Unmarshal(body, &reqBody); err != nil { + rh.c.Log.Error().Msg("failed to unmarshal req body") + resp.WriteHeader(http.StatusBadRequest) + _, _ = resp.Write([]byte("bad req")) + + return + } + + userAc, err := reqCtx.UserAcFromContext(req.Context()) + if err != nil { + return + } + + username := userAc.GetUsername() + if err := rh.c.htpasswdClient.ChangePassword(username, reqBody.OldPassword, reqBody.NewPassword); err != nil { + rh.c.Log.Error().Err(err).Str("identity", username).Msg("failed to change user password") + status := http.StatusInternalServerError + msg := err.Error() + + switch { + case errors.Is(err, zerr.ErrUserIsNotFound): + status = http.StatusNotFound + case errors.Is(err, zerr.ErrOldPasswordIsWrong): + status = http.StatusUnauthorized + case errors.Is(err, zerr.ErrPasswordIsEmpty): + status = http.StatusBadRequest + default: + msg = "internal server error" + } + + resp.WriteHeader(status) + _, _ = resp.Write([]byte(msg)) + + return + } + + resp.WriteHeader(http.StatusOK) + _, _ = resp.Write([]byte("password changed")) +} + +type ChangePasswordRequest struct { + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassowrd"` +} + // GetBlobUploadSessionLocation returns actual blob location to start/resume uploading blobs. // e.g. /v2//blobs/uploads/. func getBlobUploadSessionLocation(url *url.URL, sessionID string) string { diff --git a/pkg/api/routes_test.go b/pkg/api/routes_test.go index d9b7bc4e37..0fb2fa9a72 100644 --- a/pkg/api/routes_test.go +++ b/pkg/api/routes_test.go @@ -1513,6 +1513,79 @@ func TestRoutes(t *testing.T) { }) }) + Convey("ChangePassword Route", func() { + type testCase struct { + username string + reqBody api.ChangePasswordRequest + wantCode int + wantBody []byte + } + + testFn := func(testCase testCase) func() { + return func() { + userAc := reqCtx.NewUserAccessControl() + userAc.SetUsername(testCase.username) + ctx := userAc.DeriveContext(context.Background()) + + reqBody, err := json.Marshal(testCase.reqBody) + So(err, ShouldBeNil) + + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, + baseURL+constants.ChangePasswordPath, bytes.NewReader(reqBody)) + response := httptest.NewRecorder() + + rthdlr.ChangePassword(response, request) + + resp := response.Result() + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + So(resp.StatusCode, ShouldEqual, testCase.wantCode) + So(body, ShouldEqual, testCase.wantBody) + } + } + + Convey("successful password change", testFn(testCase{ + username: username, + reqBody: api.ChangePasswordRequest{ + OldPassword: password, + NewPassword: "new_password", + }, + wantCode: http.StatusOK, + wantBody: []byte("password changed"), + })) + + Convey("user is not found", testFn(testCase{ + username: "non-existing-user", + reqBody: api.ChangePasswordRequest{ + OldPassword: "old_password", + NewPassword: "new_password", + }, + wantCode: http.StatusNotFound, + wantBody: []byte(zerr.ErrUserIsNotFound.Error()), + })) + + Convey("old password is wrong", testFn(testCase{ + username: username, + reqBody: api.ChangePasswordRequest{ + OldPassword: "wrong_old_password", + NewPassword: "new_password", + }, + wantCode: http.StatusUnauthorized, + wantBody: []byte(zerr.ErrOldPasswordIsWrong.Error()), + })) + + Convey("new password is empty", testFn(testCase{ + username: username, + reqBody: api.ChangePasswordRequest{ + OldPassword: password, + NewPassword: "", + }, + wantCode: http.StatusBadRequest, + wantBody: []byte(zerr.ErrPasswordIsEmpty.Error()), + })) + }) + Convey("Helper functions", func() { testUpdateBlobUpload := func( query []struct{ k, v string }, diff --git a/swagger/docs.go b/swagger/docs.go index 1480c5905d..8e5c6b4898 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -1138,6 +1138,70 @@ const docTemplate = `{ } } }, + "/zot/auth/change_password": { + "post": { + "description": "Change user's password. Invalidates all user's other active sessions.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Change user's password", + "parameters": [ + { + "description": "Old password", + "name": "old_password", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "New password", + "name": "new_password", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "password changed", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "old password is incorrect", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "string" + } + } + } + } + }, "/zot/auth/logout": { "post": { "description": "Logout by removing current session", diff --git a/swagger/swagger.json b/swagger/swagger.json index d20d69d548..9c951e384f 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1129,6 +1129,70 @@ } } }, + "/zot/auth/change_password": { + "post": { + "description": "Change user's password. Invalidates all user's other active sessions.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Change user's password", + "parameters": [ + { + "description": "Old password", + "name": "old_password", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "New password", + "name": "new_password", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "password changed", + "schema": { + "type": "string" + } + }, + "400": { + "description": "bad request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "old password is incorrect", + "schema": { + "type": "string" + } + }, + "500": { + "description": "internal server error", + "schema": { + "type": "string" + } + } + } + } + }, "/zot/auth/logout": { "post": { "description": "Logout by removing current session", diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 6cc7add457..df959fb9f5 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -993,6 +993,48 @@ paths: schema: type: string summary: Create an API key for the current user + /zot/auth/change_password: + post: + consumes: + - application/json + description: Change user's password. Invalidates all user's other active sessions. + parameters: + - description: Old password + in: body + name: old_password + required: true + schema: + type: string + - description: New password + in: body + name: new_password + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: password changed + schema: + type: string + "400": + description: bad request + schema: + type: string + "401": + description: unauthorized + schema: + type: string + "403": + description: old password is incorrect + schema: + type: string + "500": + description: internal server error + schema: + type: string + summary: Change user's password /zot/auth/logout: post: consumes: