From 5024ded763a7234f8dfddf0e93f5399de916d793 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Mon, 15 Jul 2024 23:04:00 +0530 Subject: [PATCH] Add API user authentication to auth module with caching of creds on user CRUD. --- cmd/init.go | 7 +++++- cmd/roles.go | 10 +++++++++ cmd/users.go | 50 ++++++++++++++++++++++++++++++++++++------ internal/auth/auth.go | 22 +++++++++++-------- internal/core/users.go | 13 ++++++----- queries.sql | 2 +- 6 files changed, 81 insertions(+), 23 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 5f3dfb18..f039383e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -994,7 +994,12 @@ func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) *auth.Auth { Type: models.UserTypeAPI, } u.Role.ID = auth.SuperAdminRoleID - a.SetToken(username, u) + a.CacheAPIUsers([]models.User{u}) + } + + // Load all API users. + if err := cacheAPIUsers(co, a); err != nil { + lo.Fatalf("error loading API users: %v", err) } return a diff --git a/cmd/roles.go b/cmd/roles.go index c7a820d7..5813209c 100644 --- a/cmd/roles.go +++ b/cmd/roles.go @@ -76,6 +76,11 @@ func handleUpdateRole(c echo.Context) error { return err } + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{out}) } @@ -94,6 +99,11 @@ func handleDeleteRole(c echo.Context) error { return err } + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{true}) } diff --git a/cmd/users.go b/cmd/users.go index 830ec78b..cab08beb 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/knadh/listmonk/internal/auth" + "github.com/knadh/listmonk/internal/core" "github.com/knadh/listmonk/internal/utils" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" @@ -94,15 +95,20 @@ func handleCreateUser(c echo.Context) error { } // Create the user in the database. - out, err := app.core.CreateUser(u) + user, err := app.core.CreateUser(u) if err != nil { return err } - if out.Type != models.UserTypeAPI { - out.Password = null.String{} + if user.Type != models.UserTypeAPI { + user.Password = null.String{} } - return c.JSON(http.StatusOK, okResp{out}) + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{user}) } // handleUpdateUser handles user modification. @@ -170,13 +176,21 @@ func handleUpdateUser(c echo.Context) error { u.Name = u.Username } - out, err := app.core.UpdateUser(id, u) + // Update the user in the DB. + user, err := app.core.UpdateUser(id, u) if err != nil { return err } - out.Password = null.String{} - return c.JSON(http.StatusOK, okResp{out}) + // Clear the pasword before sending outside. + user.Password = null.String{} + + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{user}) } // handleDeleteUsers handles user deletion, either a single one (ID in the URI), or a list. @@ -199,6 +213,11 @@ func handleDeleteUsers(c echo.Context) error { return err } + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{true}) } @@ -250,3 +269,20 @@ func handleUpdateUserProfile(c echo.Context) error { return c.JSON(http.StatusOK, okResp{out}) } + +func cacheAPIUsers(co *core.Core, a *auth.Auth) error { + allUsers, err := co.GetUsers() + if err != nil { + return err + } + + apiUsers := make([]models.User, 0, len(allUsers)) + for _, u := range allUsers { + if u.Type == models.UserTypeAPI && u.Status == models.UserStatusEnabled { + apiUsers = append(apiUsers, u) + } + } + + a.CacheAPIUsers(apiUsers) + return nil +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ff3f7fb2..bbc40d7b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -68,7 +68,7 @@ type Callbacks struct { } type Auth struct { - tokens map[string]models.User + apiUsers map[string]models.User sync.RWMutex cfg Config @@ -86,7 +86,7 @@ func New(cfg Config, db *sql.DB, cb *Callbacks, lo *log.Logger) (*Auth, error) { cb: cb, log: lo, - tokens: map[string]models.User{}, + apiUsers: map[string]models.User{}, } // Initialize OIDC. @@ -138,17 +138,21 @@ func New(cfg Config, db *sql.DB, cb *Callbacks, lo *log.Logger) (*Auth, error) { return a, nil } -// SetToken caches tokens for authenticating API client calls. -func (o *Auth) SetToken(apiKey string, u models.User) { +// CacheAPIUsers caches API users for authenticating requests. +func (o *Auth) CacheAPIUsers(users []models.User) { o.Lock() - o.tokens[apiKey] = u + o.apiUsers = map[string]models.User{} + + for _, u := range users { + o.apiUsers[u.Username] = u + } o.Unlock() } -// GetToken validates an API user+token. -func (o *Auth) GetToken(user string, token string) (models.User, bool) { +// GetAPIToken validates an API user+token. +func (o *Auth) GetAPIToken(user string, token string) (models.User, bool) { o.RLock() - t, ok := o.tokens[user] + t, ok := o.apiUsers[user] o.RUnlock() if !ok || subtle.ConstantTimeCompare([]byte(t.Password.String), []byte(token)) != 1 { @@ -221,7 +225,7 @@ func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc { } // Validate the token. - user, ok := o.GetToken(key, token) + user, ok := o.GetAPIToken(key, token) if !ok { c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, "invalid API credentials")) return next(c) diff --git a/internal/core/users.go b/internal/core/users.go index 038e95c9..a31ea110 100644 --- a/internal/core/users.go +++ b/internal/core/users.go @@ -33,7 +33,7 @@ func (c *Core) GetUser(id int, username, email string) (models.User, error) { // CreateUser creates a new user. func (c *Core) CreateUser(u models.User) (models.User, error) { - var out models.User + var id int // If it's an API user, generate a random token for password // and set the e-mail to default. @@ -41,7 +41,7 @@ func (c *Core) CreateUser(u models.User) (models.User, error) { // Generate a random admin password. tk, err := utils.GenerateRandomString(32) if err != nil { - return out, err + return models.User{}, err } u.Email = null.String{String: u.Username + "@api", Valid: true} @@ -49,7 +49,7 @@ func (c *Core) CreateUser(u models.User) (models.User, error) { u.Password = null.String{String: tk, Valid: true} } - if err := c.q.CreateUser.Get(&out, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.RoleID, u.Status); err != nil { + if err := c.q.CreateUser.Get(&id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.RoleID, u.Status); err != nil { return models.User{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err))) } @@ -60,7 +60,8 @@ func (c *Core) CreateUser(u models.User) (models.User, error) { u.Password = null.String{Valid: false} } - return out, nil + out, err := c.GetUser(id, "", "") + return out, err } // UpdateUser updates a given user. @@ -75,7 +76,9 @@ func (c *Core) UpdateUser(id int, u models.User) (models.User, error) { return models.User{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper")) } - return c.GetUser(id, "", "") + out, err := c.GetUser(id, "", "") + + return out, err } // UpdateUserProfile updates the basic fields of a given uesr (name, email, password). diff --git a/queries.sql b/queries.sql index 4bd40537..10aaaa7e 100644 --- a/queries.sql +++ b/queries.sql @@ -1039,7 +1039,7 @@ INSERT INTO users (username, password_login, password, email, name, type, role_i THEN $3 ELSE NULL END - ), $4, $5, $6, $7, $8) RETURNING *; + ), $4, $5, $6, $7, $8) RETURNING id; -- name: update-user WITH u AS (