From 1516bf216ffe7ec1132d720d06b9954fa914e4ba Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Tue, 7 May 2024 11:08:31 +0530 Subject: [PATCH] Add `api` type user. --- cmd/users.go | 85 ++++++++++++++------ frontend/fontello/config.json | 28 +++---- frontend/src/assets/icons/fontello.css | 2 + frontend/src/assets/icons/fontello.woff2 | Bin 8028 -> 8140 bytes frontend/src/assets/style.scss | 15 +++- frontend/src/views/SubscriberForm.vue | 3 +- frontend/src/views/UserForm.vue | 96 +++++++++++++++-------- frontend/src/views/Users.vue | 30 ++++--- i18n/en.json | 8 +- internal/auth/auth.go | 32 ++++---- internal/core/users.go | 35 ++++++++- internal/migrations/v3.1.0.go | 6 +- internal/utils/utils.go | 34 ++++++++ models/models.go | 6 +- models/queries.go | 11 +-- queries.sql | 21 ++++- schema.sql | 6 +- 17 files changed, 299 insertions(+), 119 deletions(-) create mode 100644 internal/utils/utils.go diff --git a/cmd/users.go b/cmd/users.go index 54ddb3c8d..fbcf470df 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -2,12 +2,19 @@ package main import ( "net/http" + "regexp" "strconv" "strings" "time" + "github.com/knadh/listmonk/internal/utils" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" + "gopkg.in/volatiletech/null.v6" +) + +var ( + reUsername = regexp.MustCompile("^[a-zA-Z0-9_\\-\\.]+$") ) // handleGetUsers retrieves users. @@ -53,22 +60,33 @@ func handleCreateUser(c echo.Context) error { u.Username = strings.TrimSpace(u.Username) u.Name = strings.TrimSpace(u.Name) - u.Email = strings.TrimSpace(u.Email) - - if u.Name == "" { - u.Name = u.Username - } + email := strings.TrimSpace(u.Email.String) + // Validate fields. if !strHasLen(u.Username, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) } - - if u.PasswordLogin { - if !strHasLen(u.Password.String, 8, stdInputMaxLen) { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + if !reUsername.MatchString(u.Username) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + if u.Type != models.UserTypeAPI { + if !utils.ValidateEmail(email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email")) + } + if u.PasswordLogin { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } } + + u.Email = null.String{String: email, Valid: true} + } + + if u.Name == "" { + u.Name = u.Username } + // Create the user in the database. out, err := app.core.CreateUser(u) if err != nil { return err @@ -97,34 +115,49 @@ func handleUpdateUser(c echo.Context) error { // Validate. u.Username = strings.TrimSpace(u.Username) u.Name = strings.TrimSpace(u.Name) - u.Email = strings.TrimSpace(u.Email) - - if u.Name == "" { - u.Name = u.Username - } + email := strings.TrimSpace(u.Email.String) + // Validate fields. if !strHasLen(u.Username, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) } + if !reUsername.MatchString(u.Username) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } - if u.PasswordLogin { - if u.Password.String != "" { + if u.Type != models.UserTypeAPI { + if !utils.ValidateEmail(email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email")) + } + if u.PasswordLogin && u.Password.String != "" { if !strHasLen(u.Password.String, 8, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) } - } else { - // Get the existing user for password validation. - user, err := app.core.GetUser(id) - if err != nil { - return err - } - // If password login is enabled, but there's no password in the DB and there's no incoming - // password, throw an error. - if !user.HasPassword { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + if u.Password.String != "" { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + } else { + // Get the existing user for password validation. + user, err := app.core.GetUser(id) + if err != nil { + return err + } + + // If password login is enabled, but there's no password in the DB and there's no incoming + // password, throw an error. + if !user.HasPassword { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } } } + + u.Email = null.String{String: email, Valid: true} + } + + if u.Name == "" { + u.Name = u.Username } out, err := app.core.UpdateUser(id, u) diff --git a/frontend/fontello/config.json b/frontend/fontello/config.json index 90bcca42a..9cb2d79c2 100755 --- a/frontend/fontello/config.json +++ b/frontend/fontello/config.json @@ -600,6 +600,20 @@ "code": 59431, "src": "typicons" }, + { + "uid": "77025195d19e048302e8943e2da4cc75", + "css": "account-outline", + "code": 983059, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z", + "width": 1000 + }, + "search": [ + "account-outline" + ] + }, { "uid": "f4ad3f6d071a0bfb3a8452b514ed0892", "css": "vector-square", @@ -838,20 +852,6 @@ "account-off" ] }, - { - "uid": "77025195d19e048302e8943e2da4cc75", - "css": "account-outline", - "code": 983059, - "src": "custom_icons", - "selected": false, - "svg": { - "path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z", - "width": 1000 - }, - "search": [ - "account-outline" - ] - }, { "uid": "571120b7ff63feb71df85710d019302c", "css": "account-plus", diff --git a/frontend/src/assets/icons/fontello.css b/frontend/src/assets/icons/fontello.css index 41a0f96e3..ee4874e2e 100644 --- a/frontend/src/assets/icons/fontello.css +++ b/frontend/src/assets/icons/fontello.css @@ -75,6 +75,7 @@ /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ } + .mdi-view-dashboard-variant-outline:before { content: '\e800'; } /* '' */ .mdi-format-list-bulleted-square:before { content: '\e801'; } /* '' */ .mdi-newspaper-variant-outline:before { content: '\e802'; } /* '' */ @@ -115,6 +116,7 @@ .mdi-email-bounce:before { content: '\e825'; } /* '' */ .mdi-speedometer:before { content: '\e826'; } /* '' */ .mdi-warning-empty:before { content: '\e827'; } /* '' */ +.mdi-account-outline:before { content: '󰀓'; } /* '\f0013' */ .mdi-code:before { content: '󰅩'; } /* '\f0169' */ .mdi-logout-variant:before { content: '󰗽'; } /* '\f05fd' */ .mdi-wrench-outline:before { content: '󰯠'; } /* '\f0be0' */ diff --git a/frontend/src/assets/icons/fontello.woff2 b/frontend/src/assets/icons/fontello.woff2 index caa51743acc37cf081e278765de9f55341b7659f..5f729eea5fa7920ff75f4e52b114d6c29eaa0c51 100755 GIT binary patch literal 8140 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 literal 8028 zcmV-iAEV%RPew8T0RR9103Tcc4*&oF06f$H03QJW0RR9100000000000000000000 z0000SR0d!Ghc*Zf37iZO2nxbLyEO}100A}vBm;pk1Rw>4O$UY)41pXQ=QlND+|3Ld z2Zm+lJ4F#}90VxlakBsaYjQ(|LIvt(eGh@9l)EC#hcZYVtM!X1EVgCp0>ka5L2YDN-1)VnQdl8k?$W%Lcx%YNmA^0 zTsJFMm`seZR56%_JYs7>)NUP9Fkw=JhaW{Wc+m^{lDOtQC~4hZ*cJM zILs9WScO>kh95I<4#6mLk!k#>Siy>w-)l3us1jD-E0fB8y8$}DF(qs}qzOvrJr}i$ zT3z+E$A@cLK#1(%zNn^vJo-zX(YnQL^G$y$Ck38}bf?Mzy=8j^0z2)$yzeyG1Li_^Xws%q*Qnp!d) zI(6yRqgS7P2sIRk&W3j6Ql9Qn{4g{Lc1YQko6mk)DSJj~b|M1EASKFUF4rT-D?cgchroTgBwaZ79d(ZAXZ*rX9 zj-0_=^n=Fqqpr-DB!Fh~BV?qKuy5E)+BTQm!^ZZs>K1BKJ&jMyKD9Xa<` z!y#%0n}_K#lo>JdQVBI{l96K3xCdKFrWCb`3K)RwsR0d+0}WG}(R1FH4=xq8tf_=3 z6=R^NU{f_6>+H!T&eA}tzE7+=k+ZMwQ}vus%U@~ zyDfx@!K}1Q1%x|5q8Q9cZ!V2K6)b?$N}sb!7D^nlkQb}Y$9_CB8?7}XPXq0EqXDPZ zh)lN!k3zTsWRI6oxvQd)bPhFT*v#f^E-jbRvu3~hE8;bE*!_os^G(2Klc_lIrlh!? zn6-D}t>Y^OD5AB^i!}o)hM)kzfbSb8SJ%`Swx{fup{^jJn<|l6;F5*quF8lJInaeB zL1r1_!?#fQy>E}{HL`d>GGr1yd_8xZr

