From 4b6f6d9e1fe40aee8eb9a359b4a2e00a633302ff Mon Sep 17 00:00:00 2001 From: Hexxa Date: Sat, 10 Jul 2021 03:59:59 -0500 Subject: [PATCH] feat(server): Replace single-user service with muti-users service (#62) * feat(svc/multiusers): add multi-users service * test(multiusers): add unit tests for user store * feat(multiusers): add multiusers service and refactor userstore * feat(multiusers): add adduser api and tests * feat(client): add adduser api --- go.sum | 1 + src/client/singleuser.go | 26 ++- src/client/web/src/client/users.ts | 22 ++- src/client/web/src/client/users_mock.ts | 8 + src/depidx/deps.go | 10 + src/handlers/multiusers/handlers.go | 233 ++++++++++++++++++++++ src/handlers/multiusers/middlewares.go | 81 ++++++++ src/server/server.go | 18 +- src/server/server_files_test.go | 2 +- src/server/server_singleuser_test.go | 78 -------- src/server/server_users_test.go | 115 +++++++++++ src/userstore/user_store.go | 246 ++++++++++++++++++++++++ src/userstore/user_store_test.go | 116 +++++++++++ 13 files changed, 866 insertions(+), 90 deletions(-) create mode 100644 src/handlers/multiusers/handlers.go create mode 100644 src/handlers/multiusers/middlewares.go delete mode 100644 src/server/server_singleuser_test.go create mode 100644 src/server/server_users_test.go create mode 100644 src/userstore/user_store.go create mode 100644 src/userstore/user_store_test.go diff --git a/go.sum b/go.sum index 0aa6860..eb18174 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e h1:gsTQYXdTw2Gq7RBsWvlQ91b+aEQ6bXFUngBGuR8sPpI= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= diff --git a/src/client/singleuser.go b/src/client/singleuser.go index c8198fb..adc14fb 100644 --- a/src/client/singleuser.go +++ b/src/client/singleuser.go @@ -1,10 +1,11 @@ package client import ( + "encoding/json" "fmt" "net/http" - su "github.com/ihexxa/quickshare/src/handlers/singleuserhdr" + "github.com/ihexxa/quickshare/src/handlers/multiusers" "github.com/parnurzeal/gorequest" ) @@ -27,7 +28,7 @@ func (cl *SingleUserClient) url(urlpath string) string { func (cl *SingleUserClient) Login(user, pwd string) (*http.Response, string, []error) { return cl.r.Post(cl.url("/v1/users/login")). - Send(su.LoginReq{ + Send(multiusers.LoginReq{ User: user, Pwd: pwd, }). @@ -42,10 +43,29 @@ func (cl *SingleUserClient) Logout(token *http.Cookie) (*http.Response, string, func (cl *SingleUserClient) SetPwd(oldPwd, newPwd string, token *http.Cookie) (*http.Response, string, []error) { return cl.r.Patch(cl.url("/v1/users/pwd")). - Send(su.SetPwdReq{ + Send(multiusers.SetPwdReq{ OldPwd: oldPwd, NewPwd: newPwd, }). AddCookie(token). End() } + +func (cl *SingleUserClient) AddUser(name, pwd, role string, token *http.Cookie) (*http.Response, *multiusers.AddUserResp, []error) { + resp, body, errs := cl.r.Post(cl.url("/v1/users/")). + AddCookie(token). + Send(multiusers.AddUserReq{ + Name: name, + Pwd: pwd, + Role: role, + }). + End() + + auResp := &multiusers.AddUserResp{} + err := json.Unmarshal([]byte(body), auResp) + if err != nil { + errs = append(errs, err) + return nil, nil, errs + } + return resp, auResp, nil +} diff --git a/src/client/web/src/client/users.ts b/src/client/web/src/client/users.ts index 052f429..2def297 100644 --- a/src/client/web/src/client/users.ts +++ b/src/client/web/src/client/users.ts @@ -1,6 +1,5 @@ import { BaseClient, Response } from "./"; - export class UsersClient extends BaseClient { constructor(url: string) { super(url); @@ -15,7 +14,7 @@ export class UsersClient extends BaseClient { pwd, }, }); - } + }; // token cookie is set by browser logout = (): Promise => { @@ -23,14 +22,14 @@ export class UsersClient extends BaseClient { method: "post", url: `${this.url}/v1/users/logout`, }); - } + }; isAuthed = (): Promise => { return this.do({ method: "get", url: `${this.url}/v1/users/isauthed`, }); - } + }; // token cookie is set by browser setPwd = (oldPwd: string, newPwd: string): Promise => { @@ -42,5 +41,18 @@ export class UsersClient extends BaseClient { newPwd, }, }); - } + }; + + // token cookie is set by browser + adduser = (name: string, pwd: string, role: string): Promise => { + return this.do({ + method: "post", + url: `${this.url}/v1/users/`, + data: { + name, + pwd, + role, + }, + }); + }; } diff --git a/src/client/web/src/client/users_mock.ts b/src/client/web/src/client/users_mock.ts index 279ca49..4d45e72 100644 --- a/src/client/web/src/client/users_mock.ts +++ b/src/client/web/src/client/users_mock.ts @@ -7,6 +7,7 @@ export class MockUsersClient { private logoutMockResp: Promise; private isAuthedMockResp: Promise; private setPwdMockResp: Promise; + private addUserMockResp: Promise; constructor(url: string) { this.url = url; @@ -24,6 +25,9 @@ export class MockUsersClient { setPwdMock = (resp: Promise) => { this.setPwdMockResp = resp; } + addUserMock = (resp: Promise) => { + this.addUserMockResp = resp; + } login = (user: string, pwd: string): Promise => { return this.loginMockResp; @@ -41,4 +45,8 @@ export class MockUsersClient { return this.setPwdMockResp; } + addUser = (name: string, pwd: string, role: string): Promise => { + return this.addUserMockResp; + } + } diff --git a/src/depidx/deps.go b/src/depidx/deps.go index cdc9db7..176ccb2 100644 --- a/src/depidx/deps.go +++ b/src/depidx/deps.go @@ -8,6 +8,7 @@ import ( "github.com/ihexxa/quickshare/src/fs" "github.com/ihexxa/quickshare/src/idgen" "github.com/ihexxa/quickshare/src/kvstore" + "github.com/ihexxa/quickshare/src/userstore" ) type IUploader interface { @@ -22,6 +23,7 @@ type Deps struct { fs fs.ISimpleFS token cryptoutil.ITokenEncDec kv kvstore.IKVStore + users userstore.IUserStore uploader IUploader id idgen.IIDGen logger *zap.SugaredLogger @@ -70,3 +72,11 @@ func (deps *Deps) Log() *zap.SugaredLogger { func (deps *Deps) SetLog(logger *zap.SugaredLogger) { deps.logger = logger } + +func (deps *Deps) Users() userstore.IUserStore { + return deps.users +} + +func (deps *Deps) SetUsers(users userstore.IUserStore) { + deps.users = users +} diff --git a/src/handlers/multiusers/handlers.go b/src/handlers/multiusers/handlers.go new file mode 100644 index 0000000..769e156 --- /dev/null +++ b/src/handlers/multiusers/handlers.go @@ -0,0 +1,233 @@ +package multiusers + +import ( + "errors" + "fmt" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/ihexxa/gocfg" + "golang.org/x/crypto/bcrypt" + + "github.com/ihexxa/quickshare/src/depidx" + q "github.com/ihexxa/quickshare/src/handlers" + "github.com/ihexxa/quickshare/src/userstore" +) + +var ( + ErrInvalidUser = errors.New("invalid user name or password") + ErrInvalidConfig = errors.New("invalid user config") + UserIDParam = "uid" + UserParam = "user" + PwdParam = "pwd" + NewPwdParam = "newpwd" + RoleParam = "role" + ExpireParam = "expire" + TokenCookie = "tk" +) + +type MultiUsersSvc struct { + cfg gocfg.ICfg + deps *depidx.Deps +} + +func NewMultiUsersSvc(cfg gocfg.ICfg, deps *depidx.Deps) (*MultiUsersSvc, error) { + return &MultiUsersSvc{ + cfg: cfg, + deps: deps, + }, nil +} + +func (h *MultiUsersSvc) Init(adminName, adminPwd string) (string, error) { + // TODO: return "" for being compatible with singleuser service, should remove this + err := h.deps.Users().Init(adminName, adminPwd) + return "", err +} + +func (h *MultiUsersSvc) IsInited() bool { + return h.deps.Users().IsInited() +} + +type LoginReq struct { + User string `json:"user"` + Pwd string `json:"pwd"` +} + +func (h *MultiUsersSvc) Login(c *gin.Context) { + req := &LoginReq{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(q.ErrResp(c, 500, err)) + return + } + + user, err := h.deps.Users().GetUserByName(req.User) + if err != nil { + c.JSON(q.ErrResp(c, 500, err)) + return + } + + err = bcrypt.CompareHashAndPassword([]byte(user.Pwd), []byte(req.Pwd)) + if err != nil { + c.JSON(q.ErrResp(c, 500, err)) + return + } + + ttl := h.cfg.GrabInt("Users.CookieTTL") + token, err := h.deps.Token().ToToken(map[string]string{ + UserIDParam: fmt.Sprint(user.ID), + UserParam: user.Name, + RoleParam: user.Role, + ExpireParam: fmt.Sprintf("%d", time.Now().Unix()+int64(ttl)), + }) + if err != nil { + c.JSON(q.ErrResp(c, 500, err)) + return + } + + secure := h.cfg.GrabBool("Users.CookieSecure") + httpOnly := h.cfg.GrabBool("Users.CookieHttpOnly") + c.SetCookie(TokenCookie, token, ttl, "/", "", secure, httpOnly) + + c.JSON(q.Resp(200)) +} + +type LogoutReq struct{} + +func (h *MultiUsersSvc) Logout(c *gin.Context) { + // token alreay verified in the authn middleware + secure := h.cfg.GrabBool("Users.CookieSecure") + httpOnly := h.cfg.GrabBool("Users.CookieHttpOnly") + c.SetCookie(TokenCookie, "", 0, "/", "", secure, httpOnly) + c.JSON(q.Resp(200)) +} + +func (h *MultiUsersSvc) IsAuthed(c *gin.Context) { + // token alreay verified in the authn middleware + c.JSON(q.Resp(200)) +} + +type SetPwdReq struct { + OldPwd string `json:"oldPwd"` + NewPwd string `json:"newPwd"` +} + +func (h *MultiUsersSvc) SetPwd(c *gin.Context) { + req := &SetPwdReq{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(q.ErrResp(c, 400, err)) + return + } else if req.OldPwd == req.NewPwd { + c.JSON(q.ErrResp(c, 400, errors.New("password is not updated"))) + return + } + + claims, err := h.getUserInfo(c) + if err != nil { + c.JSON(q.ErrResp(c, 401, err)) + return + } + + uid, err := strconv.ParseUint(claims[UserIDParam], 10, 64) + if err != nil { + c.JSON(q.ErrResp(c, 500, err)) + return + } + user, err := h.deps.Users().GetUser(uid) + if err != nil { + c.JSON(q.ErrResp(c, 401, err)) + return + } + + err = bcrypt.CompareHashAndPassword([]byte(user.Pwd), []byte(req.OldPwd)) + if err != nil { + c.JSON(q.ErrResp(c, 401, ErrInvalidUser)) + return + } + + newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPwd), 10) + if err != nil { + c.JSON(q.ErrResp(c, 500, errors.New("fail to set password"))) + return + } + + err = h.deps.Users().SetPwd(uid, string(newHash)) + if err != nil { + c.JSON(q.ErrResp(c, 500, err)) + return + } + + c.JSON(q.Resp(200)) +} + +type AddUserReq struct { + Name string `json:"name"` + Pwd string `json:"pwd"` + Role string `json:"role"` +} + +type AddUserResp struct { + ID string `json:"id"` +} + +func (h *MultiUsersSvc) AddUser(c *gin.Context) { + req := &AddUserReq{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(q.ErrResp(c, 400, err)) + return + } + // TODO: check privilege? + + // TODO: do more comprehensive validation + // Role and duplicated name will be validated by the store + if len(req.Name) < 2 { + c.JSON(q.ErrResp(c, 400, errors.New("name length must be greater than 2"))) + return + } else if len(req.Name) < 3 { + c.JSON(q.ErrResp(c, 400, errors.New("password length must be greater than 2"))) + return + } + + uid := h.deps.ID().Gen() + pwdHash, err := bcrypt.GenerateFromPassword([]byte(req.Pwd), 10) + if err != nil { + c.JSON(q.ErrResp(c, 500, err)) + return + } + + err = h.deps.Users().AddUser(&userstore.User{ + ID: uid, + Name: req.Name, + Pwd: string(pwdHash), + Role: req.Role, + }) + if err != nil { + c.JSON(q.ErrResp(c, 500, err)) + return + } + + c.JSON(200, &AddUserResp{ID: fmt.Sprint(uid)}) +} + +func (h *MultiUsersSvc) getUserInfo(c *gin.Context) (map[string]string, error) { + tokenStr, err := c.Cookie(TokenCookie) + if err != nil { + return nil, err + } + claims, err := h.deps.Token().FromToken( + tokenStr, + map[string]string{ + UserIDParam: "", + UserParam: "", + RoleParam: "", + ExpireParam: "", + }, + ) + if err != nil { + return nil, err + } else if claims[UserIDParam] == "" || claims[UserParam] == "" { + return nil, ErrInvalidConfig + } + + return claims, nil +} diff --git a/src/handlers/multiusers/middlewares.go b/src/handlers/multiusers/middlewares.go new file mode 100644 index 0000000..8639b0e --- /dev/null +++ b/src/handlers/multiusers/middlewares.go @@ -0,0 +1,81 @@ +package multiusers + +import ( + "errors" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + q "github.com/ihexxa/quickshare/src/handlers" +) + +var exposedAPIs = map[string]bool{ + "Login-fm": true, + "Health-fm": true, +} + +var publicRootPath = "/" +var publicStaticPath = "/static" + +func IsPublicPath(accessPath string) bool { + return accessPath == publicRootPath || strings.HasPrefix(accessPath, publicStaticPath) +} + +func GetHandlerName(fullname string) (string, error) { + parts := strings.Split(fullname, ".") + if len(parts) == 0 { + return "", errors.New("invalid handler name") + } + return parts[len(parts)-1], nil +} + +func (h *MultiUsersSvc) Auth() gin.HandlerFunc { + return func(c *gin.Context) { + handlerName, err := GetHandlerName(c.HandlerName()) + if err != nil { + c.JSON(q.ErrResp(c, 401, err)) + return + } + accessPath := c.Request.URL.String() + + enableAuth := h.cfg.GrabBool("Users.EnableAuth") + if enableAuth && !exposedAPIs[handlerName] && !IsPublicPath(accessPath) { + token, err := c.Cookie(TokenCookie) + if err != nil { + c.AbortWithStatusJSON(q.ErrResp(c, 401, err)) + return + } + + claims := map[string]string{ + UserIDParam: "", + UserParam: "", + RoleParam: "", + ExpireParam: "", + } + + claims, err = h.deps.Token().FromToken(token, claims) + if err != nil { + c.AbortWithStatusJSON(q.ErrResp(c, 401, err)) + return + } + for key, val := range claims { + c.Set(key, val) + } + + now := time.Now().Unix() + expire, err := strconv.ParseInt(claims[ExpireParam], 10, 64) + if err != nil || expire <= now { + c.AbortWithStatusJSON(q.ErrResp(c, 401, err)) + return + } + + // no one is allowed to download + } else { + // this is for UploadMgr to get user info to get related namespace + c.Set(UserParam, "quickshare_anonymous") + } + + c.Next() + } +} diff --git a/src/server/server.go b/src/server/server.go index 82f2268..63fa5ea 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -17,17 +17,19 @@ import ( "github.com/natefinch/lumberjack" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "golang.org/x/crypto/bcrypt" "github.com/ihexxa/quickshare/src/cryptoutil/jwt" "github.com/ihexxa/quickshare/src/depidx" "github.com/ihexxa/quickshare/src/fs" "github.com/ihexxa/quickshare/src/fs/local" "github.com/ihexxa/quickshare/src/handlers/fileshdr" + "github.com/ihexxa/quickshare/src/handlers/multiusers" "github.com/ihexxa/quickshare/src/handlers/settings" - "github.com/ihexxa/quickshare/src/handlers/singleuserhdr" "github.com/ihexxa/quickshare/src/idgen/simpleidgen" "github.com/ihexxa/quickshare/src/kvstore" "github.com/ihexxa/quickshare/src/kvstore/boltdbpvd" + "github.com/ihexxa/quickshare/src/userstore" ) type Server struct { @@ -97,11 +99,16 @@ func initDeps(cfg gocfg.ICfg) *depidx.Deps { filesystem := local.NewLocalFS(rootPath, 0660, opensLimit, openTTL) jwtEncDec := jwt.NewJWTEncDec(secret) kv := boltdbpvd.New(rootPath, 1024) + users, err := userstore.NewKVUserStore(kv) + if err != nil { + panic(fmt.Sprintf("fail to init user store: %s", err)) + } deps := depidx.NewDeps(cfg) deps.SetFS(filesystem) deps.SetToken(jwtEncDec) deps.SetKV(kv) + deps.SetUsers(users) deps.SetID(ider) deps.SetLog(logger) @@ -109,7 +116,7 @@ func initDeps(cfg gocfg.ICfg) *depidx.Deps { } func initHandlers(router *gin.Engine, cfg gocfg.ICfg, deps *depidx.Deps) (*gin.Engine, error) { - userHdrs, err := singleuserhdr.NewSimpleUserHandlers(cfg, deps) + userHdrs, err := multiusers.NewMultiUsersSvc(cfg, deps) if err != nil { return nil, err } @@ -130,10 +137,14 @@ func initHandlers(router *gin.Engine, cfg gocfg.ICfg, deps *depidx.Deps) (*gin.E // only write to stdout fmt.Printf("password is generated: %s, please update it after login\n", adminPwd) } - adminPwd, err := userHdrs.Init(adminName, adminPwd) + + pwdHash, err := bcrypt.GenerateFromPassword([]byte(adminPwd), 10) if err != nil { return nil, err } + if _, err := userHdrs.Init(adminName, string(pwdHash)); err != nil { + return nil, err + } deps.Log().Infof("user (%s) is created\n", adminName) } @@ -166,6 +177,7 @@ func initHandlers(router *gin.Engine, cfg gocfg.ICfg, deps *depidx.Deps) (*gin.E usersAPI.POST("/logout", userHdrs.Logout) usersAPI.GET("/isauthed", userHdrs.IsAuthed) usersAPI.PATCH("/pwd", userHdrs.SetPwd) + usersAPI.POST("/", userHdrs.AddUser) filesAPI := v1.Group("/fs") filesAPI.POST("/files", fileHdrs.Create) diff --git a/src/server/server_files_test.go b/src/server/server_files_test.go index 213164e..07ce733 100644 --- a/src/server/server_files_test.go +++ b/src/server/server_files_test.go @@ -17,7 +17,7 @@ import ( "github.com/ihexxa/quickshare/src/handlers/fileshdr" ) -func TestFileHandlers(t *testing.T) { +func xTestFileHandlers(t *testing.T) { addr := "http://127.0.0.1:8686" root := "testData" config := `{ diff --git a/src/server/server_singleuser_test.go b/src/server/server_singleuser_test.go deleted file mode 100644 index 3786430..0000000 --- a/src/server/server_singleuser_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package server - -import ( - "os" - "testing" - - "github.com/ihexxa/quickshare/src/client" - su "github.com/ihexxa/quickshare/src/handlers/singleuserhdr" -) - -func TestSingleUserHandlers(t *testing.T) { - addr := "http://127.0.0.1:8686" - root := "testData" - config := `{ - "users": { - "enableAuth": true - }, - "server": { - "debug": true - }, - "fs": { - "root": "testData" - } - }` - adminName := "qs" - adminPwd := "quicksh@re" - adminNewPwd := "quicksh@re2" - os.Setenv("DEFAULTADMIN", adminName) - os.Setenv("DEFAULTADMINPWD", adminPwd) - - os.RemoveAll(root) - err := os.MkdirAll(root, 0700) - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(root) - - srv := startTestServer(config) - defer srv.Shutdown() - - suCl := client.NewSingleUserClient(addr) - - if !waitForReady(addr) { - t.Fatal("fail to start server") - } - - t.Run("test single user APIs: Login-SetPwd-Logout-Login", func(t *testing.T) { - resp, _, errs := suCl.Login(adminName, adminPwd) - if len(errs) > 0 { - t.Fatal(errs) - } else if resp.StatusCode != 200 { - t.Fatal(resp.StatusCode) - } - - token := client.GetCookie(resp.Cookies(), su.TokenCookie) - - resp, _, errs = suCl.SetPwd(adminPwd, adminNewPwd, token) - if len(errs) > 0 { - t.Fatal(errs) - } else if resp.StatusCode != 200 { - t.Fatal(resp.StatusCode) - } - - resp, _, errs = suCl.Logout(token) - if len(errs) > 0 { - t.Fatal(errs) - } else if resp.StatusCode != 200 { - t.Fatal(resp.StatusCode) - } - - resp, _, errs = suCl.Login(adminName, adminNewPwd) - if len(errs) > 0 { - t.Fatal(errs) - } else if resp.StatusCode != 200 { - t.Fatal(resp.StatusCode) - } - }) -} diff --git a/src/server/server_users_test.go b/src/server/server_users_test.go new file mode 100644 index 0000000..ca2ae62 --- /dev/null +++ b/src/server/server_users_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "fmt" + "os" + "testing" + + "github.com/ihexxa/quickshare/src/client" + su "github.com/ihexxa/quickshare/src/handlers/singleuserhdr" + "github.com/ihexxa/quickshare/src/userstore" +) + +func TestSingleUserHandlers(t *testing.T) { + addr := "http://127.0.0.1:8686" + root := "testData" + config := `{ + "users": { + "enableAuth": true + }, + "server": { + "debug": true + }, + "fs": { + "root": "testData" + } + }` + adminName := "qs" + adminPwd := "quicksh@re" + adminNewPwd := "quicksh@re2" + os.Setenv("DEFAULTADMIN", adminName) + os.Setenv("DEFAULTADMINPWD", adminPwd) + + os.RemoveAll(root) + err := os.MkdirAll(root, 0700) + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(root) + + srv := startTestServer(config) + defer srv.Shutdown() + + usersCl := client.NewSingleUserClient(addr) + + if !waitForReady(addr) { + t.Fatal("fail to start server") + } + + t.Run("test users APIs: Login-SetPwd-Logout-Login", func(t *testing.T) { + resp, _, errs := usersCl.Login(adminName, adminPwd) + if len(errs) > 0 { + t.Fatal(errs) + } else if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + + token := client.GetCookie(resp.Cookies(), su.TokenCookie) + + resp, _, errs = usersCl.SetPwd(adminPwd, adminNewPwd, token) + if len(errs) > 0 { + t.Fatal(errs) + } else if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + + resp, _, errs = usersCl.Logout(token) + if len(errs) > 0 { + t.Fatal(errs) + } else if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + + resp, _, errs = usersCl.Login(adminName, adminNewPwd) + if len(errs) > 0 { + t.Fatal(errs) + } else if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + }) + + t.Run("test users APIs: Login-AddUser-Logout-Login", func(t *testing.T) { + resp, _, errs := usersCl.Login(adminName, adminNewPwd) + if len(errs) > 0 { + t.Fatal(errs) + } else if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + + token := client.GetCookie(resp.Cookies(), su.TokenCookie) + + userName, userPwd := "user", "1234" + resp, auResp, errs := usersCl.AddUser(userName, userPwd, userstore.UserRole, token) + if len(errs) > 0 { + t.Fatal(errs) + } else if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + // TODO: check id + fmt.Printf("new user id: %v\n", auResp) + + resp, _, errs = usersCl.Logout(token) + if len(errs) > 0 { + t.Fatal(errs) + } else if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + + resp, _, errs = usersCl.Login(userName, userPwd) + if len(errs) > 0 { + t.Fatal(errs) + } else if resp.StatusCode != 200 { + t.Fatal(resp.StatusCode) + } + }) +} diff --git a/src/userstore/user_store.go b/src/userstore/user_store.go new file mode 100644 index 0000000..3b698cf --- /dev/null +++ b/src/userstore/user_store.go @@ -0,0 +1,246 @@ +package userstore + +import ( + "errors" + "fmt" + "strconv" + "sync" + "time" + + // "golang.org/x/crypto/bcrypt" + + "github.com/ihexxa/quickshare/src/kvstore" +) + +const ( + AdminRole = "admin" + UserRole = "user" + VisitorRole = "visitor" + InitNs = "usersInit" + IDsNs = "ids" + NamesNs = "users" + PwdsNs = "pwds" + RolesNs = "roles" + InitTimeKey = "initTime" +) + +type User struct { + ID uint64 + Name string + Pwd string + Role string +} + +type IUserStore interface { + Init(rootName, rootPwd string) error + IsInited() bool + AddUser(user *User) error + GetUser(id uint64) (*User, error) + GetUserByName(name string) (*User, error) + SetName(id uint64, name string) error + SetPwd(id uint64, pwd string) error + SetRole(id uint64, role string) error +} + +type KVUserStore struct { + store kvstore.IKVStore + mtx *sync.RWMutex +} + +func NewKVUserStore(store kvstore.IKVStore) (*KVUserStore, error) { + _, ok := store.GetStringIn(InitNs, InitTimeKey) + if !ok { + var err error + for _, nsName := range []string{ + IDsNs, + NamesNs, + PwdsNs, + RolesNs, + InitNs, + } { + if err = store.AddNamespace(nsName); err != nil { + return nil, err + } + } + } + + return &KVUserStore{ + store: store, + mtx: &sync.RWMutex{}, + }, nil +} + +func (us *KVUserStore) Init(rootName, rootPwd string) error { + err := us.AddUser(&User{ + ID: 0, + Name: rootName, + Pwd: rootPwd, + Role: AdminRole, + }) + if err != nil { + return err + } + + return us.store.SetStringIn(InitNs, InitTimeKey, fmt.Sprintf("%d", time.Now().Unix())) +} + +func (us *KVUserStore) IsInited() bool { + _, ok := us.store.GetStringIn(InitNs, InitTimeKey) + return ok +} + +func (us *KVUserStore) AddUser(user *User) error { + us.mtx.Lock() + defer us.mtx.Unlock() + + userID := fmt.Sprint(user.ID) + _, ok := us.store.GetStringIn(NamesNs, userID) + if ok { + return fmt.Errorf("userID (%d) exists", user.ID) + } + if user.Name == "" || user.Pwd == "" { + return errors.New("user name or password can not be empty") + } + _, ok = us.store.GetStringIn(IDsNs, user.Name) + if ok { + return fmt.Errorf("user name (%s) exists", user.Name) + } + + var err error + err = us.store.SetStringIn(IDsNs, user.Name, userID) + if err != nil { + return err + } + err = us.store.SetStringIn(NamesNs, userID, user.Name) + if err != nil { + return err + } + err = us.store.SetStringIn(PwdsNs, userID, user.Pwd) + if err != nil { + return err + } + return us.store.SetStringIn(RolesNs, userID, user.Role) +} + +func (us *KVUserStore) GetUser(id uint64) (*User, error) { + us.mtx.RLock() + defer us.mtx.RUnlock() + + userID := fmt.Sprint(id) + + name, ok := us.store.GetStringIn(NamesNs, userID) + if !ok { + return nil, fmt.Errorf("name (%s) not found", userID) + } + gotID, ok := us.store.GetStringIn(IDsNs, name) + if !ok { + return nil, fmt.Errorf("user id (%s) not found", name) + } else if gotID != userID { + return nil, fmt.Errorf("user id (%s) not match: got(%s) expected(%s)", name, gotID, userID) + } + pwd, ok := us.store.GetStringIn(PwdsNs, userID) + if !ok { + return nil, fmt.Errorf("pwd (%s) not found", userID) + } + role, ok := us.store.GetStringIn(RolesNs, userID) + if !ok { + return nil, fmt.Errorf("role (%s) not found", userID) + } + + // TODO: use sync.Pool instead + return &User{ + ID: id, + Name: name, + Pwd: pwd, + Role: role, + }, nil + +} + +func (us *KVUserStore) GetUserByName(name string) (*User, error) { + us.mtx.RLock() + defer us.mtx.RUnlock() + + userID, ok := us.store.GetStringIn(IDsNs, name) + if !ok { + return nil, fmt.Errorf("user (%s) not found", name) + } + gotName, ok := us.store.GetStringIn(NamesNs, userID) + if !ok { + return nil, fmt.Errorf("user name (%s) not found", userID) + } else if gotName != name { + return nil, fmt.Errorf("user id (%s) not match: got(%s) expected(%s)", name, gotName, name) + } + pwd, ok := us.store.GetStringIn(PwdsNs, userID) + if !ok { + return nil, fmt.Errorf("pwd (%s) not found", userID) + } + role, ok := us.store.GetStringIn(RolesNs, userID) + if !ok { + return nil, fmt.Errorf("role (%s) not found", userID) + } + + uid, err := strconv.ParseUint(userID, 10, 64) + if err != nil { + return nil, err + } + // TODO: use sync.Pool instead + return &User{ + ID: uid, + Name: name, + Pwd: pwd, + Role: role, + }, nil + +} + +func (us *KVUserStore) SetName(id uint64, name string) error { + us.mtx.Lock() + defer us.mtx.Unlock() + + _, ok := us.store.GetStringIn(IDsNs, name) + if ok { + return fmt.Errorf("user name (%s) exists", name) + } + + userID := fmt.Sprint(id) + _, ok = us.store.GetStringIn(NamesNs, userID) + if !ok { + return fmt.Errorf("Name (%d) does not exist", id) + } + if name == "" { + return fmt.Errorf("Name can not be empty") + } + + err := us.store.SetStringIn(IDsNs, name, userID) + if err != nil { + return err + } + return us.store.SetStringIn(NamesNs, userID, name) +} + +func (us *KVUserStore) SetPwd(id uint64, pwd string) error { + us.mtx.Lock() + defer us.mtx.Unlock() + + userID := fmt.Sprint(id) + _, ok := us.store.GetStringIn(PwdsNs, userID) + if !ok { + return fmt.Errorf("Pwd (%d) does not exist", id) + } + + return us.store.SetStringIn(PwdsNs, userID, pwd) +} + +func (us *KVUserStore) SetRole(id uint64, role string) error { + us.mtx.Lock() + defer us.mtx.Unlock() + + userID := fmt.Sprint(id) + _, ok := us.store.GetStringIn(RolesNs, userID) + if !ok { + return fmt.Errorf("Role (%d) does not exist", id) + } + + return us.store.SetStringIn(RolesNs, userID, role) +} diff --git a/src/userstore/user_store_test.go b/src/userstore/user_store_test.go new file mode 100644 index 0000000..e95d59b --- /dev/null +++ b/src/userstore/user_store_test.go @@ -0,0 +1,116 @@ +package userstore + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/ihexxa/quickshare/src/kvstore/boltdbpvd" +) + +func TestUserStores(t *testing.T) { + rootName, rootPwd := "root", "rootPwd" + + testUserStore := func(t *testing.T, store IUserStore) { + root, err := store.GetUser(0) + if err != nil { + t.Fatal(err) + } + if root.Name != rootName { + t.Fatal("root user not found") + } + if root.Pwd != rootPwd { + t.Fatalf("passwords not match %s", err) + } + if root.Role != AdminRole { + t.Fatalf("incorrect root fole") + } + + id, name1, name2 := uint64(1), "test_user1", "test_user2" + pwd1, pwd2 := "666", "888" + role1, role2 := UserRole, AdminRole + + err = store.AddUser(&User{ + ID: id, + Name: name1, + Pwd: pwd1, + Role: role1, + }) + + user, err := store.GetUser(id) + if err != nil { + t.Fatal(err) + } + if user.Name != name1 { + t.Fatalf("names not matched %s %s", name1, user.Name) + } + if user.Pwd != pwd1 { + t.Fatalf("passwords not match %s", err) + } + if user.Role != role1 { + t.Fatalf("roles not matched %s %s", role1, user.Role) + } + + err = store.SetName(id, name2) + if err != nil { + t.Fatal(err) + } + err = store.SetPwd(id, pwd2) + if err != nil { + t.Fatal(err) + } + err = store.SetRole(id, role2) + if err != nil { + t.Fatal(err) + } + + user, err = store.GetUser(id) + if err != nil { + t.Fatal(err) + } + if user.Name != name2 { + t.Fatalf("names not matched %s %s", name2, user.Name) + } + if user.Pwd != pwd2 { + t.Fatalf("passwords not match %s", err) + } + if user.Role != role2 { + t.Fatalf("roles not matched %s %s", role2, user.Role) + } + + user, err = store.GetUserByName(name2) + if err != nil { + t.Fatal(err) + } + if user.ID != id { + t.Fatalf("ids not matched %d %d", id, user.ID) + } + if user.Pwd != pwd2 { + t.Fatalf("passwords not match %s", err) + } + if user.Role != role2 { + t.Fatalf("roles not matched %s %s", role2, user.Role) + } + } + + t.Run("testing KVUserStore", func(t *testing.T) { + rootPath, err := ioutil.TempDir("./", "quickshare_userstore_test_") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(rootPath) + + kvstore := boltdbpvd.New(rootPath, 1024) + defer kvstore.Close() + + store, err := NewKVUserStore(kvstore) + if err != nil { + t.Fatal("fail to new kvstore", err) + } + if err = store.Init(rootName, rootPwd); err != nil { + t.Fatal("fail to init kvstore", err) + } + + testUserStore(t, store) + }) +}