From 853cab121191a2cf4dd37c68149fc23b64235464 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Mon, 22 Nov 2021 18:42:45 -0800 Subject: Add user endpoints --- app/controllers/api_user.go | 181 +++++++++++++++++++++++++++++++++++++++ app/controllers/api_user_list.go | 56 ++++++++++++ app/middleware/perms_check.go | 20 +++++ app/models/user.go | 56 +++++++++--- cmd/web/server.go | 14 +++ 5 files changed, 315 insertions(+), 12 deletions(-) create mode 100644 app/controllers/api_user.go create mode 100644 app/controllers/api_user_list.go create mode 100644 app/middleware/perms_check.go diff --git a/app/controllers/api_user.go b/app/controllers/api_user.go new file mode 100644 index 0000000..df667db --- /dev/null +++ b/app/controllers/api_user.go @@ -0,0 +1,181 @@ +package controllers + +import ( + "context" + "net/http" + + "code.crute.us/mcrute/cloud-identity-broker/app/models" + + glecho "code.crute.us/mcrute/golib/echo" + "code.crute.us/mcrute/golib/echo/controller" + "github.com/labstack/echo/v4" +) + +type APIUserHandler struct { + Store models.UserStore +} + +func (h *APIUserHandler) Register(prefix string, r glecho.URLRouter, mw ...echo.MiddlewareFunc) { + // This resource did not exist in the V1 API and thus has no V1 + // representation. We use the default handlers for V1 because otherwise + // requests with V1 Accept headers would result in 406 Unacceptable errors. + gh := &controller.ContentTypeNegotiatingHandler{ + DefaultHandler: h.HandleGet, + Handlers: map[string]echo.HandlerFunc{ + contentTypeV1: h.HandleGet, + contentTypeV2: h.HandleGet, + }, + } + r.GET(prefix, gh.Handle, mw...) + + ph := &controller.ContentTypeNegotiatingHandler{ + DefaultHandler: h.HandlePut, + Handlers: map[string]echo.HandlerFunc{ + contentTypeV1: h.HandlePut, + contentTypeV2: h.HandlePut, + }, + } + r.PUT(prefix, ph.Handle, mw...) + + poh := &controller.ContentTypeNegotiatingHandler{ + DefaultHandler: h.HandlePost, + Handlers: map[string]echo.HandlerFunc{ + contentTypeV1: h.HandlePost, + contentTypeV2: h.HandlePost, + }, + } + r.POST(prefix, poh.Handle, mw...) + + r.DELETE(prefix, h.HandleDelete, mw...) +} + +func (h *APIUserHandler) HandleGet(c echo.Context) error { + u, err := h.Store.Get(context.Background(), c.Param("user")) + if err != nil { + return echo.ErrInternalServerError + } + + return c.JSON(http.StatusOK, u) +} + +func validateKeysAndTokens(in *models.User) error { + for k, v := range in.Keys { + if k != v.KeyId { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Key ID must match hash key.", + } + } + + if v.PrivateKey == nil && v.PublicKey == nil { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "One of public_key or private_key must be set", + } + } + + if v.PrivateKey != nil && v.PublicKey != nil { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Only one of public_key or private_key may be set", + } + } + } + + for k, v := range in.AuthTokens { + if k != v.Kind { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Token kind must match hash key.", + } + } + } + + return nil +} + +func (h *APIUserHandler) HandlePut(c echo.Context) error { + var in models.User + if err := c.Echo().JSONSerializer.Deserialize(c, &in); err != nil { + return echo.ErrBadRequest + } + + u, err := h.Store.Get(context.Background(), c.Param("user")) + if err != nil { + return echo.ErrInternalServerError + } + + if in.Username != u.Username { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Username can not be changed. Create a new user.", + } + } + + if in.Deleted != nil && u.Deleted == nil { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Use the DELETE method to delete a record", + } + } + + if err := validateKeysAndTokens(&in); err != nil { + return err + } + + err = h.Store.Put(context.Background(), &in) + if err != nil { + return echo.ErrInternalServerError + } + + return c.String(http.StatusNoContent, "") +} + +func (h *APIUserHandler) HandlePost(c echo.Context) error { + var in models.User + if err := c.Echo().JSONSerializer.Deserialize(c, &in); err != nil { + return echo.ErrBadRequest + } + + _, err := h.Store.Get(context.Background(), c.Param("user")) + if err == nil { + return &echo.HTTPError{ + Code: http.StatusConflict, + Message: "User with username already exists.", + } + } + + if in.Deleted != nil { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Can not create deleted user, set Deleted to null", + } + } + + if err := validateKeysAndTokens(&in); err != nil { + return err + } + + err = h.Store.Put(context.Background(), &in) + if err != nil { + return echo.ErrInternalServerError + } + + c.Response().Header().Add("Location", glecho.URLFor(c, "/api/user", in.Username).String()) + + return c.String(http.StatusCreated, "") +} + +func (h *APIUserHandler) HandleDelete(c echo.Context) error { + u, err := h.Store.Get(context.Background(), c.Param("user")) + if err != nil { + return echo.ErrInternalServerError + } + + err = h.Store.Delete(context.Background(), u) + if err != nil { + return echo.ErrInternalServerError + } + + return c.String(http.StatusNoContent, "") +} diff --git a/app/controllers/api_user_list.go b/app/controllers/api_user_list.go new file mode 100644 index 0000000..ba6dff5 --- /dev/null +++ b/app/controllers/api_user_list.go @@ -0,0 +1,56 @@ +package controllers + +import ( + "context" + "net/http" + "time" + + "code.crute.us/mcrute/cloud-identity-broker/app/models" + + glecho "code.crute.us/mcrute/golib/echo" + "code.crute.us/mcrute/golib/echo/controller" + "github.com/labstack/echo/v4" +) + +type jsonListUser struct { + Username string `bson:"_id" json:"key_id"` + IsAdmin bool `json:"is_admin"` + IsService bool `json:"is_service"` + SelfLink string `json:"self"` + Deleted *time.Time `json:"deleted,omitempty"` +} + +type APIUserListHandler struct { + store models.UserStore +} + +func NewAPIUserListHandler(s models.UserStore) echo.HandlerFunc { + al := &APIUserListHandler{store: s} + h := &controller.ContentTypeNegotiatingHandler{ + DefaultHandler: al.Handle, + Handlers: map[string]echo.HandlerFunc{ + contentTypeV2: al.Handle, + }, + } + return h.Handle +} + +func (h *APIUserListHandler) Handle(c echo.Context) error { + users, err := h.store.List(context.Background()) + if err != nil { + return echo.ErrInternalServerError + } + + out := map[string]*jsonListUser{} + for _, v := range users { + out[v.Username] = &jsonListUser{ + Username: v.Username, + IsAdmin: v.IsAdmin, + IsService: v.IsService, + SelfLink: glecho.URLFor(c, "/api/user", v.Username).String(), + Deleted: v.Deleted, + } + } + + return c.JSON(http.StatusOK, out) +} diff --git a/app/middleware/perms_check.go b/app/middleware/perms_check.go new file mode 100644 index 0000000..d118f5a --- /dev/null +++ b/app/middleware/perms_check.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" +) + +func RequireAdminPrivileges(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + p, err := GetAuthorizedPrincipal(c) + if err != nil { + return echo.ErrUnauthorized + } + + if !p.IsAdmin { + return echo.NotFoundHandler(c) + } + + return next(c) + } +} diff --git a/app/models/user.go b/app/models/user.go index 0cbd92d..2871380 100644 --- a/app/models/user.go +++ b/app/models/user.go @@ -2,8 +2,11 @@ package models import ( "context" + "time" "code.crute.us/mcrute/golib/db/mongodb" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" ) const userCol = "users" @@ -16,17 +19,21 @@ type UserStore interface { } type AuthToken struct { - Kind string - Token string - RefreshToken string + Kind string `json:"kind"` + Token string `json:"token"` + + // Do not expose refresh tokens in JSON as they are long-lived tokens that + // are harder to invalidate and thus rather security sensitive. + RefreshToken string `json:"-"` } type User struct { - Username string `bson:"_id"` - IsAdmin bool - IsService bool - Keys map[string]*SessionKey // kid -> key - AuthTokens map[string]*AuthToken // kind -> token + Username string `bson:"_id" json:"key_id"` + IsAdmin bool `json:"is_admin"` + IsService bool `json:"is_service"` + Keys map[string]*SessionKey `json:"keys,omitempty"` // kid -> key + AuthTokens map[string]*AuthToken `json:"auth_tokens,omitempty"` // kind -> token + Deleted *time.Time `json:"deleted,omitempty"` } // GCKeys garbage collects keys that are no longer valid @@ -64,21 +71,41 @@ func (u *User) AddToken(t *AuthToken) { type MongoDbUserStore struct { Db *mongodb.Mongo + + // ReturnDeleted will allow all methods to return deleted items. By default + // items where the Deleted field is set will not be returned. This should + // be the common cast for most code using this store but in some Admin + // use-cases it would be useful to show deleted accounts. + ReturnDeleted bool } func (s *MongoDbUserStore) List(ctx context.Context) ([]*User, error) { var out []*User - if err := s.Db.FindAll(ctx, userCol, &out); err != nil { + + filter := bson.M{} + if !s.ReturnDeleted { + filter["deleted"] = primitive.Null{} + } + + if err := s.Db.FindAllByFilter(ctx, userCol, filter, &out); err != nil { return nil, err } + return out, nil } func (s *MongoDbUserStore) Get(ctx context.Context, username string) (*User, error) { var u User - if err := s.Db.FindOneById(ctx, userCol, username, &u); err != nil { + + filter := bson.M{"_id": username} + if !s.ReturnDeleted { + filter["deleted"] = primitive.Null{} + } + + if err := s.Db.FindOneByFilter(ctx, userCol, filter, &u); err != nil { return nil, err } + return &u, nil } @@ -90,10 +117,15 @@ func (s *MongoDbUserStore) Put(ctx context.Context, u *User) error { } func (s *MongoDbUserStore) Delete(ctx context.Context, u *User) error { - if err := s.Db.DeleteOneById(ctx, userCol, u.Username); err != nil { + u, err := s.Get(ctx, u.Username) + if err != nil { return err } - return nil + + now := time.Now() + u.Deleted = &now + + return s.Put(ctx, u) } var _ UserStore = (*MongoDbUserStore)(nil) diff --git a/cmd/web/server.go b/cmd/web/server.go index c573244..5b2e025 100644 --- a/cmd/web/server.go +++ b/cmd/web/server.go @@ -108,6 +108,11 @@ func webMain(cfg app.Config, embeddedTemplates fs.FS, version string) { ReturnDeleted: true, } as := &models.MongoDbAccountStore{Db: mongo} + + adminUserStore := &models.MongoDbUserStore{ + Db: mongo, + ReturnDeleted: true, + } us := &models.MongoDbUserStore{Db: mongo} aws := &controllers.AWSAPI{Store: as} @@ -156,6 +161,15 @@ func webMain(cfg app.Config, embeddedTemplates fs.FS, version string) { AdminStore: adminAccountStore, }).Register("/:account", account) } + + user := api.Group("/user") + user.Use(middleware.RequireAdminPrivileges) + { + user.GET("", controllers.NewAPIUserListHandler(us)) + (&controllers.APIUserHandler{ + Store: adminUserStore, + }).Register("/:user", user) + } } s.GET("/favicon.ico", echo.NotFoundHandler) s.GET("/logout", controllers.LogoutHandler) -- cgit v1.2.3