*1r)^mZp2fNRFzT8w*ZA}2`-c{Oqv%^ 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 e_l1KeSrN_Q5B?C^Px7ZlJcC - + diff --git a/frontend/src/views/UserForm.vue b/frontend/src/views/UserForm.vue index 22d9b2ac0..8dea4b882 100644 --- a/frontend/src/views/UserForm.vue +++ b/frontend/src/views/UserForm.vue @@ -13,14 +13,28 @@

{{ $t('globals.buttons.close') }} - + {{ $t('globals.buttons.save') }}
@@ -105,8 +122,10 @@ export default Vue.extend({ name: '', password: '', passwordLogin: false, + type: 'user', status: 'enabled', }, + apiToken: null, }; }, @@ -118,7 +137,7 @@ export default Vue.extend({ } if (this.isEditing) { - if (this.form.passwordLogin && this.form.password && this.form.password !== this.form.password2) { + if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password && this.form.password !== this.form.password2) { this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger'); return; } @@ -127,7 +146,7 @@ export default Vue.extend({ return; } - if (this.form.passwordLogin && this.form.password !== this.form.password2) { + if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password !== this.form.password2) { this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger'); return; } @@ -139,8 +158,15 @@ export default Vue.extend({ const form = { ...this.form, password_login: this.form.passwordLogin }; this.$api.createUser(form).then((data) => { this.$emit('finished'); - this.$parent.close(); this.$utils.toast(this.$t('globals.messages.created', { name: data.name })); + + // If the user is an API user, show the one-time token. + if (form.type === 'api') { + this.apiToken = data.password; + return; + } + + this.$parent.close(); }); }, @@ -152,6 +178,12 @@ export default Vue.extend({ this.$utils.toast(this.$t('globals.messages.updated', { name: data.name })); }); }, + + hasType(t) { + // If the user being edited is API, then the only valid field is API. + // Otherwise, all fields are valid except API. + return !this.$props.isEditing || (this.form.type === 'api' ? t === 'api' : t !== 'api'); + }, }, computed: { diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index fd4a54e41..91055188c 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -36,35 +36,37 @@ + + + {{ $t(`users.type.${props.row.type}`) }} + + {{ props.row.username }} +
{{ props.row.name }}
- + {{ $t(`users.status.${props.row.status}`) }} - - - @@ -119,6 +121,12 @@ import { mapState } from 'vuex'; import EmptyPlaceholder from '../components/EmptyPlaceholder.vue'; import UserForm from './UserForm.vue'; +const TYPE_ICONS = { + user: 'account-outline', + super: 'account-check-outline', + api: 'link-variant', +}; + export default Vue.extend({ components: { EmptyPlaceholder, @@ -201,6 +209,8 @@ export default Vue.extend({ }, ); }, + + getTypeIcon: (typ) => TYPE_ICONS[typ], }, computed: { diff --git a/i18n/en.json b/i18n/en.json index 2920121dd..27553d40e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -599,15 +599,19 @@ "users.logout": "Logout", "users.lastLogin": "Last login", "users.newUser": "New user", + "users.type": "Type", + "users.type.user": "User", + "users.type.super": "Super Admin", + "users.type.api": "API", "users.status.enabled": "Enabled", "users.status.disabled": "Disabled", - "users.status.super": "Super admin", "users.username": "Username", "users.usernameHelp": "Used with password login", "users.password": "Password", "users.invalidLogin": "Invalid username or password", "users.passwordRepeat": "Repeat password", - "users.passwordEnable": "Enable logging in with password", + "users.passwordEnable": "Enable password login", "users.passwordMismatch": "Passwords don't match", + "users.apiOneTimeToken": "Copy the API access token now. It will not be shown again.", "users.cantDelete": "User(s) couldn't be deleted. There has to be at least one 'super' user." } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index cca8d420d..0cd505977 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "context" "crypto/rand" + "crypto/subtle" "encoding/base64" "fmt" "io" @@ -49,8 +50,8 @@ type Config struct { } type Auth struct { - tokens map[string]struct{} - mut sync.RWMutex + tokens map[string][]byte + sync.RWMutex cfg oauth2.Config verifier *oidc.IDTokenVerifier @@ -81,22 +82,25 @@ func New(cfg Config) *Auth { } } -// SetTokens remembers a list of string API tokens that are used for authenticating -// API queries. -func (o *Auth) SetTokens(tokens []string) { - o.mut.Lock() - defer o.mut.Unlock() +// SetTokens caches tokens for authenticating API client calls. +func (o *Auth) SetAPITokens(tokens map[string][]byte) { + o.Lock() + defer o.Unlock() - o.tokens = make(map[string]struct{}, len(tokens)) - for _, t := range tokens { - o.tokens[t] = struct{}{} + o.tokens = make(map[string][]byte, len(tokens)) + for user, token := range tokens { + o.tokens[user] = []byte{} + copy(o.tokens[user], token) } } -// CheckToken validates an API token. -func (o *Auth) CheckToken(token string) bool { - _, ok := o.tokens[token] - return ok +// CheckAPIToken validates an API user+token. +func (o *Auth) CheckAPIToken(user string, token []byte) bool { + o.RLock() + t, ok := o.tokens[user] + o.RUnlock() + + return ok && subtle.ConstantTimeCompare(t, token) == 1 } // HandleOIDCCallback is the HTTP handler that handles the post-OIDC provider redirect callback. diff --git a/internal/core/users.go b/internal/core/users.go index 16e1c5b56..5fd09c979 100644 --- a/internal/core/users.go +++ b/internal/core/users.go @@ -4,9 +4,11 @@ import ( "database/sql" "net/http" + "github.com/knadh/listmonk/internal/utils" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" "github.com/lib/pq" + "gopkg.in/volatiletech/null.v6" ) // GetUsers retrieves all users. @@ -21,10 +23,14 @@ func (c *Core) GetUsers() ([]models.User, error) { if u.Password.String != "" { u.HasPassword = true u.PasswordLogin = true - u.Password.String = "" - u.Password.Valid = false + u.Password = null.String{} + out[n] = u } + + if u.Type == models.UserTypeAPI { + out[n].Email = null.String{} + } } return out, nil @@ -50,17 +56,38 @@ func (c *Core) GetUser(id int) (models.User, error) { // CreateUser creates a new user. func (c *Core) CreateUser(u models.User) (models.User, error) { var out models.User - if err := c.q.CreateUser.Get(&out, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Status); err != nil { + + // If it's an API user, generate a random token for password + // and set the e-mail to default. + if u.Type == models.UserTypeAPI { + // Generate a random admin password. + tk, err := utils.GenerateRandomString(32) + if err != nil { + return out, err + } + + u.Email = null.String{String: u.Username + "@api", Valid: true} + u.PasswordLogin = false + 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.Status); err != nil { return models.User{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err))) } + // Hide the password field in the response except for when the user type is an API token, + // where the frontend shows the token on the UI just once. + if u.Type != models.UserTypeAPI { + u.Password = null.String{Valid: false} + } + return out, nil } // UpdateUser updates a given user. func (c *Core) UpdateUser(id int, u models.User) (models.User, error) { - res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Status) + res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.Status) if err != nil { return models.User{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err))) diff --git a/internal/migrations/v3.1.0.go b/internal/migrations/v3.1.0.go index 8b893b0f5..eda08832c 100644 --- a/internal/migrations/v3.1.0.go +++ b/internal/migrations/v3.1.0.go @@ -15,8 +15,12 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger CREATE EXTENSION IF NOT EXISTS pgcrypto; BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN + CREATE TYPE user_type AS ENUM ('user', 'super', 'api'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN - CREATE TYPE user_status AS ENUM ('enabled', 'disabled', 'super'); + CREATE TYPE user_status AS ENUM ('enabled', 'disabled'); END IF; END$$; diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 000000000..a6b5f40cf --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,34 @@ +package utils + +import ( + "crypto/rand" + "net/mail" +) + +// ValidateEmail validates whether the given string is a correctly formed e-mail address. +func ValidateEmail(email string) bool { + // Since `mail.ParseAddress` parses an email address which can also contain an optional name component, + // here we check if incoming email string is same as the parsed email.Address. So this eliminates + // any valid email address with name and also valid address with empty name like ``. + em, err := mail.ParseAddress(email) + if err != nil || em.Address != email { + return false + } + + return true +} + +// GenerateRandomString generates a cryptographically random, alphanumeric string of length n. +func GenerateRandomString(n int) (string, error) { + const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + var bytes = make([]byte, n) + + if _, err := rand.Read(bytes); err != nil { + return "", err + } + for k, v := range bytes { + bytes[k] = dictionary[v%byte(len(dictionary))] + } + + return string(bytes), nil +} diff --git a/models/models.go b/models/models.go index 09547f27b..0ab7e5015 100644 --- a/models/models.go +++ b/models/models.go @@ -56,8 +56,9 @@ const ( ListOptinDouble = "double" // User. - UserTypeSuperadmin = "superadmin" + UserTypeSuperadmin = "super" UserTypeUser = "user" + UserTypeAPI = "api" UserStatusEnabled = "enabled" UserStatusDisabled = "disabled" @@ -151,8 +152,9 @@ type User struct { Username string `db:"username" json:"username"` Password null.String `db:"password" json:"password,omitempty"` PasswordLogin bool `db:"password_login" json:"password_login"` - Email string `db:"email" json:"email"` + Email null.String `db:"email" json:"email"` Name string `db:"name" json:"name"` + Type string `db:"type" json:"type"` Status string `db:"status" json:"status"` LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"` diff --git a/models/queries.go b/models/queries.go index 5b4a900fc..90f6343b0 100644 --- a/models/queries.go +++ b/models/queries.go @@ -108,11 +108,12 @@ type Queries struct { DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"` GetDBInfo string `query:"get-db-info"` - CreateUser *sqlx.Stmt `query:"create-user"` - UpdateUser *sqlx.Stmt `query:"update-user"` - DeleteUsers *sqlx.Stmt `query:"delete-users"` - GetUsers *sqlx.Stmt `query:"get-users"` - LoginUser *sqlx.Stmt `query:"login-user"` + CreateUser *sqlx.Stmt `query:"create-user"` + UpdateUser *sqlx.Stmt `query:"update-user"` + DeleteUsers *sqlx.Stmt `query:"delete-users"` + GetUsers *sqlx.Stmt `query:"get-users"` + GetAPITokens *sqlx.Stmt `query:"get-api-tokens"` + LoginUser *sqlx.Stmt `query:"login-user"` } // CompileSubscriberQueryTpl takes an arbitrary WHERE expressions diff --git a/queries.sql b/queries.sql index 22bba4d10..02f1dd79e 100644 --- a/queries.sql +++ b/queries.sql @@ -1028,7 +1028,18 @@ SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()), 'size_mb', (SELECT ROUND(pg_database_size((SELECT CURRENT_DATABASE()))/(1024^2)))) AS info; -- name: create-user -INSERT INTO users (username, password_login, password, email, name, status) VALUES($1, $2, (CASE WHEN $2 AND $3 != '' THEN CRYPT($3, GEN_SALT('bf')) ELSE NULL END), $4, $5, $6) RETURNING *; +INSERT INTO users (username, password_login, password, email, name, type, status) + VALUES($1, $2, ( + CASE + -- For user types with password_login enabled, bcrypt and store the hash of the password. + WHEN $6::user_type != 'api' AND $2 AND $3 != '' + THEN CRYPT($3, GEN_SALT('bf')) + WHEN $6 = 'api' + -- For APIs, store the password (token) as-is. + THEN $3 + ELSE NULL + END + ), $4, $5, $6, $7) RETURNING *; -- name: update-user UPDATE users SET @@ -1037,18 +1048,22 @@ UPDATE users SET password=(CASE WHEN $3 = TRUE THEN (CASE WHEN $4 != '' THEN CRYPT($4, GEN_SALT('bf')) ELSE password END) ELSE NULL END), email=(CASE WHEN $5 != '' THEN $5 ELSE email END), name=(CASE WHEN $6 != '' THEN $6 ELSE name END), - status=(CASE WHEN $7 != '' THEN $7::user_status ELSE status END) + type=(CASE WHEN $7 != '' THEN $7::user_type ELSE type END), + status=(CASE WHEN $8 != '' THEN $8::user_status ELSE status END) WHERE id=$1; -- name: delete-users WITH u AS ( - SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND status='super' + SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND type='super' ) DELETE FROM users WHERE id = ALL($1) AND (SELECT num FROM u) > 0; -- name: get-users SELECT * FROM users WHERE $1=0 OR id=$1 ORDER BY created_at; +-- name: get-api-tokens +SELECT username, password FROM users WHERE status='enabled' AND type='api'; + -- name: login-user WITH u AS ( SELECT * FROM users WHERE username=$1 AND status != 'disabled' AND password_login = TRUE diff --git a/schema.sql b/schema.sql index af29a0d88..d06c7d587 100644 --- a/schema.sql +++ b/schema.sql @@ -7,9 +7,10 @@ DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('r DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown'); DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint'); DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'tx'); -DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled', 'super'); +DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('user', 'super', 'api'); +DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled'); -CREATE EXTENSION pgcrypto; +CREATE EXTENSION IF NOT EXISTS pgcrypto; -- subscribers DROP TABLE IF EXISTS subscribers CASCADE; @@ -308,6 +309,7 @@ CREATE TABLE users ( password TEXT NULL, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + type user_type NOT NULL DEFAULT 'user', status user_status NOT NULL DEFAULT 'disabled', loggedin_at TIMESTAMP WITH TIME ZONE NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),