diff options
author | Mike Crute <mike@crute.us> | 2021-11-22 18:42:45 -0800 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2021-11-22 18:42:45 -0800 |
commit | 853cab121191a2cf4dd37c68149fc23b64235464 (patch) | |
tree | eb8e0ceb21daa457e7bd98e12379c6706379c26c | |
parent | 22819ad3543b6bad4f6efcedbebb8437292cae3b (diff) | |
download | cloud-identity-broker-853cab121191a2cf4dd37c68149fc23b64235464.tar.bz2 cloud-identity-broker-853cab121191a2cf4dd37c68149fc23b64235464.tar.xz cloud-identity-broker-853cab121191a2cf4dd37c68149fc23b64235464.zip |
Add user endpoints
-rw-r--r-- | app/controllers/api_user.go | 181 | ||||
-rw-r--r-- | app/controllers/api_user_list.go | 56 | ||||
-rw-r--r-- | app/middleware/perms_check.go | 20 | ||||
-rw-r--r-- | app/models/user.go | 56 | ||||
-rw-r--r-- | cmd/web/server.go | 14 |
5 files changed, 315 insertions, 12 deletions
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 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "net/http" | ||
6 | |||
7 | "code.crute.us/mcrute/cloud-identity-broker/app/models" | ||
8 | |||
9 | glecho "code.crute.us/mcrute/golib/echo" | ||
10 | "code.crute.us/mcrute/golib/echo/controller" | ||
11 | "github.com/labstack/echo/v4" | ||
12 | ) | ||
13 | |||
14 | type APIUserHandler struct { | ||
15 | Store models.UserStore | ||
16 | } | ||
17 | |||
18 | func (h *APIUserHandler) Register(prefix string, r glecho.URLRouter, mw ...echo.MiddlewareFunc) { | ||
19 | // This resource did not exist in the V1 API and thus has no V1 | ||
20 | // representation. We use the default handlers for V1 because otherwise | ||
21 | // requests with V1 Accept headers would result in 406 Unacceptable errors. | ||
22 | gh := &controller.ContentTypeNegotiatingHandler{ | ||
23 | DefaultHandler: h.HandleGet, | ||
24 | Handlers: map[string]echo.HandlerFunc{ | ||
25 | contentTypeV1: h.HandleGet, | ||
26 | contentTypeV2: h.HandleGet, | ||
27 | }, | ||
28 | } | ||
29 | r.GET(prefix, gh.Handle, mw...) | ||
30 | |||
31 | ph := &controller.ContentTypeNegotiatingHandler{ | ||
32 | DefaultHandler: h.HandlePut, | ||
33 | Handlers: map[string]echo.HandlerFunc{ | ||
34 | contentTypeV1: h.HandlePut, | ||
35 | contentTypeV2: h.HandlePut, | ||
36 | }, | ||
37 | } | ||
38 | r.PUT(prefix, ph.Handle, mw...) | ||
39 | |||
40 | poh := &controller.ContentTypeNegotiatingHandler{ | ||
41 | DefaultHandler: h.HandlePost, | ||
42 | Handlers: map[string]echo.HandlerFunc{ | ||
43 | contentTypeV1: h.HandlePost, | ||
44 | contentTypeV2: h.HandlePost, | ||
45 | }, | ||
46 | } | ||
47 | r.POST(prefix, poh.Handle, mw...) | ||
48 | |||
49 | r.DELETE(prefix, h.HandleDelete, mw...) | ||
50 | } | ||
51 | |||
52 | func (h *APIUserHandler) HandleGet(c echo.Context) error { | ||
53 | u, err := h.Store.Get(context.Background(), c.Param("user")) | ||
54 | if err != nil { | ||
55 | return echo.ErrInternalServerError | ||
56 | } | ||
57 | |||
58 | return c.JSON(http.StatusOK, u) | ||
59 | } | ||
60 | |||
61 | func validateKeysAndTokens(in *models.User) error { | ||
62 | for k, v := range in.Keys { | ||
63 | if k != v.KeyId { | ||
64 | return &echo.HTTPError{ | ||
65 | Code: http.StatusBadRequest, | ||
66 | Message: "Key ID must match hash key.", | ||
67 | } | ||
68 | } | ||
69 | |||
70 | if v.PrivateKey == nil && v.PublicKey == nil { | ||
71 | return &echo.HTTPError{ | ||
72 | Code: http.StatusBadRequest, | ||
73 | Message: "One of public_key or private_key must be set", | ||
74 | } | ||
75 | } | ||
76 | |||
77 | if v.PrivateKey != nil && v.PublicKey != nil { | ||
78 | return &echo.HTTPError{ | ||
79 | Code: http.StatusBadRequest, | ||
80 | Message: "Only one of public_key or private_key may be set", | ||
81 | } | ||
82 | } | ||
83 | } | ||
84 | |||
85 | for k, v := range in.AuthTokens { | ||
86 | if k != v.Kind { | ||
87 | return &echo.HTTPError{ | ||
88 | Code: http.StatusBadRequest, | ||
89 | Message: "Token kind must match hash key.", | ||
90 | } | ||
91 | } | ||
92 | } | ||
93 | |||
94 | return nil | ||
95 | } | ||
96 | |||
97 | func (h *APIUserHandler) HandlePut(c echo.Context) error { | ||
98 | var in models.User | ||
99 | if err := c.Echo().JSONSerializer.Deserialize(c, &in); err != nil { | ||
100 | return echo.ErrBadRequest | ||
101 | } | ||
102 | |||
103 | u, err := h.Store.Get(context.Background(), c.Param("user")) | ||
104 | if err != nil { | ||
105 | return echo.ErrInternalServerError | ||
106 | } | ||
107 | |||
108 | if in.Username != u.Username { | ||
109 | return &echo.HTTPError{ | ||
110 | Code: http.StatusBadRequest, | ||
111 | Message: "Username can not be changed. Create a new user.", | ||
112 | } | ||
113 | } | ||
114 | |||
115 | if in.Deleted != nil && u.Deleted == nil { | ||
116 | return &echo.HTTPError{ | ||
117 | Code: http.StatusBadRequest, | ||
118 | Message: "Use the DELETE method to delete a record", | ||
119 | } | ||
120 | } | ||
121 | |||
122 | if err := validateKeysAndTokens(&in); err != nil { | ||
123 | return err | ||
124 | } | ||
125 | |||
126 | err = h.Store.Put(context.Background(), &in) | ||
127 | if err != nil { | ||
128 | return echo.ErrInternalServerError | ||
129 | } | ||
130 | |||
131 | return c.String(http.StatusNoContent, "") | ||
132 | } | ||
133 | |||
134 | func (h *APIUserHandler) HandlePost(c echo.Context) error { | ||
135 | var in models.User | ||
136 | if err := c.Echo().JSONSerializer.Deserialize(c, &in); err != nil { | ||
137 | return echo.ErrBadRequest | ||
138 | } | ||
139 | |||
140 | _, err := h.Store.Get(context.Background(), c.Param("user")) | ||
141 | if err == nil { | ||
142 | return &echo.HTTPError{ | ||
143 | Code: http.StatusConflict, | ||
144 | Message: "User with username already exists.", | ||
145 | } | ||
146 | } | ||
147 | |||
148 | if in.Deleted != nil { | ||
149 | return &echo.HTTPError{ | ||
150 | Code: http.StatusBadRequest, | ||
151 | Message: "Can not create deleted user, set Deleted to null", | ||
152 | } | ||
153 | } | ||
154 | |||
155 | if err := validateKeysAndTokens(&in); err != nil { | ||
156 | return err | ||
157 | } | ||
158 | |||
159 | err = h.Store.Put(context.Background(), &in) | ||
160 | if err != nil { | ||
161 | return echo.ErrInternalServerError | ||
162 | } | ||
163 | |||
164 | c.Response().Header().Add("Location", glecho.URLFor(c, "/api/user", in.Username).String()) | ||
165 | |||
166 | return c.String(http.StatusCreated, "") | ||
167 | } | ||
168 | |||
169 | func (h *APIUserHandler) HandleDelete(c echo.Context) error { | ||
170 | u, err := h.Store.Get(context.Background(), c.Param("user")) | ||
171 | if err != nil { | ||
172 | return echo.ErrInternalServerError | ||
173 | } | ||
174 | |||
175 | err = h.Store.Delete(context.Background(), u) | ||
176 | if err != nil { | ||
177 | return echo.ErrInternalServerError | ||
178 | } | ||
179 | |||
180 | return c.String(http.StatusNoContent, "") | ||
181 | } | ||
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 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "net/http" | ||
6 | "time" | ||
7 | |||
8 | "code.crute.us/mcrute/cloud-identity-broker/app/models" | ||
9 | |||
10 | glecho "code.crute.us/mcrute/golib/echo" | ||
11 | "code.crute.us/mcrute/golib/echo/controller" | ||
12 | "github.com/labstack/echo/v4" | ||
13 | ) | ||
14 | |||
15 | type jsonListUser struct { | ||
16 | Username string `bson:"_id" json:"key_id"` | ||
17 | IsAdmin bool `json:"is_admin"` | ||
18 | IsService bool `json:"is_service"` | ||
19 | SelfLink string `json:"self"` | ||
20 | Deleted *time.Time `json:"deleted,omitempty"` | ||
21 | } | ||
22 | |||
23 | type APIUserListHandler struct { | ||
24 | store models.UserStore | ||
25 | } | ||
26 | |||
27 | func NewAPIUserListHandler(s models.UserStore) echo.HandlerFunc { | ||
28 | al := &APIUserListHandler{store: s} | ||
29 | h := &controller.ContentTypeNegotiatingHandler{ | ||
30 | DefaultHandler: al.Handle, | ||
31 | Handlers: map[string]echo.HandlerFunc{ | ||
32 | contentTypeV2: al.Handle, | ||
33 | }, | ||
34 | } | ||
35 | return h.Handle | ||
36 | } | ||
37 | |||
38 | func (h *APIUserListHandler) Handle(c echo.Context) error { | ||
39 | users, err := h.store.List(context.Background()) | ||
40 | if err != nil { | ||
41 | return echo.ErrInternalServerError | ||
42 | } | ||
43 | |||
44 | out := map[string]*jsonListUser{} | ||
45 | for _, v := range users { | ||
46 | out[v.Username] = &jsonListUser{ | ||
47 | Username: v.Username, | ||
48 | IsAdmin: v.IsAdmin, | ||
49 | IsService: v.IsService, | ||
50 | SelfLink: glecho.URLFor(c, "/api/user", v.Username).String(), | ||
51 | Deleted: v.Deleted, | ||
52 | } | ||
53 | } | ||
54 | |||
55 | return c.JSON(http.StatusOK, out) | ||
56 | } | ||
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 @@ | |||
1 | package middleware | ||
2 | |||
3 | import ( | ||
4 | "github.com/labstack/echo/v4" | ||
5 | ) | ||
6 | |||
7 | func RequireAdminPrivileges(next echo.HandlerFunc) echo.HandlerFunc { | ||
8 | return func(c echo.Context) error { | ||
9 | p, err := GetAuthorizedPrincipal(c) | ||
10 | if err != nil { | ||
11 | return echo.ErrUnauthorized | ||
12 | } | ||
13 | |||
14 | if !p.IsAdmin { | ||
15 | return echo.NotFoundHandler(c) | ||
16 | } | ||
17 | |||
18 | return next(c) | ||
19 | } | ||
20 | } | ||
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 | |||
2 | 2 | ||
3 | import ( | 3 | import ( |
4 | "context" | 4 | "context" |
5 | "time" | ||
5 | 6 | ||
6 | "code.crute.us/mcrute/golib/db/mongodb" | 7 | "code.crute.us/mcrute/golib/db/mongodb" |
8 | "go.mongodb.org/mongo-driver/bson" | ||
9 | "go.mongodb.org/mongo-driver/bson/primitive" | ||
7 | ) | 10 | ) |
8 | 11 | ||
9 | const userCol = "users" | 12 | const userCol = "users" |
@@ -16,17 +19,21 @@ type UserStore interface { | |||
16 | } | 19 | } |
17 | 20 | ||
18 | type AuthToken struct { | 21 | type AuthToken struct { |
19 | Kind string | 22 | Kind string `json:"kind"` |
20 | Token string | 23 | Token string `json:"token"` |
21 | RefreshToken string | 24 | |
25 | // Do not expose refresh tokens in JSON as they are long-lived tokens that | ||
26 | // are harder to invalidate and thus rather security sensitive. | ||
27 | RefreshToken string `json:"-"` | ||
22 | } | 28 | } |
23 | 29 | ||
24 | type User struct { | 30 | type User struct { |
25 | Username string `bson:"_id"` | 31 | Username string `bson:"_id" json:"key_id"` |
26 | IsAdmin bool | 32 | IsAdmin bool `json:"is_admin"` |
27 | IsService bool | 33 | IsService bool `json:"is_service"` |
28 | Keys map[string]*SessionKey // kid -> key | 34 | Keys map[string]*SessionKey `json:"keys,omitempty"` // kid -> key |
29 | AuthTokens map[string]*AuthToken // kind -> token | 35 | AuthTokens map[string]*AuthToken `json:"auth_tokens,omitempty"` // kind -> token |
36 | Deleted *time.Time `json:"deleted,omitempty"` | ||
30 | } | 37 | } |
31 | 38 | ||
32 | // GCKeys garbage collects keys that are no longer valid | 39 | // GCKeys garbage collects keys that are no longer valid |
@@ -64,21 +71,41 @@ func (u *User) AddToken(t *AuthToken) { | |||
64 | 71 | ||
65 | type MongoDbUserStore struct { | 72 | type MongoDbUserStore struct { |
66 | Db *mongodb.Mongo | 73 | Db *mongodb.Mongo |
74 | |||
75 | // ReturnDeleted will allow all methods to return deleted items. By default | ||
76 | // items where the Deleted field is set will not be returned. This should | ||
77 | // be the common cast for most code using this store but in some Admin | ||
78 | // use-cases it would be useful to show deleted accounts. | ||
79 | ReturnDeleted bool | ||
67 | } | 80 | } |
68 | 81 | ||
69 | func (s *MongoDbUserStore) List(ctx context.Context) ([]*User, error) { | 82 | func (s *MongoDbUserStore) List(ctx context.Context) ([]*User, error) { |
70 | var out []*User | 83 | var out []*User |
71 | if err := s.Db.FindAll(ctx, userCol, &out); err != nil { | 84 | |
85 | filter := bson.M{} | ||
86 | if !s.ReturnDeleted { | ||
87 | filter["deleted"] = primitive.Null{} | ||
88 | } | ||
89 | |||
90 | if err := s.Db.FindAllByFilter(ctx, userCol, filter, &out); err != nil { | ||
72 | return nil, err | 91 | return nil, err |
73 | } | 92 | } |
93 | |||
74 | return out, nil | 94 | return out, nil |
75 | } | 95 | } |
76 | 96 | ||
77 | func (s *MongoDbUserStore) Get(ctx context.Context, username string) (*User, error) { | 97 | func (s *MongoDbUserStore) Get(ctx context.Context, username string) (*User, error) { |
78 | var u User | 98 | var u User |
79 | if err := s.Db.FindOneById(ctx, userCol, username, &u); err != nil { | 99 | |
100 | filter := bson.M{"_id": username} | ||
101 | if !s.ReturnDeleted { | ||
102 | filter["deleted"] = primitive.Null{} | ||
103 | } | ||
104 | |||
105 | if err := s.Db.FindOneByFilter(ctx, userCol, filter, &u); err != nil { | ||
80 | return nil, err | 106 | return nil, err |
81 | } | 107 | } |
108 | |||
82 | return &u, nil | 109 | return &u, nil |
83 | } | 110 | } |
84 | 111 | ||
@@ -90,10 +117,15 @@ func (s *MongoDbUserStore) Put(ctx context.Context, u *User) error { | |||
90 | } | 117 | } |
91 | 118 | ||
92 | func (s *MongoDbUserStore) Delete(ctx context.Context, u *User) error { | 119 | func (s *MongoDbUserStore) Delete(ctx context.Context, u *User) error { |
93 | if err := s.Db.DeleteOneById(ctx, userCol, u.Username); err != nil { | 120 | u, err := s.Get(ctx, u.Username) |
121 | if err != nil { | ||
94 | return err | 122 | return err |
95 | } | 123 | } |
96 | return nil | 124 | |
125 | now := time.Now() | ||
126 | u.Deleted = &now | ||
127 | |||
128 | return s.Put(ctx, u) | ||
97 | } | 129 | } |
98 | 130 | ||
99 | var _ UserStore = (*MongoDbUserStore)(nil) | 131 | 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) { | |||
108 | ReturnDeleted: true, | 108 | ReturnDeleted: true, |
109 | } | 109 | } |
110 | as := &models.MongoDbAccountStore{Db: mongo} | 110 | as := &models.MongoDbAccountStore{Db: mongo} |
111 | |||
112 | adminUserStore := &models.MongoDbUserStore{ | ||
113 | Db: mongo, | ||
114 | ReturnDeleted: true, | ||
115 | } | ||
111 | us := &models.MongoDbUserStore{Db: mongo} | 116 | us := &models.MongoDbUserStore{Db: mongo} |
112 | 117 | ||
113 | aws := &controllers.AWSAPI{Store: as} | 118 | aws := &controllers.AWSAPI{Store: as} |
@@ -156,6 +161,15 @@ func webMain(cfg app.Config, embeddedTemplates fs.FS, version string) { | |||
156 | AdminStore: adminAccountStore, | 161 | AdminStore: adminAccountStore, |
157 | }).Register("/:account", account) | 162 | }).Register("/:account", account) |
158 | } | 163 | } |
164 | |||
165 | user := api.Group("/user") | ||
166 | user.Use(middleware.RequireAdminPrivileges) | ||
167 | { | ||
168 | user.GET("", controllers.NewAPIUserListHandler(us)) | ||
169 | (&controllers.APIUserHandler{ | ||
170 | Store: adminUserStore, | ||
171 | }).Register("/:user", user) | ||
172 | } | ||
159 | } | 173 | } |
160 | s.GET("/favicon.ico", echo.NotFoundHandler) | 174 | s.GET("/favicon.ico", echo.NotFoundHandler) |
161 | s.GET("/logout", controllers.LogoutHandler) | 175 | s.GET("/logout", controllers.LogoutHandler) |